Files
ARMSX1/frontend/main.cpp
T

8757 lines
357 KiB
C++

#include <SDL.h>
#include <SDL_gamecontroller.h>
#include <SDL_render.h>
#include <algorithm>
#include <array>
#include <atomic>
#include <csignal>
#include <cstdarg>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <exception>
#include <filesystem>
#include <fstream>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <string_view>
#include <thread>
#include <utility>
#include <vector>
#if defined(_WIN32)
#ifndef NOMINMAX
#define NOMINMAX
#endif
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#if !defined(UWP_TARGET)
#include <dbghelp.h>
#endif
#elif !defined(__EMSCRIPTEN__) && !defined(__ANDROID__) && !defined(PSVITA_TARGET)
#if defined(__has_include)
#if __has_include(<execinfo.h>)
#include <dlfcn.h>
#include <execinfo.h>
#define PSXE_HAS_EXECINFO 1
#endif
#endif
#endif
#if defined(__EMSCRIPTEN__)
#include <emscripten/html5.h>
#endif
#if defined(__ANDROID__)
#include <jni.h>
#include <android/log.h>
// Boot-path tracing. The core's own diagnostics are file-backed and off by default
// (quiet=true / logging_enabled=false in settings.toml), so a silent early return in
// ArmsxApp::run() left no trace anywhere. These go straight to logcat under the same tag
// frontend/android_jni.cpp uses, unconditionally.
#define ARMSX_BOOTLOG(...) __android_log_print(ANDROID_LOG_INFO, "ARMSX-JNI", __VA_ARGS__)
#define ARMSX_BOOTERR(...) __android_log_print(ANDROID_LOG_ERROR, "ARMSX-JNI", __VA_ARGS__)
#else
#define ARMSX_BOOTLOG(...) ((void)0)
#define ARMSX_BOOTERR(...) ((void)0)
#endif
extern "C" {
#include "../psx/psx.h"
#include "../psx/perf.h"
#include "../psx/pgxp.h"
/* psx_cpu_set_widescreen_hack() — the GTE X-projection scale behind [video] widescreen_hack. */
#include "../psx/cpu.h"
#include "../psx/rewind.h"
#include "../psx/state.h"
#include "../psx/texrep.h"
/* [cheats] — the GameShark engine. Read psx/cheats.h before touching any of the four call
sites here: the format choice, where enablement is stored and why, and the per-frame cost
contract are all argued there. */
#include "../psx/cheats.h"
#include "../psx/dev/cdrom/cdrom.h"
#include "../psx/dev/gpu.h"
/* audioDiagSnapshot() reads SPUCNT, the mixer volumes and the reverb work-area pointers
directly; the mixer only ever needed the handle. */
#include "../psx/dev/spu.h"
#include "../psx/dev/input.h"
#include "../psx/dev/pad.h"
#include "../psx/dev/timer.h"
#include "../psx/input/multitap.h"
#include "../psx/input/sda.h"
#include "common.h"
#include "config.h"
#include "toml.h"
}
#include "achievements.h"
#include "archive.h"
#include "audio_queue_policy.h"
#include "host_stats.h"
#include "host_usage.h"
// ADPF CPU clock hint + emulation-thread affinity. Both are host scheduling levers, both are
// default-off, and both compile to empty bodies off Android — see frontend/perf_hint.h.
#include "perf_hint.h"
#include "pgo.h"
#include "render.h"
#include "sdl_subsystem_lease.h"
// IconsFontAwesome5.h (from fsui-lib's imgui) removed with the FSUI cut — the ICON_FA_* glyphs were
// used only by the deleted native menus.
#ifdef USE_HARDWARE
#include "gpu_hw.h"
#include "gpu_hw_gl.h"
#include "gpu_hw_rt.h"
#endif
#undef main
namespace {
constexpr Uint32 kPauseChordGraceMs = 120;
// One PAL frame is ~20 ms. OpenSL ES periods below this cadence are prone to draining the
// queue between frame pushes on Android; callers may request more, never less.
constexpr int kAudioMinimumBufferMs = 20;
constexpr bool SupportsManagedWindowSizing() {
#if defined(__ANDROID__) || defined(IOS_TARGET) || defined(__EMSCRIPTEN__) || defined(UWP_TARGET) || defined(PSVITA_TARGET)
return false;
#else
return true;
#endif
}
constexpr bool DefaultVsyncEnabled() {
#if defined(UWP_TARGET)
return false;
#else
return true;
#endif
}
#ifdef USE_HARDWARE
// Kept numerically compatible with armsx_render_backend_t (frontend/render.h) so the two
// never drift; the settings layer speaks GpuBackend, the presentation layer speaks
// armsx_render_backend_t, and RenderBackendFor() is the single conversion point.
enum class GpuBackend {
Software = 0,
SDLAccelerated = 1,
OpenGL = 2,
Vulkan = 3,
};
#endif
class ArmsxApp;
ArmsxApp* g_active_app = nullptr;
std::atomic_bool g_crash_reporting{false};
std::mutex g_pending_launch_lock;
std::vector<std::string> g_pending_launch_arguments;
std::vector<std::string> g_pending_web_errors;
// ---------------------------------------------------------------------------
// Embedded-host control surface.
//
// When the core is hosted *in-process* by another UI (the Android Jetpack Compose
// front-end via frontend/android_jni.cpp, or any other embedder), that UI runs on its
// own thread and needs to poke the emulation loop: pause, reset, screenshot, pad input.
// Every one of those requests is parked here under g_host_control_lock and drained by
// ArmsxApp::applyHostControlRequests() on the emulation thread — exactly the same
// mutex-guarded hand-off the pending-launch-argument queue already uses. Nothing below
// touches SDL or the psx_t from the caller's thread.
// ---------------------------------------------------------------------------
// Pad command kinds parked by the host.
enum class HostPadCommandKind {
Digital = 0,
Analog = 1,
};
// Players an embedded host may address. Four, because a Multitap in port 1 carries four
// pads (PSXI_MULTITAP_SLOTS); with no tap only player 0 has anywhere to go and the rest are
// dropped by psx_pad_*_player().
constexpr int kHostMaxPlayers = 4;
struct HostPadCommand {
HostPadCommandKind kind = HostPadCommandKind::Digital;
uint32_t mask = 0; // Digital: PSXI_SW_SDA_* bit.
bool pressed = false; // Digital.
int stick = 0; // Analog: 0 = left, 1 = right.
int x = 0x80; // Analog: 0x00..0xFF centred on 0x80.
int y = 0x80;
// Which player behind the port-1 multitap this is for (0..3). Always 0 without a tap,
// and psx_pad_*_player() drops anything above 0 in that case, so a routing mistake shows
// up as a dead player rather than two players sharing one pad.
int player = 0;
};
// Presentation-only live overrides. Unlike everything else the host parks below, these need
// no VM cooperation at all — the emulation thread just reads them on its way into present() —
// so they are plain atomics rather than queued requests. -1 means "no override, use
// settings.toml".
std::atomic<int> g_host_display_aspect{-1};
/* Custom display aspect (width/height) for display_aspect == 3. Stored as a float rather
than a ratio pair because that is all compute_dst consumes; <= 0 means "no live override,
use settings.toml". */
std::atomic<float> g_host_display_aspect_custom{0.0f};
/* Live integer-scaling override. -1 = no override, use settings.toml. */
std::atomic<int> g_host_integer_scaling{-1};
/* Portrait layout. -1 on portrait_top means "follow settings"; there is no settings key today,
so it resolves to ON — top-aligning is what the touch overlay assumes. */
std::atomic<int> g_host_portrait_top{-1};
std::atomic<int> g_host_portrait_top_inset{0};
/* Sanity bounds for a user-entered ratio. Wide enough for anything anyone would want
(1:2 through 4:1) and narrow enough that a typo cannot produce a degenerate dst rect. */
constexpr float kMinCustomAspect = 0.5f;
constexpr float kMaxCustomAspect = 4.0f;
// App is off-screen: skip the whole draw+post. A plain atomic rather than a parked request,
// because the loop reads it every tick and there is nothing to sequence against the
// emulation thread — it only gates presentation, never machine state.
std::atomic<bool> g_host_presentation_suspended{false};
std::atomic<int> g_host_stretch_mode{-1};
/* Rewind ENGAGEMENT, as opposed to its configuration above. Plain atomics rather than queued
requests, and deliberately: a hold-to-rewind button is read once per frame by the emulation
thread on its way into ArmsxSession::runFrame(), exactly like the presentation flags. Going
through the request lock would put the input path behind the same mutex as disc swaps.
active — the button is held; every frame steps one snapshot back.
steps — one-shot steps (a menu row / a tap), drained in the same place. */
std::atomic<bool> g_host_rewind_active{false};
std::atomic<int> g_host_rewind_step_requests{0};
/* ---- [video] display features the PRESENT path consumes, as live overrides ----------------
Same contract as g_host_stretch_mode above: -1 means "no host has spoken, defer to
settings.toml", anything else is the host's choice and outranks the file. The Android
front-end pushes all three through JNI before the core ever opens settings.toml, so a
file-derived value must never overwrite them — that is the per-game-INI-shadows-the-UI
failure in another shape. Read on the presenting thread once per frame. */
/* Texture dumping / replacement (psx/texrep.h), requested from the UI thread and applied on
the EMULATION thread once per frame (ArmsxApp::updateTexture). psx_texrep_configure() frees
every decoded replacement, so calling it from the JNI thread while the rasterizers hold
psx_gpu_t::texrep_bind.img would be a use-after-free. -1 on the two ints means "no request
pending"; the string is guarded by its own mutex because it is not trivially atomic. */
std::mutex g_host_texture_lock;
std::string g_host_texture_dir;
std::atomic<int> g_host_texture_dump{-1};
std::atomic<int> g_host_texture_replace{-1};
std::atomic<bool> g_host_texture_pending{false};
std::atomic<int> g_host_deinterlace{-1}; // 0 weave, 1 bob, 2 adaptive
std::atomic<int> g_host_overscan_crop{-1}; // 0 none, 1 small, 2 full
std::atomic<int> g_host_display_rotation{-1}; // quarter turns clockwise 0..3
std::mutex g_host_control_lock;
bool g_host_embedded = false;
bool g_host_pause_pending = false;
bool g_host_pause_value = false;
bool g_host_audio_suspend_pending = false;
bool g_host_audio_suspend_value = false;
bool g_host_shutdown_pending = false;
bool g_host_reset_pending = false;
bool g_host_fast_forward_pending = false;
bool g_host_fast_forward_value = false;
bool g_host_speed_limits_pending = false;
bool g_host_speed_limit_frame_limit = true;
int g_host_speed_limit_percent = 100;
int g_host_speed_limit_fps = 0;
double g_host_speed_limit_fast_forward = 2.0;
int g_host_speed_limit_frame_skip = 0;
bool g_host_screenshot_pending = false;
std::string g_host_screenshot_path;
/* Disc swap. Parked here rather than performed on the calling (UI) thread: psx_swap_disc()
reopens the image under the running machine, and doing that while the emulation thread is
mid-instruction is a use-after-free waiting to happen. Drained at the same instruction
boundary as every other host request. */
bool g_host_disc_swap_pending = false;
std::string g_host_disc_swap_path;
std::vector<HostPadCommand> g_host_pad_queue;
/* [input] multitap, live. Parked like every other request that touches the machine: the
toggle destroys and rebuilds the device the SIO reads through, which is not something to
do from the UI thread mid-instruction. */
bool g_host_multitap_pending = false;
bool g_host_multitap_value = false;
/* [emulation] rewind / runahead configuration, same reasoning: psx_rewind_configure() frees
and allocates the snapshot ring. */
bool g_host_rewind_config_pending = false;
bool g_host_rewind_config_enabled = false;
int g_host_rewind_config_seconds = PSX_REWIND_DEFAULT_SECONDS;
int g_host_rewind_config_frequency = PSX_REWIND_DEFAULT_FREQUENCY;
bool g_host_runahead_pending = false;
int g_host_runahead_value = 0;
void (*g_host_present_callback)(void*) = nullptr;
void* g_host_present_user = nullptr;
std::atomic_bool g_host_vm_active{false};
std::atomic_bool g_host_loop_running{false};
// Set by an embedded host that has wired up everything SDL's non-OpenSL-ES Android audio
// backends need (SDLAudioManager's JNI glue AND its static Context). Until it is, the
// [audio] driver setting is clamped to openslES — see ArmsxApp::applyAudioDriverSetting().
std::atomic_bool g_host_audio_backends_ready{false};
// Read-only session telemetry for an embedded host. The Android front-end draws its OSD in
// Compose (there is no imgui/FSUI left to draw one natively), so the numbers the core used to
// print for itself have to be published instead. Plain atomics rather than the request lock:
// they are written once per frame here and polled from the UI thread, and a torn read of a
// frame rate is worth less than making the OSD contend with the emulation loop.
std::atomic<float> g_host_measured_fps{0.0f};
std::atomic<float> g_host_nominal_fps{0.0f};
std::atomic<unsigned int> g_host_presented_frames{0u};
// The full performance overlay (per-subsystem work counters + host phase timing) is opt-in
// and OFF here, so a player who never opens it pays for none of it: this flag also arms the
// core-side counters through psx_perf_set_enabled(), and the phase timers below are skipped
// entirely while it is clear. PSXE_HOST_STAT_* indices, frontend/host_stats.h.
std::atomic_bool g_host_stats_enabled{false};
std::mutex g_host_stats_lock;
double g_host_stats[PSXE_HOST_STAT_COUNT] = {};
// Has a window ever closed and published into g_host_stats since the overlay was last armed?
// psxe_host_stats() is documented to write nothing until it has, and the UI needs that: an
// unpublished snapshot is all zeroes, and a screenful of zeroes reads as a measurement ("your
// CPU is doing nothing") rather than as no data. Guarded by g_host_stats_lock.
bool g_host_stats_published = false;
// `perf_log`, a marker file next to the diag log (the same mechanism frontend/gpu_hw_gl.c
// uses for its A/B switches — `adb shell run-as com.nanodata.armsx touch files/logs/perf_log`).
// When it is present the overlay is armed from here and its snapshot is written to the diag
// log every window, which is what makes a measurement a number in a file rather than a
// screenshot to be read by eye. Absent — the shipping default — nothing below runs at all.
// Evaluated once, on first use, so it is read after psxe_diag_initialize() has a path.
bool perfLogEnabled() {
static const bool enabled = [] {
const char* log_path = psxe_diag_log_path();
if (!log_path || !log_path[0]) {
return false;
}
const char* slash = std::strrchr(log_path, '/');
if (!slash) {
return false;
}
std::string path(log_path, static_cast<size_t>(slash - log_path) + 1u);
path += "perf_log";
FILE* file = std::fopen(path.c_str(), "rb");
if (!file) {
return false;
}
std::fclose(file);
return true;
}();
return enabled;
}
/*
`resume_probe` — the marker that settles "which side came back empty" after a task switch.
A black picture after returning from the background has two candidate causes that look
identical from the outside, and reading the code cannot separate them:
* the SOURCE is empty — the GLES rasterizer's render target (or the software framebuffer)
lost its contents, so a correct present layer is faithfully presenting nothing;
* the PRESENT side is empty — the source is intact and the window / swapchain / presented
image is what came back dead.
Armed, the first few frames the frontend uploads after presentation resumes are scanned for
a single non-zero byte and the answer is written to the diag log:
nonzero=0 the source came back empty -> the rasterizer side
nonzero>0 the source is intact -> the present side
`touch files/logs/resume_probe` to arm; absent (the shipping default) this costs one integer
compare per frame and nothing else. Same marker mechanism as `perf_log` above and as
frontend/gpu_hw_gl.c's gl_debug_marker().
*/
bool resumeProbeEnabled() {
static const bool enabled = [] {
const char* log_path = psxe_diag_log_path();
if (!log_path || !log_path[0]) {
return false;
}
const char* slash = std::strrchr(log_path, '/');
if (!slash) {
return false;
}
std::string path(log_path, static_cast<size_t>(slash - log_path) + 1u);
path += "resume_probe";
FILE* file = std::fopen(path.c_str(), "rb");
if (!file) {
return false;
}
std::fclose(file);
return true;
}();
return enabled;
}
// Frames still to be sampled by the probe above. Set on the suspended -> resumed edge by the
// main loop, consumed in ArmsxSession::updateTexture(). Emulation thread only.
int g_resume_probe_frames = 0;
// Previous value of g_host_presentation_suspended as the loop last saw it, so the edge can be
// detected without another atomic.
bool g_presentation_was_suspended = false;
// Invoked on the emulation thread once per frame, right after the frame has been
// presented into the (external) renderer. The Android host uses it to push the software
// framebuffer into its ANativeWindow.
void HostNotifyFramePresented() {
void (*callback)(void*) = nullptr;
void* user = nullptr;
{
std::lock_guard<std::mutex> lock(g_host_control_lock);
callback = g_host_present_callback;
user = g_host_present_user;
}
if (callback) {
callback(user);
}
}
constexpr double kUiFrameRate = 60.0;
// Frame skip ([runtime] frame_skip). Presentation only — see ArmsxApp::shouldSkipPresent().
//
// kMaxFrameSkip caps the fixed modes at "present 1 of every 6", which is ~10 fps on screen for
// a 60 Hz game: past that the picture stops being usable and the remaining saving is small,
// because what is left in the frame is emulation, not presentation.
//
// kAdaptiveFrameSkipRun is the FLOOR the adaptive mode may never go below: at most two dropped
// presents in a row, i.e. never worse than 1-in-3 (~20 fps at 60). Adaptive reacts to lateness,
// and a device that is late every single frame would otherwise stop drawing entirely.
constexpr int kMaxFrameSkip = 5;
constexpr int kAdaptiveFrameSkipRun = 2;
const char* LogLevelTitle(int level) {
switch (level) {
case LOG_TRACE: return "trace";
case LOG_DEBUG: return "debug";
case LOG_INFO: return "info";
case LOG_WARN: return "warn";
case LOG_ERROR: return "error";
case LOG_FATAL: return "fatal";
default: return "unknown";
}
}
void StructuredLogCallback(log_Event* ev) {
if (!ev) {
return;
}
// log_log() gates only its own stderr path on the level (psx/log.c); extra callbacks are
// filtered by the level they were registered with, and this one is registered at LOG_TRACE
// because it is installed before settings are loaded. Without this check log_set_level()
// does nothing for the diag file, and the per-frame register traces in dma.c/gpu.c/cpu.c
// land in armsx.log at ~850 lines/s on the emulation thread whatever log_level says.
if (ev->level < log_get_level()) {
return;
}
char message[4096] = {};
vsnprintf(message, sizeof(message), ev->fmt, ev->ap);
psxe_diag_logf("psx", "%s %s:%d %s", LogLevelTitle(ev->level), ev->file, ev->line, message);
}
void SdlLogOutput(void*, int category, SDL_LogPriority priority, const char* message) {
psxe_diag_logf("sdl", "category=%d priority=%d %s", category, static_cast<int>(priority), message ? message : "");
}
[[noreturn]] void ReportNativeCrash(const char* reason);
#if defined(_WIN32) && !defined(UWP_TARGET)
LONG WINAPI WindowsUnhandledExceptionFilter(EXCEPTION_POINTERS* exception_info) {
const DWORD code = exception_info && exception_info->ExceptionRecord
? exception_info->ExceptionRecord->ExceptionCode
: 0u;
char reason[64] = {};
SDL_snprintf(reason, sizeof(reason), "SEH 0x%08lx", static_cast<unsigned long>(code));
ReportNativeCrash(reason);
return EXCEPTION_EXECUTE_HANDLER;
}
#endif
double CounterTicksToMilliseconds(uint64_t ticks) {
const uint64_t frequency = SDL_GetPerformanceFrequency();
if (frequency == 0) {
return 0.0;
}
return (static_cast<double>(ticks) * 1000.0) / static_cast<double>(frequency);
}
std::string RendererFlagsTitle(Uint32 flags) {
std::string title;
auto append = [&](const char* value) {
if (!title.empty()) {
title.append("|");
}
title.append(value);
};
if (flags & SDL_RENDERER_SOFTWARE) {
append("software");
}
if (flags & SDL_RENDERER_ACCELERATED) {
append("accelerated");
}
if (flags & SDL_RENDERER_PRESENTVSYNC) {
append("present-vsync");
}
if (flags & SDL_RENDERER_TARGETTEXTURE) {
append("target-texture");
}
if (title.empty()) {
title = "none";
}
return title;
}
[[noreturn]] void ReportNativeCrash(const char* reason);
void WriteNativeStackTraceImpl();
enum class LaunchKind {
None,
Bios,
Disc,
Exe,
};
const char* CpuEngineTitle(psx_cpu_execution_mode_t mode) {
return mode == PSX_CPU_INTERPRETER ? "Interpreter" : "Cached interpreter";
}
const char* CpuEngineSettingToken(psx_cpu_execution_mode_t mode) {
return mode == PSX_CPU_INTERPRETER ? "interpreter" : "cached";
}
std::optional<psx_cpu_execution_mode_t> ParseCpuEngine(const char* value) {
if (!value || !value[0]) {
return std::nullopt;
}
std::string lowered(value);
std::transform(lowered.begin(), lowered.end(), lowered.begin(), [](unsigned char ch) {
return static_cast<char>(std::tolower(ch));
});
if (lowered == "interpreter" || lowered == "reference") {
return PSX_CPU_INTERPRETER;
}
if (lowered == "cached" || lowered == "cached-interpreter") {
return PSX_CPU_CACHED_INTERPRETER;
}
return std::nullopt;
}
const char* LaunchKindTitle(LaunchKind kind) {
switch (kind) {
case LaunchKind::Bios: return "bios";
case LaunchKind::Disc: return "disc";
case LaunchKind::Exe: return "exe";
case LaunchKind::None:
default:
return "none";
}
}
struct CliFlags {
bool bios = false;
bool bios_folder = false;
bool model = false;
bool region = false;
bool scale = false;
bool settings_file = false;
bool quiet = false;
bool log_level = false;
bool exp_rom = false;
bool exe = false;
bool cdrom = false;
bool cpu_engine = false;
bool has_boot_request = false;
};
struct LaunchRequest {
LaunchKind kind = LaunchKind::None;
std::filesystem::path path;
std::string label;
};
void EnqueuePendingLaunchArgument(std::string argument) {
if (argument.empty()) {
return;
}
std::lock_guard<std::mutex> lock(g_pending_launch_lock);
g_pending_launch_arguments.push_back(std::move(argument));
}
std::vector<std::string> DrainPendingLaunchArguments() {
std::lock_guard<std::mutex> lock(g_pending_launch_lock);
std::vector<std::string> pending;
pending.swap(g_pending_launch_arguments);
return pending;
}
void EnqueueWebError(std::string message) {
if (message.empty()) {
return;
}
std::lock_guard<std::mutex> lock(g_pending_launch_lock);
g_pending_web_errors.push_back(std::move(message));
}
std::vector<std::string> DrainWebErrors() {
std::lock_guard<std::mutex> lock(g_pending_launch_lock);
std::vector<std::string> pending;
pending.swap(g_pending_web_errors);
return pending;
}
// Local replacement for fsui::UiState after the FSUI cut. Holds only the fields the non-menu
// code still reads (library scan paths + sort/view prefs persisted in settings.toml, and the two
// overlay flags a couple of call sites poke). Menus are owned by the Jetpack Compose front-end now.
struct ArmsxUiState {
int default_game_view = 0;
int game_sort = 2;
bool game_sort_reverse = false;
std::vector<std::filesystem::path> game_list_paths;
std::vector<std::filesystem::path> game_list_recursive_paths;
std::filesystem::path covers_path;
bool show_settings_overlay = false;
bool show_performance_overlay = false;
};
// Local replacement for fsui::CurrentGameInfo after the FSUI cut (used only by crash-context logging).
struct ArmsxGameInfo {
bool has_game = false;
std::string title;
std::string subtitle;
std::string title_id;
std::filesystem::path path;
};
struct FrontendSettings {
std::string settings_path;
std::string bios_override;
std::string bios_search = "bios";
std::string model = "scph1001";
std::string region = "auto";
std::string exp_path;
std::string default_exe_path;
int scale = 3;
int log_level = LOG_INFO;
bool quiet = true;
bool logging_enabled = false;
bool vsync_enabled = DefaultVsyncEnabled();
psx_cpu_execution_mode_t cpu_engine = PSX_CPU_CACHED_INTERPRETER;
#ifdef USE_HARDWARE
GpuBackend gpu_backend = GpuBackend::Software;
// Rasterizer, orthogonal to gpu_backend above (which is the *presentation* path).
// false keeps psx/dev/gpu.c's software rasterizer, which is the default.
bool hw_rasterizer = false;
// 1 = auto (GLES when a GL context exists, CPU otherwise), 2 = force CPU, 3 = force
// GLES. See config.h; the UI only ever writes 0 or 1.
int rasterizer_mode = 0;
int internal_scale = 1;
#endif
// Opt-in GPU accuracy fixes. Both change how games look relative to every previous
// release, so both default off and both apply to the software path and any backend
// alike (psx_gpu_set_accuracy_flags) so the 1x parity gate holds either way.
bool accurate_mask_bit = false;
bool accurate_dither = false;
// Hardware's polygon size cull (1023x511). See psx_gpu_prim_oversize().
bool accurate_prim_size = false;
// Hardware's truncating texture blend. See psx_gpu_modulate_channel().
bool accurate_tex_modulate = false;
// PGXP (psx/pgxp.c): sub-pixel vertex precision. Core-global and default off;
// only the hardware rasterizer backends consume the precise coordinates.
bool pgxp = false;
/* ---- [video] display/video feature set. All default OFF/neutral; the native gates below
are only reachable because these defaults match frontend/config.c's exactly. */
bool widescreen_hack = false;
int texture_filter = 0; // 0 nearest, 1 bilinear, 2 xBR-style (GLES rasterizer)
/* Texture dumping / replacement (psx/texrep.h). Both default OFF and the subsystem
allocates nothing until one is on. texture_dir is the BASE folder; empty means
"<prefs>/textures", which ResolveTextureDir() below fills in. */
bool texture_dump = false;
bool texture_replacements = false;
std::string texture_dir;
int downsample = 0; // 0 off, 2..8 box factor (GLES rasterizer, above 1x)
int deinterlace = 0; // 0 weave, 1 bob, 2 adaptive (480-line modes only)
int overscan_crop = 0; // 0 none, 1 small, 2 full
int display_rotation = 0; // quarter turns clockwise, 0..3
int line_detect = 0; // 0 disabled, 1 quads, 2 basic (GLES rasterizer)
bool texture_scale_mode = false;
bool debug_panel = false;
bool stretch_mode = false;
int display_aspect = 0;
// Width/height for display_aspect == 3. 16:9 as the starting point because that is what
// a user reaching for "custom" on a widescreen handheld most often wants.
float display_aspect_custom = 16.0f / 9.0f;
// Snap the output to a whole multiple of the source. Off by default: it shrinks the
// picture (a 1920x1080 window fits only 4x of a 240-line frame = 960 tall), which is
// a deliberate trade the user opts into, not something to impose.
bool integer_scaling = false;
int upscale_height = 480;
// [runtime] frame pacing. frame_limit gates the limiter, speed_percent scales the game's
// own rate, fps_limit is an optional absolute ceiling on top (0 = none), and
// fast_forward_speed replaces speed_percent while fast-forward is engaged. A 0 multiplier
// (or frame_limit = false) means uncapped: the pacer stops waiting entirely.
bool frame_limit = true;
int speed_percent = 100;
int fps_limit = 0;
double fast_forward_speed = 2.0;
// [runtime] frame_skip. Orthogonal to the four above: they decide how fast the MACHINE
// runs, this decides how many of the frames it produces are actually put on screen.
// 0 = off, 1..5 = fixed (present one, skip N), -1 = adaptive. See ArmsxApp::shouldSkipPresent().
int frame_skip = 0;
// [runtime] host CPU scheduling levers, both EXPERIMENTAL and both off by default. Neither
// touches emulation: adpf_clock_hint reports per-frame work to Android's performance-hint
// service, affinity_mode pins the emulation thread to a core cluster. See perf_hint.h.
bool adpf_clock_hint = false;
int affinity_mode = 0;
// [audio]. Consumed by ArmsxSession's SDL device + mixer and by psx/dev/spu.c; see the
// per-field notes in frontend/config.h.
int audio_volume = 100;
int audio_ff_volume = 100;
bool audio_muted = false;
bool audio_mute_fast_forward = false;
bool audio_swap_channels = false;
bool audio_skip_reverb = false;
int audio_buffer_ms = 20;
int audio_driver = 1; // 0=SDL default, 1=openslES, 2=aaudio (Android only)
// Opt-in: keep emulating and playing while the app is off-screen. Off means the host's
// background notification parks the VM and stops the platform audio stream.
bool audio_background_playback = false;
// [input] analog_mode_default — which mode the emulated DualShock reports at boot.
// The pad's ANALOG button still toggles at runtime; this is only the starting state.
bool analog_mode_default = true;
// [input] multitap — a Multitap in port 1 (psx/input/multitap.c), four players on one
// port. Off by default: a tap answers the pad poll with its own ID, so a game that does
// not understand one sees NO controller at all.
bool multitap = false;
// [emulation] rewind / runahead. Both off by default and both documented in
// psx/rewind.h; rewind_seconds x rewind_frequency is a MEMORY figure (~3.5 MiB each).
bool rewind = false;
int rewind_seconds = PSX_REWIND_DEFAULT_SECONDS;
int rewind_frequency = PSX_REWIND_DEFAULT_FREQUENCY;
int runahead = 0;
// [cheats] — GameShark codes, applied once per frame by psx/cheats.c. `cheats_file` is a
// path to a `.cht` catalogue and `cheats_enabled_codes` names which of its entries are
// armed. Both are PER-GAME values written by the launcher, which is deliberate: an
// enablement list that could end up in a global layer is how the sibling PS2 project
// armed "Infinite Health" in every game a user owned. Empty by default, so a settings.toml
// written before this section existed arms nothing.
bool cheats_enabled = false;
std::string cheats_file;
std::vector<std::string> cheats_enabled_codes;
ArmsxUiState ui_state{};
};
struct PendingChordButton {
bool physical_down = false;
bool pending = false;
bool forwarded = false;
Uint32 pending_since = 0;
};
enum class FsuiWindowState {
None,
Landing,
StartGame,
Exit,
GameList,
Settings,
PauseMenu,
};
std::string ToLower(std::string_view value) {
std::string out(value);
std::transform(out.begin(), out.end(), out.begin(), [](unsigned char ch) {
return static_cast<char>(std::tolower(ch));
});
return out;
}
std::string Trim(std::string_view value) {
size_t begin = 0;
size_t end = value.size();
while ((begin < end) && std::isspace(static_cast<unsigned char>(value[begin]))) {
begin++;
}
while ((end > begin) && std::isspace(static_cast<unsigned char>(value[end - 1]))) {
end--;
}
return std::string(value.substr(begin, end - begin));
}
std::string NormalizeModel(std::string_view value) {
std::string out;
for (unsigned char ch : value) {
if (std::isalnum(ch)) {
out.push_back(static_cast<char>(std::tolower(ch)));
}
}
return out;
}
std::string DefaultSettingsPath() {
const char* pref = psxe_cfg_get_pref_path();
if (pref && pref[0]) {
return std::string(pref) + "settings.toml";
}
return "settings.toml";
}
std::filesystem::path DefaultBrowseDirectory() {
const char* pref = psxe_cfg_get_pref_path();
if (pref && pref[0]) {
return std::filesystem::path(pref);
}
return std::filesystem::current_path();
}
std::filesystem::path DefaultDiagnosticsLogPath() {
std::filesystem::path path = DefaultBrowseDirectory() / "logs";
#if defined(UWP_TARGET)
path /= "armsx-uwp.log";
#else
path /= "armsx.log";
#endif
return path;
}
std::string AppendBrowseRootHint(std::string summary) {
#if defined(_WIN32)
if (!summary.empty() && summary.back() != ' ') {
summary.push_back(' ');
}
summary += "Use Filesystem Roots or Parent Directory to switch drives.";
#endif
return summary;
}
bool PathsMatch(const std::filesystem::path& left, const std::filesystem::path& right) {
#if defined(_WIN32)
return ToLower(left.lexically_normal().generic_string()) == ToLower(right.lexically_normal().generic_string());
#else
return left.lexically_normal() == right.lexically_normal();
#endif
}
bool PathListContains(const std::vector<std::filesystem::path>& paths, const std::filesystem::path& candidate) {
return std::any_of(paths.begin(), paths.end(), [&](const std::filesystem::path& path) {
return PathsMatch(path, candidate);
});
}
bool FileContainsCaseInsensitive(const std::filesystem::path& path, const std::string& needle_lower) {
std::ifstream input(path, std::ios::binary);
if (!input.good()) {
return false;
}
std::string content((std::istreambuf_iterator<char>(input)), std::istreambuf_iterator<char>());
return ToLower(content).find(needle_lower) != std::string::npos;
}
bool CueDirectoryReferencesImage(const std::filesystem::path& image_path) {
if (!image_path.has_parent_path()) {
return false;
}
const std::string needle = ToLower(image_path.filename().string());
std::error_code ec;
for (const auto& item : std::filesystem::directory_iterator(image_path.parent_path(), ec)) {
if (ec) {
break;
}
if (!item.is_regular_file()) {
continue;
}
if (ToLower(item.path().extension().string()) != ".cue") {
continue;
}
if (FileContainsCaseInsensitive(item.path(), needle)) {
return true;
}
}
return false;
}
bool IsLikelyBiosImagePath(const std::filesystem::path& path, const FrontendSettings& settings) {
const std::string ext = ToLower(path.extension().string());
if (ext != ".bin" && ext != ".rom") {
return false;
}
if (!settings.bios_override.empty() && PathsMatch(path, std::filesystem::path(settings.bios_override))) {
return true;
}
const std::string stem = NormalizeModel(path.stem().string());
if (stem == "bios" || stem == "biosbin") {
return true;
}
if (stem.starts_with("scph")) {
return true;
}
return false;
}
std::string NormalizePathString(std::string value) {
#if defined(_WIN32)
std::replace(value.begin(), value.end(), '\\', '/');
if (value.size() == 2 && std::isalpha(static_cast<unsigned char>(value[0])) && value[1] == ':') {
value.push_back('/');
}
#endif
return value;
}
bool StartsWithCaseInsensitive(std::string_view value, std::string_view prefix) {
if (value.size() < prefix.size()) {
return false;
}
for (size_t index = 0; index < prefix.size(); index++) {
const unsigned char lhs = static_cast<unsigned char>(value[index]);
const unsigned char rhs = static_cast<unsigned char>(prefix[index]);
if (std::tolower(lhs) != std::tolower(rhs)) {
return false;
}
}
return true;
}
int HexValue(unsigned char ch) {
if (ch >= '0' && ch <= '9') {
return ch - '0';
}
if (ch >= 'a' && ch <= 'f') {
return 10 + (ch - 'a');
}
if (ch >= 'A' && ch <= 'F') {
return 10 + (ch - 'A');
}
return -1;
}
std::string PercentDecode(std::string_view value) {
std::string decoded;
decoded.reserve(value.size());
for (size_t index = 0; index < value.size(); index++) {
const unsigned char ch = static_cast<unsigned char>(value[index]);
if ((ch == '%') && ((index + 2) < value.size())) {
const int hi = HexValue(static_cast<unsigned char>(value[index + 1]));
const int lo = HexValue(static_cast<unsigned char>(value[index + 2]));
if (hi >= 0 && lo >= 0) {
decoded.push_back(static_cast<char>((hi << 4) | lo));
index += 2;
continue;
}
}
decoded.push_back(ch == '+' ? ' ' : static_cast<char>(ch));
}
return decoded;
}
std::optional<std::string> NormalizedPickerSelection(const std::string& path) {
const std::string trimmed = Trim(path);
if (trimmed.empty()) {
return std::nullopt;
}
return NormalizePathString(trimmed);
}
std::string WithHiddenId(std::string_view label, std::string_view id) {
std::string out(label);
out += "##";
out += id;
return out;
}
// The FSUI app-icon lookup (ReadSdlFileBytes / MaterializeBundledFsuiAppIcon /
// ResolveFsuiAppIconPath) was removed with the FSUI cut. It was dead code — nothing called
// ResolveFsuiAppIconPath — but it was a landmine: ReadSdlFileBytes used SDL_RWFromFile with a
// RELATIVE path, and on the in-process Android host SDL falls through to Android_JNI_FileOpen,
// which aborts the process ("CallStaticObjectMethod received NULL jclass") because SDLActivity
// glue is never registered when Compose owns the Activity. Use POSIX stat/fopen here, never SDL_RW*.
bool IsDiscPath(const std::filesystem::path& path) {
const std::string ext = ToLower(path.extension().string());
return ext == ".cue" || ext == ".bin" || ext == ".iso" || ext == ".img"
#ifdef USE_CHD
|| ext == ".chd"
#endif
;
}
bool IsExePath(const std::filesystem::path& path) {
const std::string ext = ToLower(path.extension().string());
return ext == ".exe" || ext == ".ps-exe" || ext == ".psexe";
}
bool IsZipPath(const std::filesystem::path& path) {
return armsx::IsZipPath(path);
}
std::string StemToTitle(const std::filesystem::path& path) {
std::string value = path.stem().string();
if (value.empty()) {
value = path.filename().string();
}
std::replace(value.begin(), value.end(), '_', ' ');
return Trim(value);
}
/* Empty means "derive it": <prefs>/textures, beside the other per-user data. Android never
takes that branch — the app pushes an explicit per-game path, because only it knows the
disc serial and the core has no business guessing one. */
std::string ResolveTextureDir(const std::string& configured) {
if (!configured.empty()) {
return configured;
}
const char* prefs = psxe_cfg_get_pref_path();
if (prefs && *prefs) {
return (std::filesystem::path(prefs) / "textures").string();
}
return "textures";
}
std::string EscapeTomlString(std::string_view value) {
std::string out;
out.reserve(value.size() + 8);
for (char ch : value) {
switch (ch) {
case '\\': out += "\\\\"; break;
case '"': out += "\\\""; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default: out.push_back(ch); break;
}
}
return out;
}
const char* AspectToString(int aspect) {
switch (aspect) {
case 1: return "square";
case 2: return "wide16x9";
case 3: return "custom";
default: return "classic";
}
}
const char* AspectTitle(int aspect) {
switch (aspect) {
case 1: return "Square";
case 2: return "Wide 16:9";
default: return "Classic";
}
}
/* ---- [video] display/video feature tokens. Must match frontend/config.c's parser exactly:
these are what SaveSettings() writes back, and an unrecognised token silently reverts the
setting to its default the next time the file is read. */
const char* TextureFilterToString(int mode) {
switch (mode) {
case 1: return "bilinear";
case 2: return "xbr";
default: return "nearest";
}
}
const char* DeinterlaceToString(int mode) {
switch (mode) {
case 1: return "bob";
case 2: return "adaptive";
default: return "weave";
}
}
const char* OverscanToString(int mode) {
switch (mode) {
case 1: return "small";
case 2: return "full";
default: return "none";
}
}
const char* LineDetectToString(int mode) {
switch (mode) {
case 1: return "quads";
case 2: return "basic";
default: return "disabled";
}
}
const char* UpscaleToString(int height) {
switch (height) {
case 720: return "720p";
case 1080: return "1080p";
case 1440: return "1440p";
case 2160: return "2160p";
default: return "480p";
}
}
const char* AudioDriverToString(int driver) {
switch (driver) {
case 0: return "default";
case 2: return "aaudio";
default: return "opensles";
}
}
// Always written with a decimal point so the value round-trips as a TOML float. (The reader
// takes a bare integer too, but the two writers of this file — SaveSettings here and
// Ps1SettingsStore on the Kotlin side — should agree on one shape.)
std::string FastForwardSpeedToString(double speed) {
char buffer[32] = {};
std::snprintf(buffer, sizeof(buffer), "%.2f", speed > 0.0 ? speed : 0.0);
return buffer;
}
std::string BoolTitle(bool value) {
return value ? "On" : "Off";
}
std::string RendererValueTitle(const FrontendSettings& settings) {
std::string value = settings.texture_scale_mode ? "Linear" : "Nearest";
if (settings.stretch_mode) {
value += ", Stretch";
} else {
value += ", Fit";
}
return value;
}
#ifdef USE_HARDWARE
constexpr bool SupportsHardwareGpuBackend() {
return true;
}
armsx_render_backend_t RenderBackendFor(GpuBackend backend) {
switch (backend) {
case GpuBackend::SDLAccelerated:
return ARMSX_RENDER_BACKEND_SDL_ACCELERATED;
case GpuBackend::OpenGL:
return ARMSX_RENDER_BACKEND_OPENGL;
case GpuBackend::Vulkan:
return ARMSX_RENDER_BACKEND_VULKAN;
case GpuBackend::Software:
default:
return ARMSX_RENDER_BACKEND_SDL_SOFTWARE;
}
}
const char* GpuBackendTitle(GpuBackend backend) {
return armsx_render_backend_name(RenderBackendFor(backend));
}
const char* GpuBackendSettingToken(GpuBackend backend) {
return armsx_render_backend_token(RenderBackendFor(backend));
}
/* [video] renderer. "hardware" is the token the Compose UI writes and means AUTO; the two
explicit ones only ever arrive from a hand-edited settings.toml and are round-tripped so
an A/B choice is not silently collapsed the next time the UI saves. */
#ifdef USE_HARDWARE
const char* RendererSettingToken(const FrontendSettings& settings) {
if (!settings.hw_rasterizer) {
return "software";
}
switch (settings.rasterizer_mode) {
case 2: return "hardware-cpu";
case 3: return "hardware-gl";
default: return "hardware";
}
}
#endif
std::optional<GpuBackend> ParseGpuBackendOverride(const char* value) {
if (!value || !value[0]) {
return std::nullopt;
}
const std::string lowered = ToLower(value);
if (lowered == "sdl-accelerated" || lowered == "hardware" || lowered == "hardware (experimental)" || lowered == "hw" || lowered == "hw-renderer") {
return GpuBackend::SDLAccelerated;
}
if (lowered == "opengl" || lowered == "gl" || lowered == "gles" || lowered == "gles3" ||
lowered == "opengl-es" || lowered == "angle") {
/* `angle` is the flat UI token for OpenGL presentation with the ANGLE provider.
config.c has already selected gl_driver=ANGLE for it. LoadExtraSettings() parses
the same file a second time, so treating the token as unknown here used to undo
that first pass and silently replace ANGLE with SDL software. */
return GpuBackend::OpenGL;
}
if (lowered == "vulkan" || lowered == "vk") {
return GpuBackend::Vulkan;
}
if (lowered == "software" || lowered == "sw" || lowered == "sw-renderer") {
return GpuBackend::Software;
}
return std::nullopt;
}
#endif
int RegionFromSettings(const FrontendSettings& settings) {
const std::string region = ToLower(settings.region);
if (region == "pal") {
return CDR_REGION_EUROPE;
}
if (region == "ntsc") {
const std::string model = NormalizeModel(settings.model);
static const std::array<const char*, 8> japan_models = {
"scph1000",
"scph3000",
"scph3500",
"scph5000",
"scph5500",
"scph7000",
"scph7003",
"scph100",
};
if (std::find(japan_models.begin(), japan_models.end(), model) != japan_models.end()) {
return CDR_REGION_JAPAN;
}
return CDR_REGION_AMERICA;
}
const std::string model = NormalizeModel(settings.model);
static const std::array<const char*, 8> europe_models = {
"scph1002",
"scph5502",
"scph5552",
"scph7002",
"scph7502",
"scph9002",
"scph102a",
"scph102b",
};
static const std::array<const char*, 8> japan_models = {
"scph1000",
"scph3000",
"scph3500",
"scph5000",
"scph5500",
"scph7000",
"scph7003",
"scph100",
};
if (std::find(europe_models.begin(), europe_models.end(), model) != europe_models.end()) {
return CDR_REGION_EUROPE;
}
if (std::find(japan_models.begin(), japan_models.end(), model) != japan_models.end()) {
return CDR_REGION_JAPAN;
}
return CDR_REGION_AMERICA;
}
LaunchRequest LaunchForPath(const std::filesystem::path& path) {
LaunchRequest request;
if (path.empty()) {
return request;
}
request.path = std::filesystem::path(NormalizePathString(path.string()));
request.label = StemToTitle(request.path);
if (IsExePath(request.path)) {
request.kind = LaunchKind::Exe;
} else if (IsDiscPath(request.path)) {
request.kind = LaunchKind::Disc;
}
return request;
}
std::optional<LaunchKind> ParseUriLaunchKind(std::string_view value) {
const std::string lower = ToLower(Trim(value));
if (lower == "bios") {
return LaunchKind::Bios;
}
if (lower == "disc" || lower == "cdrom") {
return LaunchKind::Disc;
}
if (lower == "exe") {
return LaunchKind::Exe;
}
return std::nullopt;
}
std::optional<std::string> QueryValue(std::string_view query, std::string_view key) {
size_t begin = 0;
while (begin <= query.size()) {
const size_t end = query.find('&', begin);
const std::string_view pair = query.substr(begin, end == std::string_view::npos ? query.size() - begin : end - begin);
const size_t sep = pair.find('=');
const std::string_view pair_key = pair.substr(0, sep);
if (ToLower(PercentDecode(pair_key)) == ToLower(key)) {
if (sep == std::string_view::npos) {
return std::string();
}
return PercentDecode(pair.substr(sep + 1));
}
if (end == std::string_view::npos) {
break;
}
begin = end + 1;
}
return std::nullopt;
}
std::string DecodeLaunchUriPathPayload(std::string_view payload) {
std::string decoded = PercentDecode(payload);
if (decoded.rfind("///", 0) == 0) {
decoded.erase(0, 2);
} else if (decoded.rfind("//", 0) == 0) {
decoded.erase(0, 2);
}
#if defined(_WIN32)
if (decoded.size() >= 3 &&
decoded[0] == '/' &&
std::isalpha(static_cast<unsigned char>(decoded[1])) &&
decoded[2] == ':') {
decoded.erase(decoded.begin());
}
#endif
return NormalizePathString(decoded);
}
LaunchRequest LaunchForUri(std::string_view uri) {
LaunchRequest request;
std::string_view remainder = uri;
if (StartsWithCaseInsensitive(uri, "armsx:")) {
remainder = uri.substr(6);
} else if (StartsWithCaseInsensitive(uri, "web+armsx:")) {
remainder = uri.substr(10);
} else {
return request;
}
const size_t fragment_pos = remainder.find('#');
if (fragment_pos != std::string_view::npos) {
remainder = remainder.substr(0, fragment_pos);
}
std::string_view query;
const size_t query_pos = remainder.find('?');
if (query_pos != std::string_view::npos) {
query = remainder.substr(query_pos + 1);
remainder = remainder.substr(0, query_pos);
}
std::optional<LaunchKind> kind = std::nullopt;
if (const std::optional<std::string> kind_value = QueryValue(query, "kind")) {
kind = ParseUriLaunchKind(*kind_value);
}
if (kind == LaunchKind::Bios) {
request.kind = LaunchKind::Bios;
request.label = "PlayStation BIOS";
return request;
}
std::string launch_path;
if (const std::optional<std::string> query_path = QueryValue(query, "path")) {
launch_path = NormalizePathString(*query_path);
} else {
launch_path = DecodeLaunchUriPathPayload(remainder);
}
const std::string launch_path_lower = ToLower(Trim(launch_path));
if (launch_path_lower == "bios" || launch_path_lower == "/bios") {
request.kind = LaunchKind::Bios;
request.label = "PlayStation BIOS";
return request;
}
request = LaunchForPath(std::filesystem::path(launch_path));
if (request.kind == LaunchKind::None) {
return request;
}
if (kind == LaunchKind::Disc || kind == LaunchKind::Exe) {
request.kind = *kind;
}
return request;
}
LaunchRequest LaunchForArgument(std::string_view argument, std::optional<LaunchKind> forced_kind = std::nullopt) {
LaunchRequest request;
const std::string trimmed = Trim(argument);
if (trimmed.empty()) {
return request;
}
if (StartsWithCaseInsensitive(trimmed, "armsx:")) {
request = LaunchForUri(trimmed);
} else {
const std::filesystem::path path(trimmed);
#ifdef USE_CHD
if (IsZipPath(path)) {
std::string archive_error;
const auto extracted = armsx::ExtractZipLaunchCandidate(path, archive_error);
if (!extracted.has_value()) {
psxe_diag_logf(
"launch",
"ZIP launch failed path=%s error=%s",
path.string().c_str(),
archive_error.c_str()
);
return request;
}
request = LaunchForPath(*extracted);
} else
#endif
{
request = LaunchForPath(path);
}
}
if (request.kind == LaunchKind::None) {
return request;
}
if (forced_kind == LaunchKind::Bios) {
return LaunchRequest{.kind = LaunchKind::Bios, .path = {}, .label = "PlayStation BIOS"};
}
if ((forced_kind == LaunchKind::Disc || forced_kind == LaunchKind::Exe) && !request.path.empty()) {
request.kind = *forced_kind;
}
return request;
}
#ifdef USE_HARDWARE
#endif
std::vector<std::string> ModelChoices() {
return {
"scph1000", "scph1001", "scph1002", "scph3000", "scph3500", "scph5000", "scph5500",
"scph5501", "scph5502", "scph5552", "scph7000", "scph7001", "scph7002", "scph7003",
"scph7501", "scph7502", "scph9002", "scph100", "scph101", "scph102a", "scph102b", "scph102c",
};
}
std::time_t FileTimeToTimeT(const std::filesystem::file_time_type& value) {
using namespace std::chrono;
const auto adjusted = time_point_cast<system_clock::duration>(
value - std::filesystem::file_time_type::clock::now() + system_clock::now()
);
return system_clock::to_time_t(adjusted);
}
void CopyTomlString(const toml_datum_t& datum, std::string& out) {
if (!datum.ok || !datum.u.s) {
return;
}
out = datum.u.s;
free(datum.u.s);
}
void CopyTomlPathString(const toml_datum_t& datum, std::string& out) {
if (!datum.ok || !datum.u.s) {
return;
}
out = NormalizePathString(datum.u.s);
free(datum.u.s);
}
void CopyTomlArrayStrings(const toml_array_t* array, std::vector<std::filesystem::path>& out) {
out.clear();
if (!array) {
return;
}
const int count = toml_array_nelem(array);
out.reserve(static_cast<size_t>(count));
for (int index = 0; index < count; index++) {
toml_datum_t value = toml_string_at(array, index);
if (!value.ok || !value.u.s) {
continue;
}
out.emplace_back(NormalizePathString(value.u.s));
free(value.u.s);
}
}
/* The plain-text sibling of the above, for lists that are NOT paths. `[cheats] enabled_codes`
holds cheat NAMES, so putting them through NormalizePathString() would quietly rewrite a
name containing a backslash or a double space and the entry would then match nothing. */
void CopyTomlArrayPlainStrings(const toml_array_t* array, std::vector<std::string>& out) {
out.clear();
if (!array) {
return;
}
const int count = toml_array_nelem(array);
out.reserve(static_cast<size_t>(count));
for (int index = 0; index < count; index++) {
toml_datum_t value = toml_string_at(array, index);
if (!value.ok || !value.u.s) {
continue;
}
out.emplace_back(value.u.s);
free(value.u.s);
}
}
CliFlags ScanCliFlags(int argc, const char* argv[]) {
CliFlags flags;
for (int index = 1; index < argc; index++) {
const std::string_view arg(argv[index] ? argv[index] : "");
auto mark_value_opt = [&](const char* short_name, const char* long_name, bool* target) {
const std::string_view short_opt(short_name);
const std::string_view long_opt(long_name);
if (arg == short_opt || arg == long_opt) {
*target = true;
index++;
return true;
}
if (arg.starts_with(long_opt) && arg.size() > (long_opt.size() + 1) && arg[long_opt.size()] == '=') {
*target = true;
return true;
}
return false;
};
if (mark_value_opt("-b", "--bios", &flags.bios) ||
mark_value_opt("-B", "--bios-folder", &flags.bios_folder) ||
mark_value_opt("-M", "--model", &flags.model) ||
mark_value_opt("-r", "--region", &flags.region) ||
mark_value_opt("-s", "--scale", &flags.scale) ||
mark_value_opt("-S", "--settings-file", &flags.settings_file) ||
mark_value_opt("-L", "--log-level", &flags.log_level) ||
mark_value_opt("-e", "--exp-rom", &flags.exp_rom) ||
mark_value_opt("-x", "--exe", &flags.exe) ||
mark_value_opt("", "--cpu-engine", &flags.cpu_engine) ||
mark_value_opt("", "--cdrom", &flags.cdrom)) {
continue;
}
if (arg == "-q" || arg == "--quiet") {
flags.quiet = true;
continue;
}
if (!arg.empty() && arg[0] != '-') {
flags.cdrom = true;
}
}
flags.has_boot_request = flags.cdrom || flags.exe;
return flags;
}
/*
`[cheats]` out of an already-parsed settings.toml.
Split out of LoadExtraSettings() because SaveSettings() needs the same read: it rewrites
the whole file from the snapshot the session booted with, so a cheat the user switched on
from the settings screen mid-session would otherwise be written straight back out again.
That is the exact failure the comment above RefreshSessionStartSettingsFromDisk()
describes for the upscale rows, and there is no reason to learn it twice.
*/
void ReadCheatSettings(toml_table_t* root, FrontendSettings& settings) {
toml_table_t* cheats = root ? toml_table_in(root, "cheats") : nullptr;
if (!cheats) {
return;
}
toml_datum_t enabled = toml_bool_in(cheats, "enabled");
if (enabled.ok) {
settings.cheats_enabled = enabled.u.b != 0;
}
toml_datum_t file = toml_string_in(cheats, "file");
if (file.ok && file.u.s) {
settings.cheats_file = file.u.s;
free(file.u.s);
}
CopyTomlArrayPlainStrings(toml_array_in(cheats, "enabled_codes"), settings.cheats_enabled_codes);
}
/* Re-read `[cheats]` straight off disk. See ReadCheatSettings() for why. */
void RefreshCheatSettingsFromDisk(FrontendSettings& settings) {
const std::filesystem::path target = settings.settings_path.empty()
? std::filesystem::path(DefaultSettingsPath())
: std::filesystem::path(settings.settings_path);
FILE* file = fopen(target.string().c_str(), "rb");
if (!file) {
return;
}
char error[256] = {};
toml_table_t* root = toml_parse_file(file, error, sizeof(error));
fclose(file);
if (!root) {
return;
}
ReadCheatSettings(root, settings);
toml_free(root);
}
/*
Load the game's cheat catalogue and arm the selected entries.
Called once per boot, and again whenever the host asks for a live re-arm. The hardcore
interlock is applied FIRST so that a build where hardcore is ever switched on cannot arm
anything at all, rather than arming and relying on something further downstream to stop
it.
*/
void ApplyCheatSettings(const FrontendSettings& settings) {
psx_cheats_set_inhibited(armsx_ach_hardcore_active() ? 1 : 0);
if (!settings.cheats_enabled || settings.cheats_file.empty()) {
/* Clearing rather than leaving the previous game's catalogue in place: a list that
outlived its disc is how a cheat ends up armed in a game it was never meant for. */
psx_cheats_load_file("");
psx_cheats_arm(0, nullptr, 0, nullptr);
return;
}
if (psx_cheats_load_file(settings.cheats_file.c_str()) < 0) {
psx_cheats_arm(0, nullptr, 0, nullptr);
return;
}
std::vector<const char*> names;
names.reserve(settings.cheats_enabled_codes.size());
for (const std::string& name : settings.cheats_enabled_codes) {
names.push_back(name.c_str());
}
int missing = 0;
const int armed = psx_cheats_arm(1, names.empty() ? nullptr : names.data(),
static_cast<int>(names.size()), &missing);
if (missing > 0) {
/* Said out loud rather than ignored. A selection that no longer matches its file is
the most confusing way for this feature to fail — the switch is on and nothing
happens — and it is exactly what renaming an entry in the .cht produces. */
log_warn("cheats: %d selected entr%s not present in %s", missing, missing == 1 ? "y is" : "ies are",
settings.cheats_file.c_str());
}
(void)armed;
}
void LoadExtraSettings(FrontendSettings& settings, const CliFlags& cli) {
if (settings.settings_path.empty()) {
return;
}
FILE* file = fopen(settings.settings_path.c_str(), "rb");
if (!file) {
return;
}
char error[256] = {};
toml_table_t* root = toml_parse_file(file, error, sizeof(error));
fclose(file);
if (!root) {
return;
}
if (toml_table_t* bios = toml_table_in(root, "bios")) {
if (!cli.bios_folder) {
CopyTomlPathString(toml_string_in(bios, "search_path"), settings.bios_search);
}
if (!cli.model) {
CopyTomlString(toml_string_in(bios, "preferred_model"), settings.model);
}
if (!cli.bios) {
CopyTomlPathString(toml_string_in(bios, "override_file"), settings.bios_override);
}
}
if (toml_table_t* console = toml_table_in(root, "console")) {
if (!cli.region) {
CopyTomlString(toml_string_in(console, "region"), settings.region);
}
}
if (toml_table_t* runtime = toml_table_in(root, "runtime")) {
if (!cli.scale) {
toml_datum_t value = toml_int_in(runtime, "display_scale");
if (value.ok) {
settings.scale = static_cast<int>(value.u.i);
}
}
if (!cli.log_level) {
toml_datum_t value = toml_int_in(runtime, "log_level");
if (value.ok) {
settings.log_level = static_cast<int>(value.u.i);
}
}
if (!cli.quiet) {
bool resolved_logging_enabled = false;
toml_datum_t logging_enabled = toml_bool_in(runtime, "logging_enabled");
if (logging_enabled.ok) {
settings.logging_enabled = logging_enabled.u.b != 0;
settings.quiet = !settings.logging_enabled;
resolved_logging_enabled = true;
}
toml_datum_t value = toml_bool_in(runtime, "quiet");
if (!resolved_logging_enabled && value.ok) {
settings.quiet = value.u.b != 0;
settings.logging_enabled = !settings.quiet;
}
}
toml_datum_t frame_limit = toml_bool_in(runtime, "frame_limit");
if (frame_limit.ok) {
settings.frame_limit = frame_limit.u.b != 0;
}
toml_datum_t speed_percent = toml_int_in(runtime, "speed_percent");
if (speed_percent.ok) {
settings.speed_percent = static_cast<int>(speed_percent.u.i);
}
toml_datum_t fps_limit = toml_int_in(runtime, "fps_limit");
if (fps_limit.ok) {
settings.fps_limit = static_cast<int>(fps_limit.u.i);
}
// Written as a float by the UI, but a hand-edited file may hold a bare integer and
// toml_double_in only matches floats — try both rather than silently falling back to 2x.
toml_datum_t ff_speed = toml_double_in(runtime, "fast_forward_speed");
if (ff_speed.ok) {
settings.fast_forward_speed = ff_speed.u.d;
} else {
toml_datum_t ff_speed_int = toml_int_in(runtime, "fast_forward_speed");
if (ff_speed_int.ok) {
settings.fast_forward_speed = static_cast<double>(ff_speed_int.u.i);
}
}
toml_datum_t frame_skip = toml_int_in(runtime, "frame_skip");
if (frame_skip.ok) {
settings.frame_skip = static_cast<int>(frame_skip.u.i);
}
}
if (toml_table_t* audio = toml_table_in(root, "audio")) {
toml_datum_t volume = toml_int_in(audio, "volume");
if (volume.ok) {
settings.audio_volume = static_cast<int>(volume.u.i);
}
toml_datum_t ff_volume = toml_int_in(audio, "fast_forward_volume");
if (ff_volume.ok) {
settings.audio_ff_volume = static_cast<int>(ff_volume.u.i);
}
toml_datum_t muted = toml_bool_in(audio, "muted");
if (muted.ok) {
settings.audio_muted = muted.u.b != 0;
}
toml_datum_t mute_ff = toml_bool_in(audio, "mute_fast_forward");
if (mute_ff.ok) {
settings.audio_mute_fast_forward = mute_ff.u.b != 0;
}
toml_datum_t swap_channels = toml_bool_in(audio, "swap_channels");
if (swap_channels.ok) {
settings.audio_swap_channels = swap_channels.u.b != 0;
}
toml_datum_t skip_reverb = toml_bool_in(audio, "skip_reverb");
if (skip_reverb.ok) {
settings.audio_skip_reverb = skip_reverb.u.b != 0;
}
toml_datum_t buffer_ms = toml_int_in(audio, "buffer_ms");
if (buffer_ms.ok) {
settings.audio_buffer_ms = static_cast<int>(buffer_ms.u.i);
}
toml_datum_t driver = toml_string_in(audio, "driver");
if (driver.ok && driver.u.s) {
const std::string value = ToLower(driver.u.s);
if (value == "aaudio") {
settings.audio_driver = 2;
} else if (value == "default" || value == "auto") {
settings.audio_driver = 0;
} else {
settings.audio_driver = 1;
}
free(driver.u.s);
}
toml_datum_t background_playback = toml_bool_in(audio, "background_playback");
if (background_playback.ok) {
settings.audio_background_playback = background_playback.u.b != 0;
}
}
if (toml_table_t* paths = toml_table_in(root, "paths")) {
if (!cli.exp_rom) {
CopyTomlPathString(toml_string_in(paths, "expansion_rom"), settings.exp_path);
}
if (!cli.exe) {
CopyTomlPathString(toml_string_in(paths, "default_psx_exe"), settings.default_exe_path);
}
}
if (toml_table_t* video = toml_table_in(root, "video")) {
toml_datum_t vsync = toml_bool_in(video, "vsync");
if (vsync.ok) {
settings.vsync_enabled = vsync.u.b != 0;
}
#ifdef USE_HARDWARE
toml_datum_t gpu_backend = toml_string_in(video, "gpu_backend");
if (gpu_backend.ok && gpu_backend.u.s) {
if (std::optional<GpuBackend> backend = ParseGpuBackendOverride(gpu_backend.u.s)) {
settings.gpu_backend = *backend;
} else {
settings.gpu_backend = GpuBackend::Software;
}
free(gpu_backend.u.s);
}
#endif
toml_datum_t texture_scale_mode = toml_bool_in(video, "texture_scale_mode");
if (texture_scale_mode.ok) {
settings.texture_scale_mode = texture_scale_mode.u.b != 0;
}
toml_datum_t debug_panel = toml_bool_in(video, "debug_panel");
if (debug_panel.ok) {
settings.debug_panel = debug_panel.u.b != 0;
}
toml_datum_t stretch_mode = toml_bool_in(video, "stretch_mode");
if (stretch_mode.ok) {
settings.stretch_mode = stretch_mode.u.b != 0;
}
toml_datum_t display_aspect = toml_string_in(video, "display_aspect");
if (display_aspect.ok && display_aspect.u.s) {
const std::string value = ToLower(display_aspect.u.s);
if (value == "square") {
settings.display_aspect = 1;
} else if (value == "wide16x9") {
settings.display_aspect = 2;
} else if (value == "custom") {
settings.display_aspect = 3;
} else {
settings.display_aspect = 0;
}
free(display_aspect.u.s);
}
toml_datum_t integer_scaling = toml_bool_in(video, "integer_scaling");
if (integer_scaling.ok) {
settings.integer_scaling = integer_scaling.u.b;
}
toml_datum_t display_aspect_custom = toml_double_in(video, "display_aspect_custom");
if (display_aspect_custom.ok) {
const float ratio = static_cast<float>(display_aspect_custom.u.d);
// Ignore an out-of-range value rather than clamping it: a clamp would silently
// present something the user did not ask for, which reads as "custom is broken".
if (ratio >= kMinCustomAspect && ratio <= kMaxCustomAspect) {
settings.display_aspect_custom = ratio;
}
}
toml_datum_t wide_upscale = toml_string_in(video, "wide_upscale");
if (wide_upscale.ok && wide_upscale.u.s) {
const std::string value = ToLower(wide_upscale.u.s);
if (value == "720p") settings.upscale_height = 720;
else if (value == "1080p") settings.upscale_height = 1080;
else if (value == "1440p") settings.upscale_height = 1440;
else if (value == "2160p") settings.upscale_height = 2160;
else settings.upscale_height = 480;
free(wide_upscale.u.s);
}
}
if (toml_table_t* input = toml_table_in(root, "input")) {
toml_datum_t analog_mode_default = toml_bool_in(input, "analog_mode_default");
if (analog_mode_default.ok) {
settings.analog_mode_default = analog_mode_default.u.b != 0;
}
toml_datum_t multitap = toml_bool_in(input, "multitap");
if (multitap.ok) {
settings.multitap = multitap.u.b != 0;
}
}
// [emulation]. An ABSENT table leaves every field at the struct default, which is what a
// settings.toml written before these keys existed must resolve to.
if (toml_table_t* emulation = toml_table_in(root, "emulation")) {
toml_datum_t rewind = toml_bool_in(emulation, "rewind");
if (rewind.ok) {
settings.rewind = rewind.u.b != 0;
}
toml_datum_t rewind_seconds = toml_int_in(emulation, "rewind_seconds");
if (rewind_seconds.ok) {
settings.rewind_seconds = static_cast<int>(rewind_seconds.u.i);
}
toml_datum_t rewind_frequency = toml_int_in(emulation, "rewind_frequency");
if (rewind_frequency.ok) {
settings.rewind_frequency = static_cast<int>(rewind_frequency.u.i);
}
toml_datum_t runahead = toml_int_in(emulation, "runahead");
if (runahead.ok) {
settings.runahead = static_cast<int>(runahead.u.i);
}
}
if (toml_table_t* library = toml_table_in(root, "library")) {
CopyTomlArrayStrings(toml_array_in(library, "folders"), settings.ui_state.game_list_paths);
CopyTomlArrayStrings(toml_array_in(library, "recursive_folders"), settings.ui_state.game_list_recursive_paths);
}
ReadCheatSettings(root, settings);
if (toml_table_t* fsui_state = toml_table_in(root, "fsui")) {
toml_datum_t default_view = toml_int_in(fsui_state, "default_game_view");
toml_datum_t sort = toml_int_in(fsui_state, "game_sort");
toml_datum_t reverse = toml_bool_in(fsui_state, "game_sort_reverse");
if (default_view.ok) {
settings.ui_state.default_game_view = static_cast<int>(default_view.u.i);
}
if (sort.ok) {
settings.ui_state.game_sort = static_cast<int>(sort.u.i);
}
if (reverse.ok) {
settings.ui_state.game_sort_reverse = reverse.u.b != 0;
}
}
toml_free(root);
}
FrontendSettings BuildSettings(const psxe_config_t* cfg, const CliFlags& cli) {
FrontendSettings settings;
#ifdef USE_HARDWARE
std::optional<GpuBackend> env_gpu_backend;
#endif
settings.settings_path = (cfg && cfg->settings_path && cfg->settings_path[0]) ? cfg->settings_path : DefaultSettingsPath();
settings.bios_override = (cfg && cfg->bios && cfg->bios[0]) ? cfg->bios : "";
settings.bios_search = (cfg && cfg->bios_search && cfg->bios_search[0]) ? cfg->bios_search : "bios";
settings.model = (cfg && cfg->model && cfg->model[0]) ? cfg->model : "scph1001";
settings.region = (cfg && cfg->region && cfg->region[0]) ? cfg->region : "auto";
settings.exp_path = (cfg && cfg->exp_path && cfg->exp_path[0]) ? cfg->exp_path : "";
settings.default_exe_path = (cfg && cfg->exe && cfg->exe[0]) ? cfg->exe : "";
settings.scale = cfg ? cfg->scale : 3;
settings.log_level = cfg ? cfg->log_level : LOG_INFO;
settings.quiet = cfg ? (cfg->quiet != 0) : true;
settings.logging_enabled = !settings.quiet;
settings.vsync_enabled = cfg ? (cfg->vsync_enabled != 0) : DefaultVsyncEnabled();
settings.cpu_engine = cfg && !cfg->cpu_engine ? PSX_CPU_INTERPRETER : PSX_CPU_CACHED_INTERPRETER;
if (const char* env_cpu_engine = std::getenv("ARMSX_CPU_ENGINE")) {
if (std::optional<psx_cpu_execution_mode_t> parsed = ParseCpuEngine(env_cpu_engine)) {
settings.cpu_engine = *parsed;
}
}
#if defined(USE_HARDWARE)
// psxe_config_t::gpu_backend is the same 0..3 encoding as GpuBackend (frontend/config.c).
switch (cfg ? cfg->gpu_backend : 0) {
case 1: settings.gpu_backend = GpuBackend::SDLAccelerated; break;
case 2: settings.gpu_backend = GpuBackend::OpenGL; break;
case 3: settings.gpu_backend = GpuBackend::Vulkan; break;
default: settings.gpu_backend = GpuBackend::Software; break;
}
if (const char* env_backend = std::getenv("ARMSX_GPU_BACKEND")) {
env_gpu_backend = ParseGpuBackendOverride(env_backend);
}
settings.accurate_mask_bit = cfg ? (cfg->accurate_mask_bit != 0) : false;
settings.accurate_dither = cfg ? (cfg->accurate_dither != 0) : false;
settings.accurate_prim_size = cfg ? (cfg->accurate_prim_size != 0) : false;
settings.accurate_tex_modulate = cfg ? (cfg->accurate_tex_modulate != 0) : false;
settings.pgxp = cfg ? (cfg->pgxp != 0) : false;
settings.widescreen_hack = cfg ? (cfg->widescreen_hack != 0) : false;
settings.texture_filter = cfg ? cfg->texture_filter : 0;
settings.texture_dump = cfg ? (cfg->texture_dump != 0) : false;
settings.texture_replacements = cfg ? (cfg->texture_replacements != 0) : false;
settings.texture_dir = (cfg && cfg->texture_dir) ? cfg->texture_dir : "";
settings.downsample = cfg ? cfg->downsample : 0;
settings.deinterlace = cfg ? cfg->deinterlace : 0;
settings.overscan_crop = cfg ? cfg->overscan_crop : 0;
settings.display_rotation = cfg ? cfg->display_rotation : 0;
settings.line_detect = cfg ? cfg->line_detect : 0;
settings.hw_rasterizer = cfg ? (cfg->renderer != 0) : false;
settings.rasterizer_mode = cfg ? cfg->renderer : 0;
settings.internal_scale = cfg ? cfg->internal_scale : 1;
if (settings.internal_scale < 1) {
settings.internal_scale = 1;
}
#endif
settings.texture_scale_mode = cfg ? (cfg->texture_scale_mode != 0) : false;
settings.debug_panel = cfg ? (cfg->debug_panel != 0) : false;
settings.stretch_mode = cfg ? (cfg->stretch_mode != 0) : false;
settings.display_aspect = cfg ? cfg->display_aspect : 0;
if (cfg && cfg->display_aspect_custom > 0.0f)
settings.display_aspect_custom = cfg->display_aspect_custom;
settings.integer_scaling = cfg ? cfg->integer_scaling != 0 : false;
settings.upscale_height = cfg ? cfg->upscale_height : 480;
settings.analog_mode_default = cfg ? (cfg->analog_mode_default != 0) : true;
settings.multitap = cfg ? (cfg->multitap != 0) : false;
settings.rewind = cfg ? (cfg->rewind != 0) : false;
settings.rewind_seconds = cfg ? cfg->rewind_seconds : PSX_REWIND_DEFAULT_SECONDS;
settings.rewind_frequency = cfg ? cfg->rewind_frequency : PSX_REWIND_DEFAULT_FREQUENCY;
settings.runahead = cfg ? cfg->runahead : 0;
settings.frame_limit = cfg ? (cfg->frame_limit != 0) : true;
settings.speed_percent = cfg ? cfg->speed_percent : 100;
settings.fps_limit = cfg ? cfg->fps_limit : 0;
settings.fast_forward_speed = cfg ? cfg->fast_forward_speed : 2.0;
settings.frame_skip = cfg ? cfg->frame_skip : 0;
settings.adpf_clock_hint = cfg ? (cfg->adpf_clock_hint != 0) : false;
settings.affinity_mode = cfg ? cfg->affinity_mode : 0;
settings.audio_volume = cfg ? cfg->audio_volume : 100;
settings.audio_ff_volume = cfg ? cfg->audio_ff_volume : 100;
settings.audio_muted = cfg ? (cfg->audio_muted != 0) : false;
settings.audio_mute_fast_forward = cfg ? (cfg->audio_mute_fast_forward != 0) : false;
settings.audio_swap_channels = cfg ? (cfg->audio_swap_channels != 0) : false;
settings.audio_skip_reverb = cfg ? (cfg->audio_skip_reverb != 0) : false;
settings.audio_buffer_ms = cfg ? cfg->audio_buffer_ms : 20;
settings.audio_driver = cfg ? cfg->audio_driver : 1;
settings.audio_background_playback = cfg ? (cfg->audio_background_playback != 0) : false;
settings.ui_state.show_settings_overlay = settings.debug_panel;
settings.ui_state.show_performance_overlay = settings.debug_panel;
if (!cli.bios && !settings.bios_override.empty()) {
const std::filesystem::path bios_path(settings.bios_override);
if (!std::filesystem::exists(bios_path) && (settings.bios_override == "bios.bin")) {
settings.bios_override.clear();
}
}
LoadExtraSettings(settings, cli);
#if defined(USE_HARDWARE)
if (env_gpu_backend) {
settings.gpu_backend = *env_gpu_backend;
}
#endif
settings.scale = std::max(1, settings.scale);
settings.log_level = std::clamp(settings.log_level, static_cast<int>(LOG_TRACE), static_cast<int>(LOG_FATAL));
settings.logging_enabled = !settings.quiet;
// LoadExtraSettings re-reads the same file straight from TOML, so clamp AFTER it rather
// than only in config.c: a hand-edited fast_forward_speed = 0.01 would otherwise reach the
// frame pacer. 0 is preserved as the "uncapped" sentinel.
if (settings.fast_forward_speed > 0.0) {
settings.fast_forward_speed = std::clamp(settings.fast_forward_speed, 1.0, 16.0);
} else {
settings.fast_forward_speed = 0.0;
}
settings.speed_percent = std::clamp(settings.speed_percent, 10, 1000);
settings.fps_limit = std::clamp(settings.fps_limit, 0, 1000);
// -1 is the adaptive sentinel, so the floor is -1 and not 0. Clamped here as well as in
// config.c because LoadExtraSettings re-reads the raw TOML over the top of cfg.
settings.frame_skip = std::clamp(settings.frame_skip, -1, kMaxFrameSkip);
settings.audio_volume = std::clamp(settings.audio_volume, 0, 200);
settings.audio_ff_volume = std::clamp(settings.audio_ff_volume, 0, 200);
settings.audio_buffer_ms = std::clamp(settings.audio_buffer_ms, kAudioMinimumBufferMs, 100);
settings.audio_driver = std::clamp(settings.audio_driver, 0, 2);
settings.ui_state.show_settings_overlay = settings.debug_panel;
settings.ui_state.show_performance_overlay = settings.debug_panel;
if (settings.ui_state.covers_path.empty()) {
settings.ui_state.covers_path = DefaultBrowseDirectory() / "covers";
}
return settings;
}
/*
Re-reads the SESSION-START keys from the file on disk.
SaveSettings() regenerates the whole settings.toml from the core's in-memory snapshot, which
was loaded when the session started. The launcher writes the same file while that session is
running — that is how the pause menu's Upscale row works — so anything the user changed
mid-session was silently written back to its old value the next time the core saved.
That is exactly the reported "upscale buttons do nothing": the UI wrote internal_scale = 4,
the core still held 1, and the core's own save put 1 back before the restart could read it.
The core is authoritative for what it changes at RUNTIME (vsync). It is NOT authoritative for
the keys it merely read at boot, so those are taken from disk at write time.
*/
void RefreshSessionStartSettingsFromDisk(FrontendSettings& settings) {
const std::filesystem::path target = settings.settings_path.empty()
? std::filesystem::path(DefaultSettingsPath())
: std::filesystem::path(settings.settings_path);
std::ifstream in(target, std::ios::binary);
if (!in) {
return; /* No file yet: the in-memory values ARE the truth. */
}
std::string line;
while (std::getline(in, line)) {
const auto eq = line.find('=');
if (eq == std::string::npos) {
continue;
}
std::string key = line.substr(0, eq);
std::string value = line.substr(eq + 1);
const auto trim = [](std::string& text) {
const auto first = text.find_first_not_of(" \t\r\n\"");
const auto last = text.find_last_not_of(" \t\r\n\"");
text = (first == std::string::npos) ? std::string() : text.substr(first, last - first + 1);
};
trim(key);
trim(value);
if (key == "internal_scale") {
const int parsed = std::atoi(value.c_str());
if (parsed >= 1) {
settings.internal_scale = parsed;
}
} else if (key == "renderer") {
if (value == "hardware-gl" || value == "opengl" || value == "gl") {
settings.rasterizer_mode = 3;
settings.hw_rasterizer = true;
} else if (value == "hardware-cpu" || value == "cpu") {
settings.rasterizer_mode = 2;
settings.hw_rasterizer = true;
} else if (value == "hardware" || value == "hw" || value == "internal" ||
value == "upscale") {
settings.rasterizer_mode = 1;
settings.hw_rasterizer = true;
} else if (value == "software") {
settings.rasterizer_mode = 0;
settings.hw_rasterizer = false;
}
}
}
}
bool SaveSettings(const FrontendSettings& settings_in) {
/* Copy so the caller's snapshot is untouched; only what goes to DISK is refreshed. */
FrontendSettings settings = settings_in;
RefreshSessionStartSettingsFromDisk(settings);
/* Same reasoning, section-aware: the launcher writes [cheats] while this session runs. */
RefreshCheatSettingsFromDisk(settings);
const std::filesystem::path target = settings.settings_path.empty() ? std::filesystem::path(DefaultSettingsPath()) : std::filesystem::path(settings.settings_path);
std::filesystem::path temp = target;
temp += ".tmp";
try {
if (target.has_parent_path()) {
std::filesystem::create_directories(target.parent_path());
}
} catch (...) {
return false;
}
std::ofstream out(temp, std::ios::binary | std::ios::trunc);
if (!out.is_open()) {
return false;
}
auto write_path_array = [&](const std::vector<std::filesystem::path>& paths) {
out << "[";
for (size_t index = 0; index < paths.size(); index++) {
if (index) {
out << ", ";
}
out << "\"" << EscapeTomlString(paths[index].string()) << "\"";
}
out << "]";
};
/* Plain strings, not paths: `[cheats] enabled_codes` holds cheat NAMES. */
auto write_string_array = [&](const std::vector<std::string>& values) {
out << "[";
for (size_t index = 0; index < values.size(); index++) {
if (index) {
out << ", ";
}
out << "\"" << EscapeTomlString(values[index]) << "\"";
}
out << "]";
};
out
<< "# Settings file generated by ARMSX\n\n"
<< "psxe_version = \"" << STR(REP_VERSION) << "\"\n\n"
<< "[bios]\n"
<< " search_path = \"" << EscapeTomlString(settings.bios_search) << "\"\n"
<< " preferred_model = \"" << EscapeTomlString(settings.model) << "\"\n"
<< " override_file = \"" << EscapeTomlString(settings.bios_override) << "\"\n\n"
<< "[console]\n"
<< " region = \"" << EscapeTomlString(settings.region) << "\"\n\n"
<< "[cpu]\n"
<< " execution_mode = \"" << CpuEngineSettingToken(settings.cpu_engine) << "\"\n\n"
<< "[runtime]\n"
<< " display_scale = " << settings.scale << "\n"
<< " logging_enabled = " << (settings.logging_enabled ? "true" : "false") << "\n"
<< " log_level = " << settings.log_level << "\n"
<< " quiet = " << (settings.logging_enabled ? "false" : "true") << "\n"
<< " frame_limit = " << (settings.frame_limit ? "true" : "false") << "\n"
<< " speed_percent = " << settings.speed_percent << "\n"
<< " fps_limit = " << settings.fps_limit << "\n"
<< " fast_forward_speed = " << FastForwardSpeedToString(settings.fast_forward_speed) << "\n"
<< " frame_skip = " << settings.frame_skip << "\n"
<< " adpf_clock_hint = " << (settings.adpf_clock_hint ? "true" : "false") << "\n"
<< " affinity_mode = " << settings.affinity_mode << "\n\n"
<< "[paths]\n"
<< " expansion_rom = \"" << EscapeTomlString(settings.exp_path) << "\"\n"
<< " default_psx_exe = \"" << EscapeTomlString(settings.default_exe_path) << "\"\n\n"
<< "[video]\n"
<< " vsync = " << (settings.vsync_enabled ? "true" : "false") << "\n"
#ifdef USE_HARDWARE
<< " gpu_backend = \"" << GpuBackendSettingToken(settings.gpu_backend) << "\"\n"
// Round-trips the explicit hardware-cpu / hardware-gl tokens rather than collapsing
// them back to "hardware", so an A/B choice made in settings.toml survives the next
// save from the UI.
<< " renderer = \"" << RendererSettingToken(settings) << "\"\n"
<< " internal_scale = " << settings.internal_scale << "\n"
#endif
<< " accurate_mask_bit = " << (settings.accurate_mask_bit ? "true" : "false") << "\n"
<< " accurate_dither = " << (settings.accurate_dither ? "true" : "false") << "\n"
<< " accurate_prim_size = " << (settings.accurate_prim_size ? "true" : "false") << "\n"
<< " accurate_tex_modulate = " << (settings.accurate_tex_modulate ? "true" : "false") << "\n"
<< " pgxp = " << (settings.pgxp ? "true" : "false") << "\n"
// Every one of these MUST be written. [video] is rewritten wholesale from this
// explicit list, so a key with no line here is dropped from settings.toml the first
// time anything saves — the setting appears to work and then silently reverts.
<< " widescreen_hack = " << (settings.widescreen_hack ? "true" : "false") << "\n"
<< " texture_filter = \"" << TextureFilterToString(settings.texture_filter) << "\"\n"
<< " texture_dump = " << (settings.texture_dump ? "true" : "false") << "\n"
<< " texture_replacements = " << (settings.texture_replacements ? "true" : "false") << "\n"
<< " texture_dir = \"" << EscapeTomlString(settings.texture_dir) << "\"\n"
<< " downsample = " << settings.downsample << "\n"
<< " deinterlace = \"" << DeinterlaceToString(settings.deinterlace) << "\"\n"
<< " overscan_crop = \"" << OverscanToString(settings.overscan_crop) << "\"\n"
<< " display_rotation = " << (settings.display_rotation & 3) * 90 << "\n"
<< " line_detect = \"" << LineDetectToString(settings.line_detect) << "\"\n"
<< " texture_scale_mode = " << (settings.texture_scale_mode ? "true" : "false") << "\n"
<< " debug_panel = " << (settings.debug_panel ? "true" : "false") << "\n"
<< " stretch_mode = " << (settings.stretch_mode ? "true" : "false") << "\n"
<< " display_aspect = \"" << AspectToString(settings.display_aspect) << "\"\n"
<< " display_aspect_custom = " << settings.display_aspect_custom << "\n"
<< " integer_scaling = " << (settings.integer_scaling ? "true" : "false") << "\n"
<< " wide_upscale = \"" << UpscaleToString(settings.upscale_height) << "\"\n\n"
<< "[input]\n"
<< " analog_mode_default = " << (settings.analog_mode_default ? "true" : "false") << "\n"
<< " multitap = " << (settings.multitap ? "true" : "false") << "\n\n"
<< "[emulation]\n"
<< " rewind = " << (settings.rewind ? "true" : "false") << "\n"
<< " rewind_seconds = " << settings.rewind_seconds << "\n"
<< " rewind_frequency = " << settings.rewind_frequency << "\n"
<< " runahead = " << settings.runahead << "\n\n"
// [cheats]. MUST be written for the same reason as every other section: this rewrites
// settings.toml wholesale from an explicit list, so a key with no line here is erased
// the first time the core saves and the user's cheat selection silently disappears.
// The values come from RefreshCheatSettingsFromDisk() above, not from the boot
// snapshot, so a selection made while the game is running survives this save.
<< "[cheats]\n"
<< " enabled = " << (settings.cheats_enabled ? "true" : "false") << "\n"
<< " file = \"" << EscapeTomlString(settings.cheats_file) << "\"\n"
<< " enabled_codes = ";
write_string_array(settings.cheats_enabled_codes);
out
<< "\n\n"
<< "[audio]\n"
<< " volume = " << settings.audio_volume << "\n"
<< " fast_forward_volume = " << settings.audio_ff_volume << "\n"
<< " muted = " << (settings.audio_muted ? "true" : "false") << "\n"
<< " mute_fast_forward = " << (settings.audio_mute_fast_forward ? "true" : "false") << "\n"
<< " swap_channels = " << (settings.audio_swap_channels ? "true" : "false") << "\n"
<< " skip_reverb = " << (settings.audio_skip_reverb ? "true" : "false") << "\n"
<< " buffer_ms = " << settings.audio_buffer_ms << "\n"
<< " driver = \"" << AudioDriverToString(settings.audio_driver) << "\"\n"
<< " background_playback = " << (settings.audio_background_playback ? "true" : "false") << "\n\n"
<< "[library]\n"
<< " folders = ";
write_path_array(settings.ui_state.game_list_paths);
out << "\n recursive_folders = ";
write_path_array(settings.ui_state.game_list_recursive_paths);
out
<< "\n\n[fsui]\n"
<< " default_game_view = " << settings.ui_state.default_game_view << "\n"
<< " game_sort = " << settings.ui_state.game_sort << "\n"
<< " game_sort_reverse = " << (settings.ui_state.game_sort_reverse ? "true" : "false") << "\n";
out.flush();
if (!out.good()) {
std::error_code ec;
std::filesystem::remove(temp, ec);
return false;
}
out.close();
if (!out.good()) {
std::error_code ec;
std::filesystem::remove(temp, ec);
return false;
}
#if defined(_WIN32)
if (MoveFileExW(temp.c_str(), target.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) == 0) {
std::error_code ec;
std::filesystem::remove(temp, ec);
return false;
}
#else
std::error_code ec;
std::filesystem::rename(temp, target, ec);
if (ec) {
std::filesystem::remove(temp, ec);
return false;
}
#endif
return true;
}
void MixPsxAudio(psx_t* psx, uint8_t* buffer, int size) {
if (!psx || !buffer || size <= 0) {
return;
}
psx_cdrom_t* cdrom = psx->cdrom;
psx_spu_t* spu = psx->spu;
std::memset(buffer, 0, static_cast<size_t>(size));
psx_cdrom_get_audio_samples(cdrom, buffer, size);
psx_spu_update_cdda_buffer(spu, cdrom->cdda_buf);
for (int sample = 0; sample < (size >> 2); sample++) {
// Drain first, generate only as a fallback. psx_spu_tick() has already produced this
// frame's samples spread across the frame, from psx_update(), which is the ONLY way an
// SPU interrupt raised mid-frame can reach a running CPU. Generating here instead
// stops the machine, and every interrupt raised in the lump collapses into one at the
// frame boundary — the starvation described above psx_spu_tick() in psx/dev/spu.c.
//
// The fallback is not dead code: a frame that reaches vblank in fewer cycles than its
// sample budget covers leaves a genuine shortfall, and so does the frame a state load
// lands in. Filling it here keeps the per-frame sample count exactly what the queueing
// and resampling below have always been handed.
uint32_t value = 0;
if (!psx_spu_pop_sample(spu, &value)) {
value = psx_spu_get_sample(spu);
if (g_psx_audio_diag_enabled) {
g_psx_audio_diag.spu_gen_inline++;
}
}
const int16_t left = static_cast<int16_t>(value & 0xffff);
const int16_t right = static_cast<int16_t>(value >> 16);
// This is the ONE place CD/XA and the SPU meet. The add used to be a bare 16-bit +=
// with no saturation, so a loud disc track under a loud sound effect wrapped from full
// positive to full negative — heard as a hard buzz, not as distortion.
//
// It measured wrap=0 in all three MGS captures, so on its own it was not worth
// touching. It is saturated now because the SPU main-volume fix (spu_main_gain, 6 dB)
// doubles what the SPU contributes here: the dry sum already reached 30016 of 32767
// during an alert, so post-fix the SPU alone lands near full scale and ANY CD audio
// added on top would wrap. Saturating cannot alter a sample that was not going to
// wrap, so this is a no-op everywhere the old behaviour was already correct.
auto* out_l = reinterpret_cast<int16_t*>(&buffer[(sample << 2) + 0]);
auto* out_r = reinterpret_cast<int16_t*>(&buffer[(sample << 2) + 2]);
const int sum_l = *out_l + left;
const int sum_r = *out_r + right;
if (g_psx_audio_diag_enabled) {
if (sum_l > INT16_MAX || sum_l < INT16_MIN || sum_r > INT16_MAX || sum_r < INT16_MIN) {
g_psx_audio_diag.mix_wrap++;
g_psx_audio_diag.mix_sat++;
}
PSX_AUDIO_DIAG_PEAK(g_psx_audio_diag.mix_peak_l, sum_l);
PSX_AUDIO_DIAG_PEAK(g_psx_audio_diag.mix_peak_r, sum_r);
}
*out_l = static_cast<int16_t>(std::clamp(sum_l, -32768, 32767));
*out_r = static_cast<int16_t>(std::clamp(sum_r, -32768, 32767));
}
}
class ArmsxSession {
public:
~ArmsxSession() {
destroy();
}
bool create(armsx_renderer_t* renderer, const FrontendSettings& settings, const LaunchRequest& request, std::string& error) {
destroy();
if (!renderer) {
error = "Display is not ready.";
return false;
}
render_ = renderer;
vblank_counter_ = 0;
psxe_config_t cfg{};
cfg.bios = settings.bios_override.empty() ? nullptr : settings.bios_override.c_str();
cfg.bios_search = settings.bios_search.c_str();
cfg.model = settings.model.c_str();
char* bios_path = psxe_cfg_get_bios_path(&cfg);
std::unique_ptr<char, decltype(&free)> bios_guard(bios_path, &free);
if (!bios_path || !bios_path[0]) {
error = "No BIOS could be resolved. Set a BIOS override file or a BIOS folder that contains the selected model.";
return false;
}
psx_ = psx_create();
if (!psx_) {
error = "Failed to allocate a PSX session.";
return false;
}
const char* expansion = settings.exp_path.empty() ? nullptr : settings.exp_path.c_str();
const int init_result = psx_init(psx_, bios_path, expansion);
if (init_result != 0) {
error = "Failed to initialize the emulator core. Check the BIOS and expansion ROM paths.";
destroy();
return false;
}
psx_cpu_set_execution_mode(psx_get_cpu(psx_), settings.cpu_engine);
psxe_diag_logf("cpu", "execution engine=%s", CpuEngineSettingToken(settings.cpu_engine));
ARMSX_BOOTLOG("core: CPU execution engine=%s", CpuEngineSettingToken(settings.cpu_engine));
psx_gpu_t* gpu = psx_get_gpu(psx_);
{
uint32_t accuracy = 0;
if (settings.accurate_mask_bit) {
accuracy |= PSX_GPU_ACCURACY_MASK_BIT;
}
if (settings.accurate_dither) {
accuracy |= PSX_GPU_ACCURACY_DITHER_GATE;
}
if (settings.accurate_prim_size) {
accuracy |= PSX_GPU_ACCURACY_PRIM_SIZE;
}
if (settings.accurate_tex_modulate) {
accuracy |= PSX_GPU_ACCURACY_TEX_MODULATE;
}
psx_gpu_set_accuracy_flags(gpu, accuracy);
psx_gpu_set_texture_filter(gpu, settings.texture_filter);
/* Texture dumping / replacement (psx/texrep.h). Applied at boot from
settings.toml; Android re-pushes a per-game directory through
setPs1TextureOptions once it knows the disc serial. Both off is the shipping
default and allocates nothing. */
{
const std::string dir = ResolveTextureDir(settings.texture_dir);
psx_texrep_configure(gpu,
settings.texture_dump ? 1 : 0,
settings.texture_replacements ? 1 : 0,
dir.c_str());
if (settings.texture_dump || settings.texture_replacements) {
log_info("Texture %s%s%s from %s",
settings.texture_dump ? "dumping" : "",
(settings.texture_dump && settings.texture_replacements) ? " + " : "",
settings.texture_replacements ? "replacement" : "",
dir.c_str());
}
}
if (accuracy) {
log_info("GPU accuracy fixes: mask_bit=%s dither_gate=%s prim_size=%s tex_modulate=%s",
settings.accurate_mask_bit ? "on" : "off",
settings.accurate_dither ? "on" : "off",
settings.accurate_prim_size ? "on" : "off",
settings.accurate_tex_modulate ? "on" : "off");
}
}
// Marker-armed one-shot GP0 primitive dump (psx/dev/gpu.c). The directory is PUSHED
// in rather than pulled because psx/ must not link frontend/diagnostics.c — both GPU
// test harnesses compile gpu.c without it. Completely inert until someone creates
// files/logs/gpu_prim_dump, exactly like gpu_hw_gl.c's gl_debug_marker() switches.
{
const char* diag_log_path = psxe_diag_log_path();
const char* diag_slash = diag_log_path ? std::strrchr(diag_log_path, '/') : nullptr;
if (diag_slash) {
const std::string diag_dir(
diag_log_path, static_cast<size_t>(diag_slash - diag_log_path) + 1u);
psx_gpu_debug_set_log_dir(gpu, diag_dir.c_str());
}
}
// PGXP. Applied whether on or off so a settings change between runs
// always lands; the setter itself is idempotent and logs transitions.
psx_pgxp_set_enabled(settings.pgxp ? 1 : 0);
// Widescreen hack (psx/cpu.c GTE). Same reasoning as PGXP: core-global, applied
// unconditionally so a change between runs always lands, and idempotent.
psx_cpu_set_widescreen_hack(settings.widescreen_hack ? 1 : 0);
// GLES rasterizer video options. Pushed BEFORE the backend is created below, and
// deliberately not a create() argument: the backend is recreated behind our back on
// any GL failure and these have to survive that. Harmless when the GLES backend is
// not built or not selected.
#ifdef USE_HARDWARE
armsx_hw_gl_set_video_options(settings.texture_filter, settings.downsample,
settings.line_detect);
#endif
// Host CPU scheduling levers from [runtime]. DEFAULTS, not assignments: the Android
// front-end pushes both through JNI before the core ever opens settings.toml, and a
// file that predates these keys carries the off value for both. Applying it here as if
// it were a user choice would silently un-toggle whatever the UI just set — the exact
// shape of the "per-game INI shadows the higher layer" bug. perf_hint.c drops these on
// the floor once a host has spoken.
armsx_perf_hint_adpf_set_default(settings.adpf_clock_hint ? 1 : 0);
armsx_affinity_set_default_mode(settings.affinity_mode);
#ifdef USE_HARDWARE
// Internal-resolution rasterizer. Entirely opt-in: with [video] renderer =
// "software" (the default) no backend is installed and psx/dev/gpu.c behaves
// exactly as it always has. See frontend/gpu_hw_rt.h.
if (settings.hw_rasterizer) {
// Two implementations of one ABI. The GLES one rasterizes on the GPU, so its
// CPU cost does not grow with the internal scale at all; the CPU one is
// graphics-API-independent and is the fallback when there is no GL context (a
// Vulkan or SDL presentation backend). See frontend/gpu_hw_gl.h.
const int mode = settings.rasterizer_mode;
rasterizer_mode_ = mode;
internal_scale_ = settings.internal_scale;
if (mode != 2) {
hw_rt_backend_ = armsx_hw_gl_create(gpu, settings.internal_scale);
hw_rt_is_gl_ = (hw_rt_backend_ != nullptr);
if (!hw_rt_backend_) {
// Never silent: armsx_hw_gl_status() says which of the availability
// gates rejected it.
log_info("GLES rasterizer not used: %s", armsx_hw_gl_status());
psxe_diag_logf("renderer", "GLES rasterizer not used: %s",
armsx_hw_gl_status());
ARMSX_BOOTLOG("core: GLES rasterizer not used: %s", armsx_hw_gl_status());
}
}
if (!hw_rt_backend_ &&
armsx_hw_gl_use_cpu_fallback(mode, settings.internal_scale)) {
hw_rt_backend_ = armsx_hw_rt_create(gpu, settings.internal_scale);
hw_rt_is_gl_ = false;
}
if (hw_rt_backend_) {
psx_gpu_set_backend(gpu, hw_rt_backend_);
log_info("Rasterizer: %s at %dx internal resolution",
hw_rt_is_gl_ ? "GLES (GPU)" : "internal-resolution (CPU)",
psx_gpu_resolution_scale(gpu));
psxe_diag_logf("renderer", "Rasterizer selected=%s scale=%dx",
hw_rt_is_gl_ ? "GLES3 GPU" : "internal-resolution CPU",
psx_gpu_resolution_scale(gpu));
ARMSX_BOOTLOG("core: rasterizer selected=%s scale=%dx",
hw_rt_is_gl_ ? "GLES3 GPU" : "internal-resolution CPU",
psx_gpu_resolution_scale(gpu));
} else {
log_error("Hardware rasterizer unavailable at %dx; staying on the software path",
settings.internal_scale);
psxe_diag_logf("renderer",
"Hardware rasterizer unavailable at %dx; using software",
settings.internal_scale);
ARMSX_BOOTLOG("core: hardware rasterizer unavailable at %dx; using software",
settings.internal_scale);
}
}
#endif
psx_gpu_set_event_callback(gpu, GPU_EVENT_DMODE, nullptr);
psx_gpu_set_event_callback(gpu, GPU_EVENT_VBLANK, SessionVblankEvent);
psx_gpu_set_event_callback(gpu, GPU_EVENT_HBLANK, psxe_gpu_hblank_event_cb);
psx_gpu_set_event_callback(gpu, GPU_EVENT_VBLANK_END, psxe_gpu_vblank_end_event_cb);
psx_gpu_set_event_callback(gpu, GPU_EVENT_HBLANK_END, psxe_gpu_hblank_end_event_cb);
psx_gpu_set_udata(gpu, 0, this);
psx_gpu_set_udata(gpu, 1, psx_->timer);
psx_gpu_set_udata(gpu, 2, nullptr);
#ifdef USE_HARDWARE
// "Hardware backend active" now means "presentation is GPU-accelerated", whichever
// backend provided it. The PlayStation rasterizer itself is unaffected either way.
hardware_backend_active_ = armsx_renderer_is_accelerated(render_);
if (settings.gpu_backend != GpuBackend::Software && !hardware_backend_active_) {
psxe_diag_logf("renderer", "GPU presentation requested but unavailable; using software presentation.");
} else if (hardware_backend_active_) {
psxe_diag_logf(
"renderer",
"Accelerated presentation enabled backend=%s driver=%s; PlayStation VRAM remains software-authoritative.",
armsx_render_backend_name(armsx_renderer_backend(render_)),
armsx_renderer_driver_name(render_)
);
}
#endif
psx_cdrom_set_region(psx_get_cdrom(psx_), RegionFromSettings(settings));
// [input] analog_mode_default. Default OFF = digital, matching real hardware. It was
// briefly defaulted ON so the sticks were discoverable (the ANALOG button has no
// standard Android keycode), and that broke input outright in pre-DualShock titles:
// Crash Bandicoot (1996) mis-handles a pad reporting model 0x73 at boot, leaving BOTH
// the touch overlay and a physical pad dead. Pad code 200 still toggles live either way.
//
// [input] multitap decides WHICH device goes in the port — one pad, or a tap carrying
// four of them. Both paths go through attachPadDevice() so the live toggle below
// cannot drift from what a launch builds.
analog_mode_default_ = settings.analog_mode_default;
if (!attachPadDevice(settings.multitap, settings.analog_mode_default)) {
error = "Failed to create the controller input bridge.";
destroy();
return false;
}
ARMSX_BOOTLOG("core: pad boot mode=%s (analog_mode_default=%s) multitap=%s",
settings.analog_mode_default ? "analog" : "digital",
settings.analog_mode_default ? "true" : "false",
settings.multitap ? "true" : "false");
// Rewind + runahead (psx/rewind.h). Applied here rather than at process start so a
// relaunch with changed settings picks them up, and so nothing is allocated until a
// machine actually exists to snapshot.
psx_rewind_configure(settings.rewind ? 1 : 0, settings.rewind_seconds,
settings.rewind_frequency);
psx_runahead_configure(settings.runahead);
runahead_restore_pending_ = false;
// [cheats]. Armed here, at the same point and for the same reason as rewind above: a
// relaunch with a changed selection has to pick it up, and there is no machine to
// patch until one exists. Costs a file read of a few KB when the game has cheats and
// literally nothing when it does not.
ApplyCheatSettings(settings);
const std::string slot1 = std::string(psxe_cfg_get_pref_path() ? psxe_cfg_get_pref_path() : "") + "slot1.mcd";
const std::string slot2 = std::string(psxe_cfg_get_pref_path() ? psxe_cfg_get_pref_path() : "") + "slot2.mcd";
psx_pad_attach_mcd(psx_->pad, 0, slot1.c_str());
psx_pad_attach_mcd(psx_->pad, 1, slot2.c_str());
switch (request.kind) {
case LaunchKind::Disc:
if (psx_cdrom_open(psx_get_cdrom(psx_), request.path.string().c_str()) == 0) {
error = "Failed to open the selected disc image.";
destroy();
return false;
}
disc_path_ = request.path;
exe_path_.clear();
title_ = request.label.empty() ? StemToTitle(request.path) : request.label;
break;
case LaunchKind::Exe:
while (psx_->cpu->pc != 0x80030000) {
psx_update(psx_);
}
psx_load_exe(psx_, request.path.string().c_str());
exe_path_ = request.path;
disc_path_.clear();
title_ = request.label.empty() ? StemToTitle(request.path) : request.label;
break;
case LaunchKind::Bios:
disc_path_.clear();
exe_path_.clear();
title_ = "PlayStation BIOS";
break;
case LaunchKind::None:
default:
error = "Invalid launch request.";
destroy();
return false;
}
launch_kind_ = request.kind;
#if defined(HW_DEBUG)
#ifdef USE_HARDWARE
if (hardware_backend_active_ && psx_ && psx_->gpu) {
int output_width = 0;
int output_height = 0;
armsx_renderer_output_size(render_, &output_width, &output_height);
psxe_diag_logf(
"hw",
"session-create kind=%s title=%s renderer=%p output=%dx%d texture=%dx%d timing=%s target_fps=%.3f display_mode=0x%08x gpustat=0x%08x",
LaunchKindTitle(request.kind),
title_.c_str(),
(void*)render_,
output_width,
output_height,
texture_width_,
texture_height_,
timingModeTitle(),
targetFrameRate(),
psx_->gpu->display_mode,
psx_->gpu->gpustat
);
}
#endif
#endif
psxe_diag_breadcrumbf(
"Session created kind=%s title=%s path=%s bios=%s model=%s region=%s",
LaunchKindTitle(request.kind),
title_.c_str(),
request.path.empty() ? "(none)" : request.path.string().c_str(),
bios_path,
settings.model.c_str(),
settings.region.c_str()
);
applyAudioSettings(settings);
setSpeedLimits(settings.frame_limit, settings.speed_percent, settings.fps_limit,
settings.fast_forward_speed, settings.frame_skip);
// A session being (re)initialised over a live device has to give the old one back first.
// openAudioDevice() below no-ops while one is open, and the previous code just overwrote
// audio_dev_ — which leaked the device and left buffer_ms stuck on the old value.
closeAudioDevice();
// Remembered so the device can be reopened byte-for-byte identically when the app comes
// back from the background — see setAudioSuspended(). allowed_changes = 0, so SDL always
// hands the callback exactly this format and `obtained` never differs.
audio_desired_ = SDL_AudioSpec{};
audio_desired_.freq = kAudioMixRate;
audio_desired_.format = AUDIO_S16SYS;
audio_desired_.channels = 2;
// [audio] buffer_ms. A PS1 PAL frame contributes about 20 ms of stereo samples. A smaller OpenSL ES
// callback period lets the device drain the queue between two emulated frames, which
// is audible as a crackle even when the mixer itself is perfectly healthy. Keep the
// user setting, but never request a sub-frame period; the queue prebuffer below absorbs
// ordinary Android scheduler jitter on top of that cadence-safe floor.
audio_desired_.samples = static_cast<Uint16>(std::clamp(
(kAudioMixRate * std::clamp(settings.audio_buffer_ms, kAudioMinimumBufferMs, 100)) / 1000,
64, 8192));
audio_desired_.callback = AudioUpdate;
audio_desired_.userdata = this;
paused_ = false;
// A fresh session owns a fresh device, so the background latch has to start clear or the
// first return from the background would be swallowed as a no-op.
audio_suspended_ = false;
fast_forward_enabled_ = false;
debug_view_ = false;
texture_width_ = 0;
texture_height_ = 0;
texture_format_ = SDL_PIXELFORMAT_UNKNOWN;
openAudioDevice();
updateTexture(settings);
return true;
}
// Open (or reopen) the SDL device from the remembered spec and reset everything the queue
// and the rate converter carry across it. No-op if a device is already open.
void openAudioDevice() {
if (audio_dev_) {
return;
}
audio_dev_ = SDL_OpenAudioDevice(nullptr, 0, &audio_desired_, nullptr, 0);
if (!audio_dev_) {
// Only worth shouting about once per failure run; ensureAudioDevice() retries.
if (!audio_reopen_failed_) {
audio_reopen_failed_ = true;
psxe_diag_logf("audio", "open failed: %s", SDL_GetError());
}
return;
}
audio_reopen_failed_ = false;
audio_device_ever_opened_ = true;
audio_playback_started_ = false;
audio_rebuffer_requested_ = false;
audio_sample_accumulator_ = 0.0;
audio_queue_.clear();
audio_queue_read_offset_ = 0;
audio_consumed_bytes_ = 0;
resetAudioRateConverter();
updateAudioPlaybackState();
}
/**
* Retry a reopen that did not take.
*
* The whole point of closing the device on background is that it can be given back — and the
* one way this fix could be WORSE than the bug is a return from the background whose
* SDL_OpenAudioDevice() fails (the output can be momentarily unavailable while another app is
* still tearing its own stream down), leaving the game silent for the rest of the session
* with nothing to retry it. Called once per frame from ArmsxApp::runFrame().
*
* Only ever retries a device that HAS worked in this session: a boot where audio never came
* up at all is a different problem (no driver, dummy driver) and must not spin here.
*/
void ensureAudioDevice() {
if (audio_dev_ || audio_suspended_ || !audio_device_ever_opened_) {
return;
}
const Uint64 now = SDL_GetTicks64();
if (audio_reopen_next_attempt_ != 0 && now < audio_reopen_next_attempt_) {
return;
}
audio_reopen_next_attempt_ = now + 1000;
openAudioDevice();
}
// Tear the SDL device down completely.
//
// ★ Closing is the ONLY way to actually stop the stream on Android. SDL_PauseAudioDevice()
// does not: SDL's audio thread keeps running and keeps handing the backend buffers, it just
// fills them with silence, and the OpenSL ES player stays PLAYING. AudioFlinger therefore
// keeps our track Active, the output thread never reaches standby, and audioserver holds a
// PARTIAL_WAKE_LOCK ('AudioMix', charged to this app's uid) for as long as we are in the
// background — measured on a sleeping Retroid Pocket 6, where it was the only wake lock left
// on the device. SDL's own openslES_PauseDevices() would park the player properly, but it is
// NOT exported from libSDL2.so (935 exported symbols, none of them openslES_*), so linking
// against it produces a library that will not load.
//
// SDL_CloseAudioDevice() reaches openslES_DestroyPCMPlayer(), which SetPlayState(STOPPED)s
// and Destroy()s the player object. The audio thread is joined first, and it cannot wedge:
// the player is still playing while SDL waits, so the buffer-queue callback keeps posting the
// semaphore the thread is parked on until it notices the shutdown flag.
void closeAudioDevice() {
if (!audio_dev_) {
audio_playback_started_ = false;
return;
}
SDL_LockAudioDevice(audio_dev_);
resetAudioQueueLocked();
audio_rebuffer_requested_ = false;
SDL_UnlockAudioDevice(audio_dev_);
SDL_PauseAudioDevice(audio_dev_, 1);
SDL_CloseAudioDevice(audio_dev_);
audio_dev_ = 0;
audio_playback_started_ = false;
}
// Push the [audio] table at the live session. Called at create() and again whenever the
// host re-applies settings, so volume/mute/swap/skip-reverb take effect without a relaunch.
// buffer_ms and driver are NOT live: both are properties of an already-open SDL device.
void applyAudioSettings(const FrontendSettings& settings) {
audio_volume_ = std::clamp(settings.audio_volume, 0, 200);
audio_ff_volume_ = std::clamp(settings.audio_ff_volume, 0, 200);
audio_muted_ = settings.audio_muted;
audio_mute_fast_forward_ = settings.audio_mute_fast_forward;
audio_swap_channels_ = settings.audio_swap_channels;
if (psx_ && psx_->spu) {
psx_spu_set_reverb_disabled(psx_->spu, settings.audio_skip_reverb ? 1 : 0);
}
if (audio_dev_) {
updateAudioPlaybackState();
}
}
void destroy() {
// A capture still open here would leak its FILE* and lose its tail; closing it also
// clears g_psx_audio_diag_enabled so a fresh session starts unarmed.
audioDiagEnd();
if (psx_) {
psxe_diag_breadcrumbf(
"Session destroyed kind=%s title=%s path=%s",
LaunchKindTitle(launch_kind_),
title_.empty() ? "(none)" : title_.c_str(),
disc_path_.empty() ? (exe_path_.empty() ? "(none)" : exe_path_.string().c_str()) : disc_path_.string().c_str()
);
}
// Already closed when the app is being torn down straight out of the background; the
// latches are cleared so a session that gets rebuilt reopens normally and does not
// inherit the last session's reopen-retry state.
closeAudioDevice();
audio_suspended_ = false;
audio_device_ever_opened_ = false;
audio_reopen_failed_ = false;
audio_reopen_next_attempt_ = 0;
resetAudioRateConverter();
// [cheats]. Drops the catalogue and the compiled program, so the next game starts with
// nothing armed instead of inheriting this disc's selection — and so the strings this
// module owns are freed rather than living for the process.
psx_cheats_shutdown();
texture_snapshot_.clear();
frame_uploaded_ = false;
#ifdef USE_HARDWARE
if (hw_renderer_) {
armsx_hw_renderer_destroy(hw_renderer_);
hw_renderer_ = nullptr;
}
#endif
const bool input_attached = psx_ && psx_->pad && (psx_->pad->joy_slot[0] == input_);
#ifdef USE_HARDWARE
// Detach before the GPU goes away: psx_gpu_t does not own the backend.
if (hw_rt_backend_) {
releaseAdoptedTexture();
if (psx_) {
psx_gpu_set_backend(psx_get_gpu(psx_), nullptr);
}
if (hw_rt_is_gl_) {
armsx_hw_gl_destroy(hw_rt_backend_);
} else {
armsx_hw_rt_destroy(hw_rt_backend_);
}
hw_rt_backend_ = nullptr;
hw_rt_is_gl_ = false;
}
#endif
if (psx_) {
psx_destroy(psx_);
psx_ = nullptr;
}
// The clean-shutdown PGO flush, riding the same teardown that just flushed the memory
// cards inside psx_destroy(). Covers Close Game and switching titles; the background
// flush in psxe_host_set_audio_suspended() covers everything else, including being
// killed. Both write-then-reset, so the two never double-count each other. No-op in a
// normal build (see frontend/pgo.c).
armsx_pgo_flush("session-teardown");
if (input_ && !input_attached) {
psx_input_destroy(input_);
}
input_ = nullptr;
render_ = nullptr;
disc_path_.clear();
exe_path_.clear();
title_.clear();
launch_kind_ = LaunchKind::None;
paused_ = false;
fast_forward_enabled_ = false;
debug_view_ = false;
vblank_counter_ = 0;
#ifdef USE_HARDWARE
hardware_backend_active_ = false;
#endif
}
bool valid() const {
return psx_ != nullptr;
}
psx_t* psx() const {
return psx_;
}
psx_pad_t* pad() const {
return psx_ ? psx_->pad : nullptr;
}
void rebindRenderer(armsx_renderer_t* renderer, const FrontendSettings& settings) {
render_ = renderer;
#ifdef USE_HARDWARE
if (hw_renderer_) {
armsx_hw_renderer_set_renderer(hw_renderer_, armsx_renderer_sdl(render_));
if (texture_width_ > 0 && texture_height_ > 0) {
armsx_hw_renderer_set_output_size(hw_renderer_, texture_width_, texture_height_);
}
}
hardware_backend_active_ = armsx_renderer_is_accelerated(render_);
#endif
texture_snapshot_.clear();
frame_uploaded_ = false;
texture_width_ = 0;
texture_height_ = 0;
texture_format_ = SDL_PIXELFORMAT_UNKNOWN;
updateTexture(settings);
}
void setPaused(bool paused) {
paused_ = paused;
if (audio_dev_) {
if (paused) {
SDL_LockAudioDevice(audio_dev_);
resetAudioQueueLocked();
audio_rebuffer_requested_ = false;
SDL_UnlockAudioDevice(audio_dev_);
audio_playback_started_ = false;
}
updateAudioPlaybackState();
}
}
/**
* Release the audio device entirely because the app went off-screen, and take it back when
* it returns.
*
* Kept SEPARATE from setPaused(): the in-game pause menu wants the emulation frozen with the
* device still open (that is the cheap, instant path, and the overlay is still on screen).
* This is the app-is-not-on-screen case, where holding the device costs a wake lock and
* leaks the tail of the mix out of a sleeping handheld. See closeAudioDevice() for why
* pausing the device is not enough and closing it is what actually works.
*/
void setAudioSuspended(bool suspended) {
if (audio_suspended_ == suspended) {
return;
}
audio_suspended_ = suspended;
if (suspended) {
closeAudioDevice();
} else {
openAudioDevice();
}
}
bool audioSuspended() const {
return audio_suspended_;
}
bool paused() const {
return paused_;
}
void setFastForwardEnabled(bool enabled) {
if (fast_forward_enabled_ == enabled) {
return;
}
fast_forward_enabled_ = enabled;
// Drop whatever is queued on BOTH edges. Entering fast-forward, the queue holds
// real-time audio the converter is about to start compressing; leaving it, the queue
// holds compressed audio. Either way the tail belongs to the old rate.
clearQueuedAudio();
resetAudioRateConverter();
if (audio_dev_) {
updateAudioPlaybackState();
}
}
bool fastForwardEnabled() const {
return fast_forward_enabled_;
}
// The whole speed policy in one call: frame_limit gates the limiter, speed_percent scales
// the game's own rate, fps_limit is an optional absolute ceiling and fast_forward_speed
// replaces speed_percent while fast-forward is engaged (0 = uncapped). Set from
// [runtime] at launch and by psxe_host_set_speed_limits() when the UI moves.
//
// frame_skip rides along because the host edits it from the same panel and pushes the whole
// policy at once — but it is NOT a speed control and nothing below reads it. It changes how
// many emulated frames reach the screen, never how fast they are produced, so it is
// deliberately kept out of targetFrameRate() and out of the audio converter's decision.
void setSpeedLimits(bool frame_limit, int speed_percent, int fps_limit, double fast_forward_speed,
int frame_skip) {
const bool converter_was_active = audioRateConversionActive();
frame_limit_ = frame_limit;
speed_percent_ = std::clamp(speed_percent, 10, 1000);
fps_limit_ = std::clamp(fps_limit, 0, 1000);
fast_forward_speed_ = (fast_forward_speed > 0.0) ? std::clamp(fast_forward_speed, 1.0, 16.0) : 0.0;
frame_skip_ = std::clamp(frame_skip, -1, kMaxFrameSkip);
// A speed change re-aims the converter; leaving its phase and rate estimate behind
// would have it glide across the change instead of tracking it.
if (converter_was_active != audioRateConversionActive()) {
clearQueuedAudio();
}
resetAudioRateConverter();
}
double fastForwardSpeed() const {
return fast_forward_speed_;
}
// 0 = off, 1..kMaxFrameSkip = fixed, -1 = adaptive. Read once per frame by the app loop.
int frameSkip() const {
return frame_skip_;
}
// The sentinel targetFrameRate() hands back for "no cap". Exposed so the app loop can tell
// an unreachable target apart from a real one — adaptive frame skip must not treat a
// deliberately uncapped session as "running behind".
static constexpr double uncappedFrameRate() {
return kUncappedFrameRate;
}
/*
Build controller port 1's device and plug it in.
ONE builder for both the launch path and the live Multitap toggle, so the two cannot
drift — a tap that behaves differently depending on whether it was switched on before
or after the game booted is exactly the sort of half-wiring this project keeps hitting.
psx_pad_attach_joy() destroys whatever was in the slot first, so this is also the
detach. The SIO destination is cleared afterwards because the swap can land in the
middle of a transfer; the game simply re-polls on its next frame.
*/
bool attachPadDevice(bool multitap, bool analog_default) {
if (!psx_ || !psx_->pad) {
return false;
}
psx_input_t* input = psx_input_create();
if (!input) {
return false;
}
psx_input_init(input);
if (multitap) {
psxi_multitap_t* tap = psxi_multitap_create();
if (!tap) {
psx_input_destroy(input);
return false;
}
psxi_multitap_init(tap);
psxi_multitap_set_analog_mode(tap, analog_default ? 1 : 0);
psxi_multitap_init_input(tap, input);
} else {
psxi_sda_t* sda = psxi_sda_create();
if (!sda) {
psx_input_destroy(input);
return false;
}
psxi_sda_init(sda, SDA_MODEL_DIGITAL);
psxi_sda_set_analog_mode(sda, analog_default ? 1 : 0);
psxi_sda_init_input(sda, input);
}
psx_pad_attach_joy(psx_->pad, 0, input);
psx_->pad->dest[0] = 0;
input_ = input;
multitap_attached_ = multitap;
return true;
}
bool multitapAttached() const {
return multitap_attached_;
}
/* Live Multitap toggle. Emulation thread only — it destroys the device the SIO reads
through. Everything the old device was holding down goes with it, which is correct:
the guest is being told a different controller was plugged in. */
bool setMultitapEnabled(bool enabled) {
if (!psx_ || !psx_->pad || enabled == multitap_attached_) {
return false;
}
if (!attachPadDevice(enabled, analog_mode_default_)) {
log_error("multitap: could not rebuild the port %s a tap", enabled ? "with" : "without");
return false;
}
/* A snapshot taken with the other device in the port would be refused on load
(pad_load_joy checks the device kind), so drop them rather than leave a rewind
buffer that silently fails on every step. */
psx_rewind_reset();
runahead_restore_pending_ = false;
log_info("multitap: port 1 now has %s", enabled ? "a multitap (4 players)" : "one pad");
return true;
}
/* Undo the previous frame's look-ahead.
MUST run before this frame's input is applied: a snapshot carries the pad's button
word, so restoring after an input pass would silently swallow every press that landed
in between — and the press edge is never re-sent, so the button would simply never
reach the game. Called from ArmsxApp::runFrame() as its first act. */
void runaheadRestore() {
if (!runahead_restore_pending_ || !psx_) {
return;
}
runahead_restore_pending_ = false;
const int result = psx_runahead_restore(psx_);
if (result == PSX_STATE_ERR_MISSING) {
/* The slot was deliberately dropped between the look-ahead and now — a reset, a
disc swap, a rewind step, or the user loading a state (all of which call
psx_rewind_reset). Putting a stale future back over any of those would undo
what the user just asked for, so this is the CORRECT outcome, not a failure. */
return;
}
if (result != PSX_STATE_OK) {
/* Disarm rather than retry: a slot that will not apply now will not apply later,
and re-running the look-ahead on a machine that never got put back would
compound the drift. */
psx_runahead_configure(0);
log_error("runahead: could not restore the look-ahead snapshot (%s); runahead off",
psx_state_strerror(result));
}
}
/* One emulated frame: step until the GPU raises the next vblank. Shared by the ordinary
advance and by runahead's re-simulated frames, so the two cannot diverge. Returns the
instruction count. */
std::uint32_t stepOneFrame() {
const std::uint64_t start_vblank = vblank_counter_;
std::uint32_t steps = 0;
while (vblank_counter_ == start_vblank) {
psx_update(psx_);
steps++;
if (steps >= kMaxFrameSteps) {
psxe_diag_logf("timing", "Session frame advance exceeded vblank step budget title=%s",
title_.empty() ? "(none)" : title_.c_str());
break;
}
}
return steps;
}
/* Hold-to-rewind / one-shot step back. Returns true when the machine was moved BACKWARDS,
in which case the caller must not also advance it. Returns true (without moving) when
rewind is engaged but the ring is empty — holding rewind at the end of the buffer
freezes rather than resuming forward play, which is what every other emulator does and
what the button visibly promises. */
bool rewindFrameIfRequested() {
int steps = g_host_rewind_step_requests.exchange(0, std::memory_order_acq_rel);
const bool active = g_host_rewind_active.load(std::memory_order_acquire);
if (active) {
steps++;
}
if (steps <= 0) {
return false;
}
if (!psx_rewind_enabled()) {
return false;
}
bool moved = false;
for (int i = 0; i < steps; i++) {
if (psx_rewind_step_back(psx_) != PSX_STATE_OK) {
break;
}
moved = true;
}
if (moved) {
/* The look-ahead described a future of the timeline we just left. */
runahead_restore_pending_ = false;
}
return true;
}
std::uint32_t runFrame() {
if (!psx_ || paused_) {
return 0;
}
#if defined(HW_DEBUG)
traceHardwareFrameState("frame-start", 0);
#endif
#ifdef USE_HARDWARE
if (hardware_backend_active_ && hw_renderer_) {
armsx_hw_renderer_begin_frame(hw_renderer_);
}
#endif
// Rewind ([emulation] rewind). Checked before anything is stepped: stepping back IS
// this frame's work, so the machine must not also be advanced. The hardware frame that
// was just opened is still closed by the caller's finishHardwareFrame().
if (rewindFrameIfRequested()) {
return 0;
}
std::uint32_t steps = 0;
// R3000A accounting. The equivalent lives in psx_run_frame(), which this front-end
// never calls — it drives psx_update() itself, so the OSD's instruction and cycle
// rows read a flat zero while every other counter worked. Derived, not instrumented:
// the step count IS the instruction count, and the CPU already sums executed clocks
// into total_cycles, so psx_cpu_cycle() (half a million calls a frame) stays free of
// any counter.
const std::uint32_t cpu_cycles_before = psx_ ? psx_->cpu->total_cycles : 0;
const auto account_cpu = [&]() {
if (!g_psx_perf_enabled || !psx_) {
return;
}
g_psx_perf.cpu_instructions += steps;
// total_cycles is a wrapping uint32; the unsigned difference is exact over a frame.
g_psx_perf.cpu_cycles +=
static_cast<std::uint32_t>(psx_->cpu->total_cycles - cpu_cycles_before);
};
// Before the frame, not after: this is what grants the SPU its per-frame sample budget
// so psx_spu_tick() can spend it across the frame from psx_update(). See
// beginAudioFrame() and the block comment above psx_spu_tick() in psx/dev/spu.c.
beginAudioFrame();
steps = stepOneFrame();
account_cpu();
/*
[cheats]. THE application point: once per REAL emulated frame, at the vblank the
frame just reached.
Deliberately not inside stepOneFrame() — that also runs runahead's re-simulated
frames, whose whole timeline is thrown away and restored at the top of the next
frame, so patching them would be work nobody ever sees. And deliberately nowhere
near psx_update(), which runs half a million times a frame.
The hardcore interlock is re-asserted from the authoritative source every frame
rather than being set once at boot and trusted. It is one call returning a
compile-time false today; when hardcore is eventually switched on, cheats go off
on the very next frame with nothing left to remember to wire up.
Cost with no cheats armed: psx_cheats_apply() is a branch on a pointer plus one
atomic load, sixty times a second. See psx/cheats.h.
*/
psx_cheats_set_inhibited(armsx_ach_hardcore_active() ? 1 : 0);
psx_cheats_apply(psx_);
queueAudioForFrame();
// The grant ends with the frame it was made for. A frame that reached vblank in fewer
// cycles than its budget covered still has some left, and everything below this line —
// runahead's re-simulated frames above all — drives psx_update() outside any frame the
// host is going to pull. Revoking it here is what keeps "the SPU only ever produces the
// frames the audio device asked for" true by construction rather than by inspection.
if (psx_) {
psx_spu_begin_frame(psx_->spu, 0);
}
// Rewind snapshot for the frame that just happened. After the audio pull so the
// snapshot describes a whole frame, and before runahead so the ring records the real
// timeline rather than the look-ahead.
psx_rewind_notify_frame(psx_, frameRate());
/*
Runahead ([emulation] runahead).
The machine has advanced exactly one frame — that is the timeline. Snapshot it,
then run N MORE frames with the same held input and WITHOUT pulling audio, and
leave VRAM showing that Nth frame. What gets presented is therefore N frames into
the future, which is the whole point: the picture reacts to a press N frames
sooner. The snapshot is put back at the top of the next host frame
(runaheadRestore), before any new input, so the machine still only ever advances
one frame per frame.
Audio deliberately comes from the REAL frame only. Queueing the look-ahead frames
as well would play each frame's audio N+1 times.
The cost is unavoidable and is charged every single frame: one state save, one
state load, and N extra emulated frames. On a device that is already at 100% this
does not hide latency, it halves the frame rate — which is why the default is 0
and why the UI says so.
*/
const int runahead = psx_runahead_frames();
if (runahead > 0) {
if (psx_runahead_save(psx_) == PSX_STATE_OK) {
for (int i = 0; i < runahead; i++) {
stepOneFrame();
}
runahead_restore_pending_ = true;
} else {
psx_runahead_configure(0);
log_error("runahead: snapshot failed; runahead off");
}
}
#if defined(HW_DEBUG)
traceHardwareFrameState("frame-end", steps);
#endif
return steps;
}
void setDebugView(bool enabled) {
debug_view_ = enabled;
}
bool debugView() const {
return debug_view_;
}
bool reset() {
if (!psx_) {
return false;
}
psxe_diag_breadcrumbf("Soft reset requested title=%s", title_.empty() ? "(none)" : title_.c_str());
setFastForwardEnabled(false);
// psx_soft_reset() empties the rewind ring and the look-ahead slot; this clears the
// front-end's half of that handshake so the next frame does not try to restore one.
runahead_restore_pending_ = false;
psx_soft_reset(psx_);
return true;
}
bool swapDisc(const std::filesystem::path& path) {
if (!psx_) {
return false;
}
if (psx_swap_disc(psx_, path.string().c_str()) == 0) {
return false;
}
setFastForwardEnabled(false);
runahead_restore_pending_ = false;
disc_path_ = path;
launch_kind_ = LaunchKind::Disc;
title_ = StemToTitle(path);
psxe_diag_breadcrumbf("Disc swapped path=%s", path.string().c_str());
psx_soft_reset(psx_);
return true;
}
ArmsxGameInfo currentGameInfo() const {
ArmsxGameInfo info;
if (!psx_) {
return info;
}
info.has_game = true;
info.title = title_.empty() ? "ARMSX" : title_;
info.subtitle = launch_kind_ == LaunchKind::Bios ? "No disc inserted" : (disc_path_.empty() ? exe_path_.filename().string() : disc_path_.filename().string());
info.title_id = NormalizeModel(info.title);
info.path = launch_kind_ == LaunchKind::Bios ? std::filesystem::path("bios://boot") : (disc_path_.empty() ? exe_path_ : disc_path_);
return info;
}
// settingsOverlayLines / performanceOverlayLines removed with the FSUI cut (imgui overlays).
#ifdef USE_HARDWARE
// the backend: where the GPU path cannot serve a game it falls back
// EXPLICITLY AND LOGGED, never silently. The GLES backend disables itself on a
// persistent GL error rather than presenting an empty render target that looks like a
// black screen with no clue attached; this is the frontend half of that contract.
// The present layer may be sourcing straight from the GL rasterizer's scanout texture
// (armsx_hw_gl_present_texture). That texture dies with the backend, and a deleted GL
// name can be recycled by any later object, so the adoption has to be dropped BEFORE the
// backend is torn down — otherwise present samples whatever now owns that name.
void releaseAdoptedTexture() {
if (render_ && hw_rt_is_gl_) {
armsx_renderer_adopt_gl_texture(render_, 0, 0, 0, SDL_PIXELFORMAT_UNKNOWN);
frame_uploaded_ = false;
}
}
void checkRasterizerHealth() {
if (!hw_rt_backend_ || !hw_rt_is_gl_ || !armsx_hw_gl_failed(hw_rt_backend_)) {
return;
}
psx_gpu_t* gpu = psx_ ? psx_get_gpu(psx_) : nullptr;
log_error("GLES rasterizer disabled itself: %s", armsx_hw_gl_status());
releaseAdoptedTexture();
psx_gpu_set_backend(gpu, nullptr);
armsx_hw_gl_destroy(hw_rt_backend_);
hw_rt_backend_ = nullptr;
hw_rt_is_gl_ = false;
if (gpu && armsx_hw_gl_use_cpu_fallback(rasterizer_mode_, internal_scale_)) {
hw_rt_backend_ = armsx_hw_rt_create(gpu, internal_scale_);
if (hw_rt_backend_) {
psx_gpu_set_backend(gpu, hw_rt_backend_);
log_info("Fell back to the CPU internal-resolution rasterizer at %dx",
psx_gpu_resolution_scale(gpu));
return;
}
}
log_info("Fell back to the software rasterizer.");
texture_snapshot_.clear();
frame_uploaded_ = false;
}
#endif
/* ---- [video] deinterlace ---------------------------------------------------------
This core has no field concept at all: in a 480-line mode psx_get_dmode_height()
returns 480 and the frame is read as 480 consecutive VRAM rows, i.e. whatever the
game left there, which is a WEAVE of both fields. That is mode 0 and it is what has
always shipped.
bob present field 0 only, each kept line repeated over the line it replaces.
Halves vertical detail and removes combing outright.
adaptive measure combing on this frame and bob only when it is actually there, so a
static or progressive-in-a-480-mode scene keeps full detail.
Geometry is deliberately PRESERVED (lines are repeated, not dropped): the texture keeps
its width, height and pitch, so the dirty-row scan, the snapshot, the crop rect and the
aspect all stay exactly as they were. The alternative — halving the height — would have
to be understood by four other pieces of code.
Costs nothing at all in weave, and nothing but a sparse read in adaptive-on-a-static
scene. */
static int deinterlaceLuma(uint16_t v) {
return (int)(v & 31u) + (int)((v >> 5) & 31u) + (int)((v >> 10) & 31u);
}
/* Sparse three-line comb detector: a combed pixel sits far from BOTH vertical neighbours
while those neighbours agree with each other, which is what a field mismatch looks like
and ordinary vertical detail does not. Sampled every other native row and every 4th
pixel — this runs per frame and the answer only has to be right in aggregate. */
bool frameIsCombed(const uint8_t* source, uint32_t stride, int native_w, int native_h,
int scale) const {
if (native_h < 4 || native_w < 8) {
return false;
}
const int threshold = 6; /* 5-bit channels summed: ~6/93 of full range */
long sampled = 0;
long combed = 0;
for (int n = 1; n + 1 < native_h; n += 2) {
const auto* above = reinterpret_cast<const uint16_t*>(
source + (size_t)((n - 1) * scale) * stride);
const auto* mid = reinterpret_cast<const uint16_t*>(
source + (size_t)(n * scale) * stride);
const auto* below = reinterpret_cast<const uint16_t*>(
source + (size_t)((n + 1) * scale) * stride);
for (int x = 0; x < native_w; x += 4) {
const int sx = x * scale;
const int a = deinterlaceLuma(above[sx]);
const int b = deinterlaceLuma(mid[sx]);
const int c = deinterlaceLuma(below[sx]);
const int da = a - b;
const int dc = c - b;
sampled++;
if (((da > threshold) && (dc > threshold)) ||
((da < -threshold) && (dc < -threshold))) {
combed++;
}
}
}
/* 2% of sampled pixels. Below that it is film grain or dither, not a field split. */
return (sampled > 0) && ((combed * 50) > sampled);
}
const uint8_t* deinterlaceFrame(const uint8_t* source, uint32_t stride, int mode, int scale) {
if (scale < 1) {
scale = 1;
}
const int native_h = texture_height_ / scale;
const int native_w = texture_width_ / scale;
if ((native_h < 4) || (texture_height_ % scale) != 0) {
return source;
}
if (mode == 2 && !frameIsCombed(source, stride, native_w, native_h, scale)) {
return source; /* adaptive, and this frame does not need it */
}
const size_t need = (size_t)stride * (size_t)texture_height_;
if (deint_buffer_.size() != need) {
deint_buffer_.assign(need, 0);
}
for (int row = 0; row < texture_height_; ++row) {
const int n = row / scale;
const int k = row % scale;
/* Field 0 is the even NATIVE lines; both lines of a pair come from the even one. */
const int src_row = (n & ~1) * scale + k;
std::memcpy(deint_buffer_.data() + (size_t)row * stride,
source + (size_t)src_row * stride,
(size_t)texture_width_ * (size_t)SDL_BYTESPERPIXEL(texture_format_));
}
return deint_buffer_.data();
}
/* Live override wins over settings.toml, matching every other display lever here. */
static int resolveDeinterlace(const FrontendSettings& settings) {
const int host = g_host_deinterlace.load(std::memory_order_acquire);
return host < 0 ? settings.deinterlace : host;
}
static int resolveOverscanCrop(const FrontendSettings& settings) {
const int host = g_host_overscan_crop.load(std::memory_order_acquire);
return host < 0 ? settings.overscan_crop : host;
}
static int resolveDisplayRotation(const FrontendSettings& settings) {
const int host = g_host_display_rotation.load(std::memory_order_acquire);
return (host < 0 ? settings.display_rotation : host) & 3;
}
/* True only for a frame this pass can actually act on: an interlaced (480-line) mode, a
16-bit frame, and not the whole-VRAM debug view. 24bpp playback is packed RGB888 whose
rows are not the console's scanlines in the first place. */
bool deinterlaceApplies(const FrontendSettings& settings) const {
return (resolveDeinterlace(settings) != 0) && !debug_view_ && psx_ && psx_->gpu &&
((psx_->gpu->display_mode & 0x4) != 0) && psx_get_display_format(psx_) == 0;
}
/* Drains a setPs1TextureOptions() request onto the EMULATION thread. psx_texrep_configure
frees every decoded replacement and the rasterizers hold pointers into them, so this
cannot run on the caller's thread. One relaxed atomic load per frame when idle. */
void applyPendingTextureOptions() {
if (!g_host_texture_pending.load(std::memory_order_acquire) || !psx_) {
return;
}
g_host_texture_pending.store(false, std::memory_order_release);
const int dump = g_host_texture_dump.load(std::memory_order_acquire);
const int replace = g_host_texture_replace.load(std::memory_order_acquire);
std::string dir;
{
std::lock_guard<std::mutex> lock(g_host_texture_lock);
dir = g_host_texture_dir;
}
const std::string resolved = ResolveTextureDir(dir);
psx_texrep_configure(psx_get_gpu(psx_), dump > 0 ? 1 : 0, replace > 0 ? 1 : 0,
resolved.c_str());
log_info("Texture options: dump=%s replace=%s dir=%s",
dump > 0 ? "on" : "off", replace > 0 ? "on" : "off", resolved.c_str());
}
void updateTexture(const FrontendSettings& settings) {
if (!psx_ || !render_) {
return;
}
#ifdef USE_HARDWARE
checkRasterizerHealth();
#endif
applyPendingTextureOptions();
int next_width = debug_view_ ? PSX_GPU_FB_WIDTH : static_cast<int>(psx_get_display_width(psx_));
int next_height = debug_view_ ? PSX_GPU_FB_HEIGHT : static_cast<int>(psx_get_display_height(psx_));
// Falls back to the last NATIVE size, not texture_width_/height_, which may already
// carry an internal-resolution multiplier and would get scaled a second time below.
if (next_width <= 0) {
next_width = texture_native_width_ > 0 ? texture_native_width_ : 320;
}
if (next_height <= 0) {
next_height = texture_native_height_ > 0 ? texture_native_height_ : 240;
}
texture_native_width_ = next_width;
texture_native_height_ = next_height;
const Uint32 next_format = debug_view_ || !psx_get_display_format(psx_) ? SDL_PIXELFORMAT_BGR555 : SDL_PIXELFORMAT_RGB24;
const bool use_vram_source = debug_view_ || !psx_ || !psx_->gpu ? false : ((psx_->gpu->disp_y + next_height) > PSX_GPU_FB_HEIGHT);
// Internal-resolution scanout. The whole-VRAM debug view and 24bpp playback both
// have to stay native: 24bpp reinterprets VRAM bytes as packed RGB888, which the
// upscaled target does not contain (the backend). Everything else
// reads the backend's render target directly, so the upscaled pixels reach the
// present layer without a downsample.
int display_scale = 1;
Uint32 display_stride = PSX_GPU_FB_STRIDE;
const void* scaled_surface = nullptr;
#ifdef USE_HARDWARE
const bool want_native_scanout = use_vram_source || (next_format != SDL_PIXELFORMAT_BGR555);
// The brokered seam (the backend). When the GLES rasterizer
// and the GL present backend share a context, the frame the rasterizer just produced
// is already a texture in the presenter's namespace: hand it over instead of reading
// it back and uploading it again. Both of those are S^2 in the internal scale, and on
// a tiler the readback is also a full pipeline sync — together they are the largest
// remaining cost in the upscaled path.
//
// Every condition here is a fallback to the readback path, not an error. The debug
// VRAM view and 24bpp playback need native pixels (), and armsx_hw_gl_present_texture()
// refuses on its own side whenever the texture would be meaningless to the presenter.
//
// Deinterlacing also declines the seam: it is a CPU pass over the finished frame, so
// the pixels have to come back to the host. It is opt-in and only fires in 480-line
// modes, so the fast path is untouched in every other case.
const bool adopted_gl_texture =
!deinterlaceApplies(settings) &&
!want_native_scanout && !debug_view_ && psx_ && psx_->gpu && hw_rt_backend_ &&
hw_rt_is_gl_ && (armsx_renderer_backend(render_) == ARMSX_RENDER_BACKEND_OPENGL) &&
(armsx_hw_gl_present_texture(hw_rt_backend_, render_) != 0);
if (adopted_gl_texture) {
// The adopted texture is the render target at internal resolution, so the
// presentation size is the same one the upload path would have produced — and
// armsx_render_compute_dst() therefore letterboxes it identically.
display_scale = psx_gpu_resolution_scale(psx_->gpu);
next_width *= display_scale;
next_height *= display_scale;
} else if (!want_native_scanout && psx_ && psx_->gpu) {
uint32_t backend_stride = PSX_GPU_FB_STRIDE;
const bool backend_scanout = psx_gpu_backend_owns_display(psx_->gpu) != 0;
const void* surface =
psx_gpu_get_display_surface(psx_->gpu, 0, &display_scale, &backend_stride);
// Note the "|| backend_scanout": a GPU backend at 1x must still be the thing on
// screen. Gating on display_scale > 1 alone would leave the software shadow's
// (correct) image visible at 1x and hide a completely broken GPU path, which is
// exactly the scale the parity gate lives at.
if (surface && backend_stride && ((display_scale > 1) || backend_scanout)) {
scaled_surface = surface;
display_stride = static_cast<Uint32>(backend_stride);
next_width *= display_scale;
next_height *= display_scale;
} else {
// No backend, or it handed back the native surface (display disabled).
display_scale = 1;
}
}
#endif
#if defined(USE_HARDWARE) && defined(HW_DEBUG)
if (hardware_backend_active_) {
int output_width = 0;
int output_height = 0;
armsx_renderer_output_size(render_, &output_width, &output_height);
psxe_diag_logf(
"hw",
"texture-select frame=%llu display_mode=0x%08x gpustat=0x%08x display_enable=%d dmode=%ux%u display=%ux%u aspect=%.3f disp_window=(%u,%u)-(%u,%u) disp_y=%u offset=(%d,%d) output=%dx%d next=%dx%d format=%s source=%s stretch=%s debug=%s timing=%s target_fps=%.3f",
static_cast<unsigned long long>(vblank_counter_),
psx_ && psx_->gpu ? psx_->gpu->display_mode : 0u,
psx_ && psx_->gpu ? psx_->gpu->gpustat : 0u,
psx_ && psx_->gpu ? psx_->gpu->display_enable : 0,
psx_ ? psx_get_dmode_width(psx_) : 0u,
psx_ ? psx_get_dmode_height(psx_) : 0u,
psx_ ? psx_get_display_width(psx_) : 0u,
psx_ ? psx_get_display_height(psx_) : 0u,
psx_ ? psx_get_display_aspect(psx_) : 0.0,
psx_ && psx_->gpu ? psx_->gpu->disp_x1 : 0u,
psx_ && psx_->gpu ? psx_->gpu->disp_y1 : 0u,
psx_ && psx_->gpu ? psx_->gpu->disp_x2 : 0u,
psx_ && psx_->gpu ? psx_->gpu->disp_y2 : 0u,
psx_ && psx_->gpu ? psx_->gpu->disp_y : 0u,
psx_ && psx_->gpu ? psx_->gpu->off_x : 0,
psx_ && psx_->gpu ? psx_->gpu->off_y : 0,
output_width,
output_height,
next_width,
next_height,
SDL_GetPixelFormatName(next_format),
use_vram_source ? "vram" : "display",
settings.stretch_mode ? "true" : "false",
debug_view_ ? "true" : "false",
timingModeTitle(),
targetFrameRate()
);
}
#endif
if ((next_width != texture_width_) || (next_height != texture_height_) || (next_format != texture_format_)) {
texture_width_ = next_width;
texture_height_ = next_height;
texture_format_ = next_format;
texture_snapshot_.clear();
frame_uploaded_ = false;
#ifdef USE_HARDWARE
if (hw_renderer_) {
armsx_hw_renderer_set_output_size(hw_renderer_, texture_width_, texture_height_);
}
#endif
}
#ifdef USE_HARDWARE
if (adopted_gl_texture) {
// There is no CPU frame this frame, so the snapshot the dirty-row scan compares
// against describes a frame the present layer is no longer showing. Dropping it
// costs nothing (the vector keeps its capacity) and makes whichever frame falls
// back to upload_frame() do a full upload, which is the only safe answer.
texture_snapshot_.clear();
frame_uploaded_ = true;
// Say so rather than going quiet: on the brokered seam the frame never becomes CPU
// pixels, so the probe below cannot look at it, and an armed run that logged nothing
// would read as "the probe is broken".
if (g_resume_probe_frames > 0) {
--g_resume_probe_frames;
psxe_diag_logf("hw",
"resume-probe left=%d source=adopted-gl-texture %dx%d (no CPU frame "
"to sample; the GL render target IS the presented image)",
g_resume_probe_frames, texture_width_, texture_height_);
}
return;
}
#endif
const void* display_buffer = scaled_surface
? scaled_surface
: (use_vram_source ? psx_get_vram(psx_) : psx_get_display_buffer(psx_));
if (!display_buffer) {
return;
}
const int bytes_per_pixel = SDL_BYTESPERPIXEL(texture_format_);
const size_t row_bytes = static_cast<size_t>(texture_width_) * static_cast<size_t>(bytes_per_pixel);
const size_t snapshot_size = static_cast<size_t>(display_stride) * static_cast<size_t>(texture_height_);
const auto* source = static_cast<const uint8_t*>(display_buffer);
// [video] deinterlace. Substitutes an equally-shaped buffer, so everything below —
// the dirty scan, the snapshot and the upload — is unchanged and unaware.
if (deinterlaceApplies(settings)) {
source = deinterlaceFrame(source, display_stride, resolveDeinterlace(settings),
display_scale);
}
// Dirty-row scan: only the changed span of the framebuffer is handed to the backend.
// Unchanged from the pre-abstraction path; every backend gets the same row range.
int first_dirty_row = 0;
int last_dirty_row = texture_height_ - 1;
if (texture_snapshot_.size() == snapshot_size) {
first_dirty_row = texture_height_;
last_dirty_row = -1;
for (int row = 0; row < texture_height_; ++row) {
const size_t offset = static_cast<size_t>(row) * static_cast<size_t>(display_stride);
if (std::memcmp(source + offset, texture_snapshot_.data() + offset, row_bytes) != 0) {
first_dirty_row = std::min(first_dirty_row, row);
last_dirty_row = row;
}
}
} else {
texture_snapshot_.assign(snapshot_size, 0);
}
// `resume_probe`: is the picture we are about to hand the present layer actually there?
// See resumeProbeEnabled() for how to read the answer. Disarmed this is one compare.
if (g_resume_probe_frames > 0) {
--g_resume_probe_frames;
size_t nonzero = 0;
int first_nonzero_row = -1;
for (int row = 0; row < texture_height_; ++row) {
const uint8_t* line = source + (static_cast<size_t>(row) * static_cast<size_t>(display_stride));
for (size_t byte = 0; byte < row_bytes; ++byte) {
if (line[byte]) {
++nonzero;
}
}
if (nonzero && first_nonzero_row < 0) {
first_nonzero_row = row;
}
}
psxe_diag_logf("hw",
"resume-probe left=%d source=%s %dx%d stride=%u format=%s nonzero=%zu "
"first_row=%d dirty=[%d..%d] scale=%d",
g_resume_probe_frames,
scaled_surface ? "backend-readback"
: (use_vram_source ? "vram-shadow" : "software-display"),
texture_width_, texture_height_, (unsigned)display_stride,
SDL_GetPixelFormatName(texture_format_), nonzero, first_nonzero_row,
first_dirty_row, last_dirty_row, display_scale);
}
if (!armsx_renderer_upload_frame(render_,
source,
texture_width_,
texture_height_,
static_cast<int>(display_stride),
texture_format_,
first_dirty_row,
last_dirty_row)) {
return;
}
frame_uploaded_ = true;
for (int row = first_dirty_row; row <= last_dirty_row; ++row) {
const size_t offset = static_cast<size_t>(row) * static_cast<size_t>(display_stride);
std::memcpy(texture_snapshot_.data() + offset, source + offset, row_bytes);
}
}
// Present the emulated framebuffer through the active presentation backend
// (frontend/render.h). The aspect decision stays here because it is a *settings* concern;
// the letterbox math itself is shared by every backend (armsx_render_compute_dst).
void draw(const FrontendSettings& settings) {
if (!frame_uploaded_ || !render_) {
return;
}
// Live overrides from the host UI (JNI). Aspect and stretch are pure presentation
// properties — nothing in the VM depends on them — so changing either from the
// in-game menu takes effect on the very next frame rather than at the next launch.
int display_aspect = settings.display_aspect;
bool stretch = settings.stretch_mode;
if (const int override_aspect = g_host_display_aspect.load(std::memory_order_acquire);
override_aspect >= 0) {
display_aspect = override_aspect;
}
if (const int override_stretch = g_host_stretch_mode.load(std::memory_order_acquire);
override_stretch >= 0) {
stretch = override_stretch != 0;
}
float aspect = 4.0f / 3.0f;
if (debug_view_) {
aspect = static_cast<float>(texture_width_) / static_cast<float>(std::max(texture_height_, 1));
} else if (display_aspect == 3) {
/* Custom. The live override wins over the file so an in-game slider is immediate;
both are sanity-clamped because a zero or negative ratio would make
compute_dst produce a degenerate rect and the screen go black. */
const float live = g_host_display_aspect_custom.load(std::memory_order_acquire);
const float configured = settings.display_aspect_custom;
const float chosen = live > 0.0f ? live : configured;
aspect = (chosen >= kMinCustomAspect && chosen <= kMaxCustomAspect) ? chosen
: (4.0f / 3.0f);
} else if (display_aspect == 2) {
aspect = 16.0f / 9.0f;
} else if (display_aspect == 1) {
aspect = 1.0f;
} else {
// Classic is a HARD 4:3 — the display aspect of a PlayStation on a CRT, which is
// what every mode from 256x240 to 640x480 was authored for. It is deliberately NOT
// psx_get_display_aspect(): that returns the framebuffer's own pixel ratio capped
// at 4:3, so a 256-wide game came out at 1.067 — visually identical to Square, and
// the reason "Classic" and "Square" looked like the same dead setting.
aspect = 4.0f / 3.0f;
}
armsx_render_frame_params_t params{};
params.stretch = stretch;
params.linear_filter = settings.texture_scale_mode;
params.portrait_top = g_host_portrait_top.load(std::memory_order_acquire) != 0;
params.portrait_top_inset = g_host_portrait_top_inset.load(std::memory_order_acquire);
params.integer_scaling = g_host_integer_scaling.load(std::memory_order_acquire) < 0
? settings.integer_scaling
: g_host_integer_scaling.load(std::memory_order_acquire) != 0;
params.aspect = aspect;
/* ---- [video] overscan_crop -----------------------------------------------------
Trims the blanking border games leave around the picture and lets the kept region
fill the same destination rect — i.e. it zooms, which is what makes it worth
having. Expressed as a fraction of each edge rather than fixed pixel counts
because the horizontal resolution varies per game (256/320/368/512/640) and a
fixed 8px means something different in each.
small 1/32 of each edge (~3%) — the usual garbage column/row
full 1/16 of each edge (~6%) — the "all borders" setting
Skipped for the whole-VRAM debug view, where the border is the point. */
const int crop_mode = debug_view_ ? 0 : resolveOverscanCrop(settings);
if (crop_mode > 0 && texture_width_ > 16 && texture_height_ > 16) {
const int div = (crop_mode == 2) ? 16 : 32;
const int inset_x = texture_width_ / div;
const int inset_y = texture_height_ / div;
params.crop_x = inset_x;
params.crop_y = inset_y;
params.crop_w = texture_width_ - inset_x * 2;
params.crop_h = texture_height_ - inset_y * 2;
}
/* [video] display_rotation. Pure presentation: compute_dst transposes the rect and
each backend spins its own draw. The debug view rotates too — there is no reason
for it not to. */
params.rotation = resolveDisplayRotation(settings);
if (aspect_traces_ < 3 || display_aspect != last_logged_aspect_mode_ ||
stretch != last_logged_stretch_) {
aspect_traces_++;
last_logged_aspect_mode_ = display_aspect;
last_logged_stretch_ = stretch;
psxe_diag_logf("renderer",
"present mode=%s (%d) stretch=%s aspect=%.4f source=%dx%d",
display_aspect == 3 ? "custom"
: (display_aspect == 2 ? "wide16x9"
: (display_aspect == 1 ? "square1x1"
: "classic4x3")),
display_aspect, stretch ? "true" : "false", aspect, texture_width_,
texture_height_);
}
armsx_renderer_present(render_, &params);
// The old SDL path also composited armsx_hw_renderer_overlay_texture() here. That
// accessor is a compatibility stub that unconditionally returns NULL
// (frontend/gpu_hw.c), so the blit was dead code and is not reproduced.
}
void finishHardwareFrame() {
#ifdef USE_HARDWARE
if (hardware_backend_active_ && hw_renderer_) {
armsx_hw_renderer_end_frame(hw_renderer_);
}
#endif
}
#if defined(HW_DEBUG)
void traceHardwareFrameState(const char* stage, std::uint32_t steps) const {
#ifdef USE_HARDWARE
if (!hardware_backend_active_ || !psx_ || !psx_->gpu) {
return;
}
const psx_gpu_t* gpu = psx_->gpu;
psxe_diag_logf(
"hw",
"%s frame=%llu steps=%u timing=%s target_fps=%.3f display_mode=0x%08x gpustat=0x%08x display_enable=%d dmode=%ux%u display=%ux%u draw=(%u,%u)-(%u,%u) disp=(%u,%u)-(%u,%u) offset=(%d,%d) texture=%dx%d renderer=%p",
stage ? stage : "frame-state",
static_cast<unsigned long long>(vblank_counter_),
steps,
timingModeTitle(),
targetFrameRate(),
gpu->display_mode,
gpu->gpustat,
(gpu->gpustat & 0x00800000) != 0,
psx_get_dmode_width(psx_),
psx_get_dmode_height(psx_),
psx_get_display_width(psx_),
psx_get_display_height(psx_),
gpu->draw_x1,
gpu->draw_y1,
gpu->draw_x2,
gpu->draw_y2,
gpu->disp_x1,
gpu->disp_y1,
gpu->disp_x2,
gpu->disp_y2,
gpu->off_x,
gpu->off_y,
texture_width_,
texture_height_,
(void*)render_
);
#else
(void)stage;
(void)steps;
#endif
}
#endif
bool saveScreenshot(const std::filesystem::path& path) {
if (!psx_) {
return false;
}
const int width = texture_width_;
const int height = texture_height_;
const Uint32 format = texture_format_;
// texture_width_/height_ already carry the internal-resolution multiplier, so the
// source and its stride have to come from the same surface uploadFrame() used —
// reading the native buffer at a scaled height would run off the end of VRAM.
int shot_scale = 1;
Uint32 shot_stride = PSX_GPU_FB_STRIDE;
const void* source = debug_view_ ? psx_get_vram(psx_) : psx_get_display_buffer(psx_);
#ifdef USE_HARDWARE
if (!debug_view_ && (format == SDL_PIXELFORMAT_BGR555) && psx_->gpu) {
uint32_t backend_stride = PSX_GPU_FB_STRIDE;
const void* surface =
psx_gpu_get_display_surface(psx_->gpu, 0, &shot_scale, &backend_stride);
if (surface && (shot_scale > 1)) {
source = surface;
shot_stride = static_cast<Uint32>(backend_stride);
} else {
shot_scale = 1;
}
}
#endif
if (!debug_view_ && (shot_scale == 1) &&
((psx_->gpu->disp_y + texture_height_) > PSX_GPU_FB_HEIGHT)) {
source = psx_get_vram(psx_);
}
SDL_Surface* surface = SDL_CreateRGBSurfaceWithFormat(0, width, height, SDL_BITSPERPIXEL(format), format);
if (!surface) {
return false;
}
const size_t row_size = static_cast<size_t>(surface->pitch);
const uint8_t* src = static_cast<const uint8_t*>(source);
uint8_t* dst = static_cast<uint8_t*>(surface->pixels);
for (int row = 0; row < height; row++) {
std::memcpy(dst + (static_cast<size_t>(row) * static_cast<size_t>(surface->pitch)), src + (static_cast<size_t>(row) * static_cast<size_t>(shot_stride)), row_size);
}
const int result = SDL_SaveBMP(surface, path.string().c_str());
SDL_FreeSurface(surface);
return result == 0;
}
double frameRate() const {
if (!psx_ || !psx_->gpu) {
return 59.29;
}
return static_cast<double>(psx_gpu_frame_rate(psx_->gpu));
}
// The rate the frame pacer aims for. "Uncapped" returns a deliberately unreachable rate
// rather than 0: waitForFrameDeadline() then always finds the deadline already passed and
// never sleeps, which is exactly "run as fast as the device manages" — no special case in
// the pacer, and no divide-by-zero in updateFramePeriod().
double targetFrameRate() const {
if (fast_forward_enabled_) {
return (fast_forward_speed_ <= 0.0) ? kUncappedFrameRate : frameRate() * fast_forward_speed_;
}
if (!frame_limit_) {
return kUncappedFrameRate;
}
double target = frameRate() * (static_cast<double>(speed_percent_) / 100.0);
// The absolute cap only ever lowers the target — a 120 fps "cap" on a 59.94 Hz game
// must not make it run at double speed.
if (fps_limit_ > 0) {
target = std::min(target, static_cast<double>(fps_limit_));
}
return std::max(target, 1.0);
}
// Whether the emulated stream needs rate-converting to reach the device.
//
// This answers only whether the user DELIBERATELY requested a non-realtime rate:
// fast-forward, limiter off, speed percentage, or an fps cap. Plain 100% sessions use the
// separate wall-clock elasticity controller in queueAudioForFrame(); it stays at ratio 1.0
// while the core meets its deadline and stretches host output only after measured lateness.
bool audioRateConversionActive() const {
if (fast_forward_enabled_ || !frame_limit_) {
return true;
}
if (speed_percent_ != 100) {
return true;
}
return (fps_limit_ > 0) && (static_cast<double>(fps_limit_) < frameRate());
}
// What the converter should expect the emulation to run at, used only to seed its rate
// estimate. The closed loop finds the truth from there; this just avoids an audible
// sweep on the first tenth of a second.
double expectedSpeedMultiplier() const {
if (fast_forward_enabled_) {
return (fast_forward_speed_ > 0.0) ? fast_forward_speed_ : 2.0;
}
if (!frame_limit_) {
return 2.0;
}
const double nominal = frameRate();
return (nominal > 0.0) ? std::max(targetFrameRate() / nominal, 0.05) : 1.0;
}
std::uint64_t vblankCounter() const {
return vblank_counter_;
}
#ifdef USE_HARDWARE
bool hardwareBackendActive() const {
return hardware_backend_active_;
}
int textureWidth() const {
return texture_width_;
}
int textureHeight() const {
return texture_height_;
}
Uint32 textureFormat() const {
return texture_format_;
}
#endif
const char* timingModeTitle() const {
if (!psx_ || !psx_->gpu) {
return "NTSC-like";
}
return psx_gpu_is_pal_mode(psx_->gpu) ? "PAL-like" : "NTSC-like";
}
private:
static void SessionVblankEvent(psx_gpu_t* gpu) {
if (gpu) {
if (auto* session = static_cast<ArmsxSession*>(gpu->udata[0])) {
session->vblank_counter_++;
}
psxe_gpu_vblank_timer_event_cb(gpu);
}
}
static void AudioUpdate(void* userdata, uint8_t* buffer, int size) {
if (!buffer || size <= 0) {
return;
}
std::memset(buffer, 0, static_cast<size_t>(size));
auto* session = static_cast<ArmsxSession*>(userdata);
if (!session) {
return;
}
session->consumeQueuedAudio(buffer, static_cast<size_t>(size));
}
void updateAudioPlaybackState() {
if (!audio_dev_) {
return;
}
// Fast-forward no longer parks the device. The emulated stream is resampled down to
// the device rate in queueAudioForFrame() instead, so there is a continuous stream to
// play; only a paused VM stops the device, and a paused VM really has nothing to feed
// it. "Mute during fast-forward" is a gain of zero, not a stopped device — stopping
// and restarting an OpenSL ES stream on every fast-forward edge pops.
//
// audio_suspended_ is belt-and-braces: backgrounding closes the device outright, so this
// normally has no device to act on. It matters only if a future path leaves one open
// while the app is off-screen — a setPaused(false) landing then must not hand the
// callback back its real samples.
SDL_PauseAudioDevice(audio_dev_, (paused_ || audio_suspended_ || !audio_playback_started_) ? 1 : 0);
}
/*
`audio_diag` — ONE bounded audio capture, armed by a marker file.
Four things sound identical to a player and need opposite fixes: the emulation not
keeping up (host underruns), the XA decoder producing the wrong stream, the mix sitting
at the wrong level, and the SPU reverb feedback loop railing. Reading the code cannot
separate them, and two plausible fixes reasoned from a symptom description have already
been landed on this project and failed. So: measure first.
`touch files/logs/audio_diag` arms it. The marker is deleted the instant a capture
starts, so one touch buys exactly one window; ~10 s of it, sampled five times a second,
written to `audio_diag.txt` beside the marker. Four windows per process, then the probe
stops for good.
Cost when idle: one fopen() every kAudioDiagPollFrames frames while budget remains, and
one predicted branch per generated sample everywhere psx/perf.h's counters are written.
Nothing here runs per CPU instruction or per pixel.
Written through its own FILE* rather than psxe_diag_logf() for the reasons gpu.c gives:
it has to work with diagnostics logging off, it would otherwise drown armsx.log, and a
player can send one small file instead of the whole log.
*/
bool audioDiagPath(std::string& out, const char* name) const {
const char* log_path = psxe_diag_log_path();
if (!log_path || !log_path[0]) {
return false;
}
const char* slash = std::strrchr(log_path, '/');
if (!slash) {
return false;
}
out.assign(log_path, static_cast<size_t>(slash - log_path) + 1u);
out += name;
return true;
}
void audioDiagLine(const char* fmt, ...) {
if (!audio_diag_file_) {
return;
}
char line[1024];
va_list ap;
va_start(ap, fmt);
std::vsnprintf(line, sizeof(line), fmt, ap);
va_end(ap);
std::fputs(line, audio_diag_file_);
std::fputs("\n", audio_diag_file_);
}
// Everything the SPU, the CD-ROM and the host queue looked like over the last snapshot
// period, then the counters are zeroed so the next line describes the next period only.
void audioDiagSnapshot() {
psx_spu_t* spu = psx_->spu;
psx_cdrom_t* cdrom = psx_->cdrom;
psx_audio_diag_t& d = g_psx_audio_diag;
size_t queued = 0;
uint32_t underruns = 0;
uint64_t underrun_bytes = 0;
uint32_t rebuffers = 0;
uint32_t overflows = 0;
SDL_LockAudioDevice(audio_dev_);
queued = audio_queue_.size() - audio_queue_read_offset_;
underruns = audio_underruns_;
underrun_bytes = audio_underrun_bytes_;
rebuffers = audio_rebuffer_events_;
overflows = audio_overflow_resets_;
audio_underruns_ = 0;
audio_underrun_bytes_ = 0;
audio_rebuffer_events_ = 0;
audio_overflow_resets_ = 0;
SDL_UnlockAudioDevice(audio_dev_);
audio_diag_snapshots_++;
audioDiagLine("");
audioDiagLine("[t=%.1fs] host: fps_target=%.2f queued=%zu bytes (%.1f ms) "
"underruns=%u short=%llu bytes rebuffer=%u overflow_resets=%u rate_ratio=%.4f "
"ff=%d paused=%d",
static_cast<double>(audio_diag_snapshots_) *
(static_cast<double>(kAudioDiagSnapshotFrames) / frameRate()),
frameRate(),
queued,
(static_cast<double>(queued) / static_cast<double>(kAudioBytesPerSample)) *
1000.0 / static_cast<double>(kAudioMixRate),
underruns,
static_cast<unsigned long long>(underrun_bytes),
rebuffers,
overflows,
audio_rate_ratio_,
fast_forward_enabled_ ? 1 : 0,
paused_ ? 1 : 0);
// SPUCNT decoded in full: bit15 enable, bit7 reverb master, bit2 CD-audio reverb,
// bit0 CD-audio enable. Bits 0 and 2 are the ones nothing in this core consumes.
audioDiagLine(" spu: spucnt=%04x [enable=%d unmute=%d reverb=%d cd_reverb=%d cd_enable=%d] "
"host_skip_reverb=%d mainvol=%04x/%04x(min %04x/%04x) vlout=%04x vrout=%04x "
"cdaivol=%08x extivol=%08x eon=%06x mbase=%04x revbaddr=%05x",
spu->spucnt,
(spu->spucnt & 0x8000) ? 1 : 0,
(spu->spucnt & 0x4000) ? 1 : 0,
(spu->spucnt & 0x0080) ? 1 : 0,
(spu->spucnt & 0x0004) ? 1 : 0,
(spu->spucnt & 0x0001) ? 1 : 0,
spu->reverb_disabled,
spu->mainlvol, spu->mainrvol,
d.mainvol_min_l & 0xffffu, d.mainvol_min_r & 0xffffu,
spu->vlout, spu->vrout,
spu->cdaivol, spu->extivol,
spu->eon & 0xffffffu, spu->mbase, spu->revbaddr);
audioDiagLine(" spu levels: samples=%u silent=%u voices avg=%.2f peak=%u | "
"dry_peak=%d/%d dry_clip=%u | revin_peak=%d/%d revin_clip=%u | "
"revsum_clip=%u",
d.spu_samples, d.spu_silent,
d.spu_samples ? (static_cast<double>(d.spu_voices_sum) /
static_cast<double>(d.spu_samples)) : 0.0,
d.spu_voices_peak,
d.dry_peak_l, d.dry_peak_r, d.dry_clip,
d.revin_peak_l, d.revin_peak_r, d.revin_clip,
d.revsum_clip);
// rev_calls == 0 with reverb=1 above means the network never ran. revfb_railed
// approaching rev_calls means the feedback line is pinned: a self-sustaining loop.
audioDiagLine(" reverb: calls=%u fb_railed=%u (%.1f%%) out_railed=%u out_peak=%d/%d",
d.rev_calls, d.revfb_railed,
d.rev_calls ? (100.0 * static_cast<double>(d.revfb_railed) /
static_cast<double>(d.rev_calls)) : 0.0,
d.revout_railed, d.revout_peak_l, d.revout_peak_r);
audioDiagLine(" keyon: voices=%u negative_volume=%u sweep_mode=%u | "
"spu_ram: writes=%u fifo_drop=%u range=%05x-%05x taddr=%05x xfer_mode=%d "
"| irq9=%05x en=%d raised=%u (%.1f/s) | spu_gen: ticked=%u inline=%u",
d.kon_voices, d.kon_vol_negative, d.kon_vol_sweep,
d.spu_ram_writes, d.spu_fifo_drop,
(d.spu_ram_lo == 0xffffffffu) ? 0u : d.spu_ram_lo, d.spu_ram_hi,
spu->taddr, (spu->spucnt >> 4) & 3,
(unsigned)(spu->irq9addr << 3), (spu->spucnt & 0x40) ? 1 : 0, d.spu_irq_raised,
// Per second, because the number this has to be compared against is a rate: the
// metronome voice's own crossing rate. Landing at the frame rate is the tell that
// interrupts are being quantised to the frame instead of delivered on time.
static_cast<double>(d.spu_irq_raised) /
(static_cast<double>(kAudioDiagSnapshotFrames) / frameRate()),
d.spu_gen_ticked, d.spu_gen_inline);
// Live state of every sounding voice, sampled at snapshot time (no hot-path cost).
// phase 0..4 = attack/decay/sustain/release/end. A voice parked in SUSTAIN (2) at a
// high env with the level never falling is the signature of a note that was never
// keyed off — which is what "a sound that plays forever" looks like from in here.
{
char voices[4096];
size_t used = 0;
int listed = 0;
voices[0] = '\0';
for (int v = 0; v < 24 && used + 160u < sizeof(voices); v++) {
if (!spu->data[v].playing) {
continue;
}
// Is the sample data this voice is reading actually there? 16 bytes at the
// current decode address; all-zero means the voice is looping over blank SPU
// RAM, which is what "keyed on, full envelope, no sound" looks like when the
// transfer never delivered. blank=1 with spu_ram_writes=0 is the whole story.
const uint32_t addr = spu->data[v].current_addr & (SPU_RAM_SIZE - 1u);
int blank = 1;
for (uint32_t b = 0; b < 16u; b++) {
if (spu->ram[(addr + b) & (SPU_RAM_SIZE - 1u)] != 0) {
blank = 0;
break;
}
}
// pitch: 1000h = 44100 Hz. Printed rather than inferred from how far ca moves,
// because ring position aliases: a rate of d and a rate of d+ring look the same.
const int written = std::snprintf(voices + used, sizeof(voices) - used,
"%s%d:vol=%04x/%04x env=%04x ph=%d sa=%05x ra=%05x ca=%05x blank=%d "
"pitch=%04x out=%d/%d flags=%02x le=%u st=%u ign=%d",
listed ? " | " : "",
v, spu->voice[v].volumel, spu->voice[v].volumer,
spu->voice[v].envcvol, spu->data[v].adsr_phase,
(unsigned)(spu->voice[v].adsaddr << 3),
(unsigned)(spu->voice[v].adraddr << 3), addr, blank,
spu->voice[v].adsampr,
d.voice_peak_l[v], d.voice_peak_r[v],
d.voice_flags[v], d.voice_loopend[v], d.voice_stop[v],
spu->data[v].ignore_loop_addr);
if (written <= 0) {
break;
}
used += static_cast<size_t>(written);
listed++;
}
audioDiagLine(" voices(%d): %s", listed, listed ? voices : "(none sounding)");
}
audioDiagLine(" cd: mode=%02x [xa_adpcm=%d xa_filter=%d report=%d autopause=%d cdda=%d] "
"xa_playing=%d xa_mute=%d mute=%d filter=file %02x/chan %02x "
"lba=%u xa_lba=%u atv=%02x/%02x/%02x/%02x",
cdrom->mode,
(cdrom->mode & 0x40) ? 1 : 0,
(cdrom->mode & 0x08) ? 1 : 0,
(cdrom->mode & 0x04) ? 1 : 0,
(cdrom->mode & 0x02) ? 1 : 0,
(cdrom->mode & 0x01) ? 1 : 0,
cdrom->xa_playing, cdrom->xa_mute, cdrom->mute,
cdrom->xa_file, cdrom->xa_channel,
cdrom->lba, cdrom->xa_lba,
cdrom->vol[0], cdrom->vol[1], cdrom->vol[2], cdrom->vol[3]);
// Every CD-ROM command issued in the period, oldest first. This is what separates
// "the game never asked for XA" from "it asked and we dropped it": look for
// CdlSetmode and read bit6 (0x40) of its parameter against mode_before. It also
// exposes the cdrom_cmd_setmode() -> cdrom_pause() interaction directly, because a
// Setmode arriving with read=1 or xa=1 is one that stopped a drive that was running.
const uint32_t cmds = std::min<uint32_t>(d.cd_cmd_seen, PSX_AUDIO_DIAG_CD_CMDS);
if (cmds > 0) {
audioDiagLine(" cd_cmds: %u this period", d.cd_cmd_seen);
}
for (uint32_t i = 0; i < cmds; i++) {
const uint32_t slot =
(d.cd_cmd_head + PSX_AUDIO_DIAG_CD_CMDS - cmds + i) % PSX_AUDIO_DIAG_CD_CMDS;
char params[32];
size_t used = 0;
params[0] = '\0';
for (uint8_t p = 0; p < d.cd_cmd[slot].nparams && used + 4u < sizeof(params); p++) {
const int written = std::snprintf(params + used, sizeof(params) - used,
"%02x ", d.cd_cmd[slot].param[p]);
if (written <= 0) {
break;
}
used += static_cast<size_t>(written);
}
audioDiagLine(" cmd %02x %-14s params=[%s] mode_before=%02x xa=%d read=%d state=%d",
d.cd_cmd[slot].cmd,
psx_cdrom_command_name(d.cd_cmd[slot].cmd),
used ? params : "-",
d.cd_cmd[slot].mode_before,
d.cd_cmd[slot].xa_playing,
d.cd_cmd[slot].read_ongoing,
d.cd_cmd[slot].state);
}
audioDiagLine(" xa: accepted=%u filter_rejects=%u nonaudio_skips=%u stop_eor=%u "
"stop_far=%u starved=%u walk_peak=%u peak=%d/%d cdda_sectors=%u | "
"mix: wrap=%u sat=%u peak=%d/%d",
d.xa_sectors, d.xa_filter_rejects, d.xa_skip_nonaudio, d.xa_stop_eor,
d.xa_stop_far, d.xa_starved, d.xa_walk_peak,
d.xa_peak_l, d.xa_peak_r, d.cdda_sectors,
d.mix_wrap, d.mix_sat, d.mix_peak_l, d.mix_peak_r);
// Every sector decision, oldest first, each with the filter that was in force when the
// fetcher decided. This is what settles "music plays, dialogue does not":
// REJECT_FILTER entries whose file/chan is the dialogue stream -> the filter is wrong
// no entry for the dialogue channel at all -> it never arrived
// ACCEPT entries for it but silence -> the fault is downstream
// STOP_EOR reached while walking (chan != filter_chan) -> the walk was aborted
static const char* const kVerdict[PSX_XA_VERDICT_KINDS] = {
"ACCEPT(nofilter)", "ACCEPT(match)", "REJECT(filter)",
"SKIP(nonaudio)", "STOP(eor)"
};
const uint32_t ring = std::min<uint32_t>(d.xa_hdr_seen, PSX_AUDIO_DIAG_XA_HDRS);
for (uint32_t i = 0; i < ring; i++) {
const uint32_t slot =
(d.xa_hdr_head + PSX_AUDIO_DIAG_XA_HDRS - ring + i) % PSX_AUDIO_DIAG_XA_HDRS;
const uint8_t sm = d.xa_hdr[slot].submode;
const uint8_t ci = d.xa_hdr[slot].coding;
const uint8_t vd = d.xa_hdr[slot].verdict;
audioDiagLine(" xa lba=%u %-16s file=%02x chan=%02x vs filter=%02x/%02x(on=%d) "
"submode=%02x[eor=%d video=%d audio=%d data=%d trig=%d form2=%d "
"rt=%d eof=%d] coding=%02x[%s %s %s emphasis=%d]",
d.xa_hdr[slot].lba,
(vd < PSX_XA_VERDICT_KINDS) ? kVerdict[vd] : "?",
d.xa_hdr[slot].file, d.xa_hdr[slot].chan,
d.xa_hdr[slot].filter_file, d.xa_hdr[slot].filter_chan,
d.xa_hdr[slot].filter_on,
sm,
(sm & 0x01) ? 1 : 0, (sm & 0x02) ? 1 : 0, (sm & 0x04) ? 1 : 0,
(sm & 0x08) ? 1 : 0, (sm & 0x10) ? 1 : 0, (sm & 0x20) ? 1 : 0,
(sm & 0x40) ? 1 : 0, (sm & 0x80) ? 1 : 0,
ci,
(ci & 0x01) ? "stereo" : "mono",
(ci & 0x04) ? "18.9kHz" : "37.8kHz",
(ci & 0x10) ? "8bit" : "4bit",
(ci & 0x40) ? 1 : 0);
}
// Period counters only; the window totals come from the arithmetic on these lines.
// The minima have to be re-seeded past the memset or the next period reports 0000.
std::memset(&g_psx_audio_diag, 0, sizeof(g_psx_audio_diag));
g_psx_audio_diag.mainvol_min_l = 0xffffffffu;
g_psx_audio_diag.mainvol_min_r = 0xffffffffu;
g_psx_audio_diag.spu_ram_lo = 0xffffffffu;
}
void audioDiagBegin() {
std::string path;
if (!audioDiagPath(path, "audio_diag.txt")) {
return;
}
audio_diag_file_ = std::fopen(path.c_str(), "a");
if (!audio_diag_file_) {
return;
}
audio_diag_seq_++;
audio_diag_frames_left_ = kAudioDiagFrames;
audio_diag_snapshots_ = 0;
audioDiagLine("=== audio_diag capture #%d (%d frames, ~%.0f s) ===",
audio_diag_seq_, kAudioDiagFrames,
static_cast<double>(kAudioDiagFrames) / frameRate());
// Also breadcrumbed so "did it fire?" is answerable from armsx.log alone.
psxe_diag_logf("audio", "audio_diag: capture #%d armed, %d left this process",
audio_diag_seq_, audio_diag_budget_);
audioDiagLine("title=%s disc=%s", title_.c_str(),
disc_path_.empty() ? "(none)" : disc_path_.string().c_str());
audioDiagLine("device: mix_rate=%d buffer=%u frames (%.1f ms) volume=%d ff_volume=%d "
"muted=%d swap=%d skip_reverb=%d",
kAudioMixRate, audio_desired_.samples,
(static_cast<double>(audio_desired_.samples) * 1000.0) /
static_cast<double>(kAudioMixRate),
audio_volume_, audio_ff_volume_,
audio_muted_ ? 1 : 0, audio_swap_channels_ ? 1 : 0,
psx_->spu->reverb_disabled);
SDL_LockAudioDevice(audio_dev_);
audio_underruns_ = 0;
audio_underrun_bytes_ = 0;
audio_rebuffer_events_ = 0;
audio_overflow_resets_ = 0;
SDL_UnlockAudioDevice(audio_dev_);
psx_audio_diag_set_enabled(1);
}
void audioDiagEnd() {
psx_audio_diag_set_enabled(0);
if (!audio_diag_file_) {
return;
}
char tail[256];
std::snprintf(tail, sizeof(tail), "--- end capture #%d: %d snapshots ---\n",
audio_diag_seq_, audio_diag_snapshots_);
std::fputs(tail, audio_diag_file_);
std::fflush(audio_diag_file_);
std::fclose(audio_diag_file_);
audio_diag_file_ = nullptr;
}
// Called once per emulated frame, after the audio for that frame has been mixed and queued
// so a snapshot describes a whole frame's worth of work.
void runAudioDiag() {
if (audio_diag_file_) {
if ((audio_diag_frames_left_ % kAudioDiagSnapshotFrames) == 0) {
audioDiagSnapshot();
}
if (--audio_diag_frames_left_ <= 0) {
audioDiagEnd();
}
return;
}
if (--audio_diag_poll_ > 0) {
return;
}
audio_diag_poll_ = kAudioDiagPollFrames;
std::string path;
if (!audioDiagPath(path, "audio_diag")) {
return;
}
FILE* marker = std::fopen(path.c_str(), "rb");
if (!marker) {
return;
}
std::fclose(marker);
// Deleted before the capture opens, so a failed fopen() cannot leave the marker armed
// and a successful one cannot run a second window off one touch.
std::error_code ec;
std::filesystem::remove(std::filesystem::path(path), ec);
// The budget is checked AFTER the marker is consumed, and the refusal is written into
// the output file the user actually retrieves. A spent budget used to be indistinguishable
// from a marker that was never noticed: the marker just sat there and nothing happened,
// which reads as "the instrumentation is broken" rather than "relaunch the app".
if (audio_diag_budget_ <= 0) {
std::string out;
if (audioDiagPath(out, "audio_diag.txt")) {
if (FILE* f = std::fopen(out.c_str(), "a")) {
// fputs, not fprintf: frontend/diagnostics.h #defines fprintf into the diag
// pipe, which would both fail to compile as std::fprintf and mirror the line
// into armsx.log. Same reason psx/dev/gpu.c's dump writes with fputs().
char note[192];
std::snprintf(note, sizeof(note),
"=== marker seen but the per-process capture budget of %d is "
"spent; relaunch the app to re-arm ===\n", kAudioDiagBudget);
std::fputs(note, f);
std::fclose(f);
}
}
psxe_diag_logf("audio", "audio_diag: budget spent, relaunch to re-arm");
return;
}
audio_diag_budget_--;
audioDiagBegin();
}
/* Decides how many samples the frame ABOUT TO RUN owes the audio device, and grants the
SPU exactly that many to produce while it runs.
This used to be the first thing queueAudioForFrame() did, i.e. after the frame, and the
SPU then produced all of them in one lump with the CPU stopped. That is what capped the
SPU interrupt rate at the video frame rate; the full measurement is in the block comment
above psx_spu_tick() in psx/dev/spu.c. Splitting the decision out and moving it ahead of
the frame is the whole change — the count per frame is identical, only the instants the
samples are produced at have moved onto the CPU's timeline.
Deliberately mirrors queueAudioForFrame()'s old early-outs: no device means no grant,
so a session without audio leaves the SPU exactly as frozen as it was before. */
void beginAudioFrame() {
audio_frame_samples_ = 0;
if (!psx_ || !audio_dev_) {
return;
}
// The SPU is pulled by the frontend, not free-running: psx_spu_get_sample() advances
// the voice cursors, the ADSR envelopes and the key-on/key-off latches once per call.
// So this asks for the same number of samples per EMULATED frame whatever the
// wall-clock speed is. Fast-forward is dealt with after the mix, by resampling — never
// by pulling fewer samples, which would stretch every envelope and eat note edges.
audio_sample_accumulator_ += static_cast<double>(kAudioMixRate) / frameRate();
const int sample_count = static_cast<int>(audio_sample_accumulator_);
if (sample_count <= 0) {
return;
}
audio_sample_accumulator_ -= static_cast<double>(sample_count);
audio_frame_samples_ = sample_count;
psx_spu_begin_frame(psx_->spu, sample_count);
}
void queueAudioForFrame() {
if (!psx_ || !audio_dev_) {
return;
}
recoverAudioUnderrunIfNeeded();
const int sample_count = audio_frame_samples_;
if (sample_count <= 0) {
return;
}
const size_t byte_count = static_cast<size_t>(sample_count) * kAudioBytesPerSample;
if (byte_count == 0) {
return;
}
std::vector<uint8_t> frame_audio(byte_count);
MixPsxAudio(psx_, frame_audio.data(), static_cast<int>(frame_audio.size()));
applyOutputShaping(frame_audio.data(), static_cast<size_t>(sample_count));
size_t queued_samples = 0;
size_t drained_samples = 0;
bool start_audio = false;
const bool deliberate_rate_conversion = audioRateConversionActive();
const bool realtime_elasticity = !deliberate_rate_conversion && audio_playback_started_;
if (deliberate_rate_conversion || realtime_elasticity) {
SDL_LockAudioDevice(audio_dev_);
queued_samples = (audio_queue_.size() - audio_queue_read_offset_) / kAudioBytesPerSample;
drained_samples = audio_consumed_bytes_ / kAudioBytesPerSample;
audio_consumed_bytes_ = 0;
SDL_UnlockAudioDevice(audio_dev_);
if (deliberate_rate_conversion) {
updateAudioRateRatio(sample_count, queued_samples, drained_samples);
} else {
updateRealtimeAudioRatio(sample_count, queued_samples);
}
frame_audio = resampleToDeviceRate(frame_audio.data(), static_cast<size_t>(sample_count));
if (frame_audio.empty()) {
return;
}
} else {
// Do not let time spent building the startup/rebuffer cushion look like a slow
// emulation frame when playback resumes. The exact SPU samples are queued unchanged
// until the device starts; elasticity begins with the first real output interval.
resetRealtimeAudioClock();
}
SDL_LockAudioDevice(audio_dev_);
const size_t queue_before = audio_queue_.size() - audio_queue_read_offset_;
compactAudioQueueLocked();
if ((audio_queue_.size() - audio_queue_read_offset_) > kMaxQueuedAudioBytes) {
// The opposite failure to an underrun: audio was produced faster than the device
// took it and half a second of it is thrown away in one go, which is heard as a
// jump. `audio_diag` counts it so the two are never confused for each other.
audio_overflow_resets_++;
resetAudioQueueLocked();
}
audio_queue_.insert(audio_queue_.end(), frame_audio.begin(), frame_audio.end());
const size_t queue_after = audio_queue_.size() - audio_queue_read_offset_;
if (!audio_playback_started_ &&
queue_after >= static_cast<size_t>(kAudioPrebufferSamples) * kAudioBytesPerSample) {
// Start only after several complete frames are queued. This keeps OpenSL ES from
// consuming the first frame while the emulation thread is still bootstrapping and
// gives the normal frame loop a real jitter cushion rather than relying on luck at
// the first callback.
audio_playback_started_ = true;
start_audio = true;
}
SDL_UnlockAudioDevice(audio_dev_);
if (start_audio) {
updateAudioPlaybackState();
}
// Only read by the HW_DEBUG trace below.
(void)queue_before;
(void)queue_after;
(void)drained_samples;
#if defined(USE_HARDWARE) && defined(HW_DEBUG)
if (hardware_backend_active_) {
psxe_diag_logf(
"audio",
"frame-audio frame=%llu samples=%d bytes=%zu queue_before=%zu queue_after=%zu accumulator=%.3f frame_rate=%.3f fast_forward=%s rate_ratio=%.3f drained=%zu paused=%s",
static_cast<unsigned long long>(vblank_counter_),
sample_count,
frame_audio.size(),
queue_before,
queue_after,
audio_sample_accumulator_,
frameRate(),
fast_forward_enabled_ ? "true" : "false",
audio_rate_ratio_,
drained_samples,
paused_ ? "true" : "false"
);
}
#endif
// After the mix and the queue push, so a snapshot describes a whole frame.
runAudioDiag();
}
// Volume / mute / channel swap, applied to the freshly mixed frame. Deliberately a no-op
// at the defaults (volume 100, nothing muted, no swap) so normal playback is byte-for-byte
// what it was before the [audio] table existed.
void applyOutputShaping(uint8_t* data, size_t sample_count) {
const int volume = fast_forward_enabled_ ? audio_ff_volume_ : audio_volume_;
const bool silent = audio_muted_ || volume == 0
|| (fast_forward_enabled_ && audio_mute_fast_forward_);
if (silent) {
// Zeroed rather than skipped: MixPsxAudio() has already run, so the SPU state is
// correct either way, and feeding the device silence beats letting it run dry.
std::memset(data, 0, sample_count * kAudioBytesPerSample);
return;
}
auto* samples = reinterpret_cast<int16_t*>(data);
if (audio_swap_channels_) {
for (size_t index = 0; index < sample_count; index++) {
std::swap(samples[(index << 1) + 0], samples[(index << 1) + 1]);
}
}
if (volume == 100) {
return;
}
for (size_t index = 0; index < (sample_count << 1); index++) {
const int scaled = (static_cast<int>(samples[index]) * volume) / 100;
samples[index] = static_cast<int16_t>(std::clamp(scaled, -32768, 32767));
}
}
// Track how far the emulated stream has to be rate-converted to reach the device.
//
// The obvious answer — divide by the requested multiplier — breaks the moment the device
// cannot actually reach it: a phone managing 1.3x while the user asked for 4x would have
// its queue starved solid, and "unlimited" has no nominal multiplier to divide by at all.
// So the ratio comes from what the device REALLY drained since the last frame (the
// feed-forward term, which is the whole answer in steady state) plus a slow pull toward a
// target queue depth, which absorbs the mismatch in either direction.
//
// Both inputs arrive in device-buffer-sized lumps, so the estimate is smoothed hard before
// it becomes a ratio: an estimate that tracked the lumps would wobble the pitch audibly.
void updateAudioRateRatio(int input_samples, size_t queued_samples, size_t drained_samples) {
if (audio_rate_output_estimate_ <= 0.0) {
// First frame at this speed: seed from the configured multiplier so the loop only
// has to trim, instead of ramping from 1x with an audible sweep.
audio_rate_output_estimate_ =
static_cast<double>(input_samples) / std::max(expectedSpeedMultiplier(), 0.05);
}
const double raw_output = static_cast<double>(drained_samples)
+ (static_cast<double>(kAudioRateTargetQueueSamples) - static_cast<double>(queued_samples))
/ kAudioRateQueueCorrectionFrames;
audio_rate_output_estimate_ += kAudioRateSmoothing * (raw_output - audio_rate_output_estimate_);
audio_rate_output_estimate_ = std::max(audio_rate_output_estimate_, 1.0);
audio_rate_ratio_ = std::clamp(
static_cast<double>(input_samples) / audio_rate_output_estimate_, kAudioRateMinRatio, kAudioRateMaxRatio);
}
void resetRealtimeAudioClock() {
audio_realtime_last_counter_ = 0;
audio_realtime_elapsed_seconds_ = 0.0;
audio_realtime_input_samples_ = 0;
audio_realtime_window_frames_ = 0;
audio_rate_ratio_ = 1.0;
audio_stretch_initialized_ = false;
}
/* Normal-speed elasticity is intentionally separate from fast-forward conversion above.
The SPU still produces exactly the samples its emulated clock owes. Every four frames we
compare those samples with real elapsed output time and gently lengthen only the HOST
stream when thermal throttling or a scheduler stall made the core late. This is the
difference between slower audio and a crack: without it the device consumes the queue at
44.1 kHz while a 75%-speed core can replenish only 33 kHz, so silence is mathematically
inevitable no matter how carefully the callback is locked. */
void updateRealtimeAudioRatio(int input_samples, size_t queued_samples) {
const Uint64 now = SDL_GetPerformanceCounter();
const Uint64 frequency = SDL_GetPerformanceFrequency();
if (audio_realtime_last_counter_ == 0 || frequency == 0) {
audio_realtime_last_counter_ = now;
return;
}
const double elapsed = static_cast<double>(now - audio_realtime_last_counter_) /
static_cast<double>(frequency);
audio_realtime_last_counter_ = now;
// A background/pause transition is not a slow frame. Its queue is rebuilt through the
// normal prebuffer path, so discard the discontinuous wall-clock sample here.
if (!(elapsed > 0.0) || elapsed > 0.25) {
audio_realtime_elapsed_seconds_ = 0.0;
audio_realtime_input_samples_ = 0;
audio_realtime_window_frames_ = 0;
audio_rate_ratio_ = 1.0;
audio_stretch_initialized_ = false;
return;
}
audio_realtime_elapsed_seconds_ += elapsed;
audio_realtime_input_samples_ += static_cast<size_t>(input_samples);
if (++audio_realtime_window_frames_ < kAudioRealtimeWindowFrames) {
return;
}
const double wanted = armsx_audio_realtime_ratio(
audio_realtime_input_samples_, audio_realtime_elapsed_seconds_, kAudioMixRate,
queued_samples, kAudioRateTargetQueueSamples);
// Four frames already reject callback-size lumpiness. Attack a real slowdown quickly so
// the 120 ms cushion survives, but release toward normal speed slowly; the reverse would
// replace crackle with a high-pitch catch-up chirp after every scheduler stall.
const double response = wanted < audio_rate_ratio_ ? 0.75 : 0.25;
audio_rate_ratio_ += (wanted - audio_rate_ratio_) * response;
if (std::abs(audio_rate_ratio_ - 1.0) < 0.003) {
audio_rate_ratio_ = 1.0;
}
audio_realtime_elapsed_seconds_ = 0.0;
audio_realtime_input_samples_ = 0;
audio_realtime_window_frames_ = 0;
}
// Convert the frame to the device rate. `audio_rate_ratio_` is input samples per output
// sample, so above 1 the stream is compressed (fast-forward, limiter off, speed > 100%)
// and below 1 it is stretched (speed < 100%, or an fps cap under the game's own rate).
//
// Compressing uses a box average over the samples being collapsed rather than "keep every
// Nth": plain decimation of a 44.1 kHz stream aliases badly — the SPU's own pitch
// conversion leaves plenty of energy near Nyquist — and averaging the samples that would
// have been thrown away costs two adds each. Stretching holds the last output, which is
// rough but only ever reached below 100% speed.
//
// Neither pitch is preserved: at 2x the audio comes out an octave up, which is what a
// fast-forward without a time-stretcher sounds like everywhere else. Phase and the partial
// accumulator survive across frames, so a fractional ratio neither drifts nor clicks at
// the frame boundary.
std::vector<uint8_t> resampleToDeviceRate(const uint8_t* data, size_t input_samples) {
const auto* samples = reinterpret_cast<const int16_t*>(data);
std::vector<uint8_t> out;
out.reserve(static_cast<size_t>(static_cast<double>(input_samples) / audio_rate_ratio_ + 4.0)
* kAudioBytesPerSample);
auto emit = [&](int16_t left, int16_t right) {
const uint8_t* left_bytes = reinterpret_cast<const uint8_t*>(&left);
const uint8_t* right_bytes = reinterpret_cast<const uint8_t*>(&right);
out.insert(out.end(), left_bytes, left_bytes + sizeof(int16_t));
out.insert(out.end(), right_bytes, right_bytes + sizeof(int16_t));
};
if (input_samples == 0) {
return out;
}
if (audio_rate_ratio_ < 1.0) {
// Linear interpolation for the normal-speed slowdown path. Repeating the previous
// sample (the old ratio<1 behaviour) turns a sustained 70% run into a buzzy staircase.
// The virtual frame at index zero is the final input from the previous chunk, which
// keeps interpolation continuous across PS1 frame boundaries.
if (!audio_stretch_initialized_) {
audio_stretch_previous_left_ = samples[0];
audio_stretch_previous_right_ = samples[1];
audio_stretch_phase_ = 1.0;
audio_stretch_initialized_ = true;
}
while (audio_stretch_phase_ < static_cast<double>(input_samples)) {
const size_t upper = static_cast<size_t>(audio_stretch_phase_);
const double fraction = audio_stretch_phase_ - static_cast<double>(upper);
const int16_t left0 = upper == 0
? audio_stretch_previous_left_
: samples[((upper - 1u) << 1) + 0u];
const int16_t right0 = upper == 0
? audio_stretch_previous_right_
: samples[((upper - 1u) << 1) + 1u];
const int16_t left1 = samples[(upper << 1) + 0u];
const int16_t right1 = samples[(upper << 1) + 1u];
const int16_t left = static_cast<int16_t>(std::lround(
static_cast<double>(left0) +
(static_cast<double>(left1) - static_cast<double>(left0)) * fraction));
const int16_t right = static_cast<int16_t>(std::lround(
static_cast<double>(right0) +
(static_cast<double>(right1) - static_cast<double>(right0)) * fraction));
emit(left, right);
audio_stretch_phase_ += audio_rate_ratio_;
}
audio_stretch_phase_ -= static_cast<double>(input_samples);
audio_stretch_previous_left_ = samples[((input_samples - 1u) << 1) + 0u];
audio_stretch_previous_right_ = samples[((input_samples - 1u) << 1) + 1u];
return out;
}
audio_stretch_initialized_ = false;
for (size_t index = 0; index < input_samples; index++) {
audio_rate_accumulator_left_ += samples[(index << 1) + 0];
audio_rate_accumulator_right_ += samples[(index << 1) + 1];
audio_rate_accumulator_count_++;
audio_rate_phase_ += 1.0;
while (audio_rate_phase_ >= audio_rate_ratio_) {
audio_rate_phase_ -= audio_rate_ratio_;
// The inner iterations of this loop (ratio below 1) have no fresh input to
// average, so they repeat the last output rather than divide by zero.
if (audio_rate_accumulator_count_ > 0) {
audio_rate_last_left_ =
static_cast<int16_t>(audio_rate_accumulator_left_ / audio_rate_accumulator_count_);
audio_rate_last_right_ =
static_cast<int16_t>(audio_rate_accumulator_right_ / audio_rate_accumulator_count_);
audio_rate_accumulator_left_ = 0;
audio_rate_accumulator_right_ = 0;
audio_rate_accumulator_count_ = 0;
}
emit(audio_rate_last_left_, audio_rate_last_right_);
}
}
return out;
}
void resetAudioRateConverter() {
audio_rate_ratio_ = 1.0;
audio_rate_output_estimate_ = 0.0;
audio_rate_phase_ = 0.0;
audio_rate_accumulator_left_ = 0;
audio_rate_accumulator_right_ = 0;
audio_rate_accumulator_count_ = 0;
audio_rate_last_left_ = 0;
audio_rate_last_right_ = 0;
audio_consumed_bytes_ = 0;
resetRealtimeAudioClock();
audio_stretch_phase_ = 1.0;
audio_stretch_previous_left_ = 0;
audio_stretch_previous_right_ = 0;
}
void consumeQueuedAudio(uint8_t* buffer, size_t size) {
const size_t available = audio_queue_.size() - audio_queue_read_offset_;
const ArmsxAudioQueueReadDecision decision =
armsx_audio_queue_read_decision(available, size, audio_rebuffer_requested_);
const size_t to_copy = decision.copy_bytes;
// `audio_diag`. A short read means the producer missed a device deadline. Do not tear
// the queued tail by copying only part of it: AudioUpdate() already zeroed this callback,
// and the emulation thread will pause/re-prime on its next frame. Counted on SDL's audio
// thread and read under SDL_LockAudioDevice(), like audio_consumed_bytes_.
if (decision.request_rebuffer) {
audio_underruns_++;
audio_underrun_bytes_ += size;
if (!audio_rebuffer_requested_) {
audio_rebuffer_requested_ = true;
audio_rebuffer_events_++;
}
return;
}
if (to_copy > 0) {
std::memcpy(buffer, audio_queue_.data() + audio_queue_read_offset_, to_copy);
audio_queue_read_offset_ += to_copy;
// Feeds the fast-forward rate estimate above. Only ever touched from here (inside
// SDL's audio lock) and from the emulation thread under SDL_LockAudioDevice().
audio_consumed_bytes_ += to_copy;
}
if (audio_queue_read_offset_ >= audio_queue_.size()) {
// The callback owns only the queue storage. The fractional sample accumulator is
// emulation-thread state and must survive a normal device drain.
resetAudioQueueStorageLocked();
} else if (audio_queue_read_offset_ >= kAudioQueueCompactThreshold) {
compactAudioQueueLocked();
}
}
void clearQueuedAudio() {
if (!audio_dev_) {
resetAudioQueueLocked();
audio_playback_started_ = false;
audio_rebuffer_requested_ = false;
return;
}
SDL_LockAudioDevice(audio_dev_);
resetAudioQueueLocked();
audio_rebuffer_requested_ = false;
SDL_UnlockAudioDevice(audio_dev_);
audio_playback_started_ = false;
}
void recoverAudioUnderrunIfNeeded() {
SDL_LockAudioDevice(audio_dev_);
const bool requested = audio_rebuffer_requested_;
SDL_UnlockAudioDevice(audio_dev_);
if (!requested) {
return;
}
// SDL waits for an in-flight callback here. Calling this from AudioUpdate itself would
// deadlock; queueAudioForFrame() is the emulation-thread rendezvous. Keep the unread tail
// intact, mark playback unprimed, and let the existing startup threshold resume it only
// after the jitter cushion is full again.
SDL_PauseAudioDevice(audio_dev_, 1);
SDL_LockAudioDevice(audio_dev_);
audio_rebuffer_requested_ = false;
audio_playback_started_ = false;
SDL_UnlockAudioDevice(audio_dev_);
}
void resetAudioQueueStorageLocked() {
audio_queue_.clear();
audio_queue_read_offset_ = 0;
}
void resetAudioQueueLocked() {
// The fractional sample accumulator belongs to the emulation thread. Do not touch it
// from the SDL callback when the device happens to drain the queue: that was a data race
// and periodically discarded the fractional part of the next frame's sample budget.
audio_sample_accumulator_ = 0.0;
resetAudioQueueStorageLocked();
}
void compactAudioQueueLocked() {
if (audio_queue_read_offset_ == 0) {
return;
}
if (audio_queue_read_offset_ >= audio_queue_.size()) {
resetAudioQueueStorageLocked();
return;
}
audio_queue_.erase(audio_queue_.begin(), audio_queue_.begin() + static_cast<std::ptrdiff_t>(audio_queue_read_offset_));
audio_queue_read_offset_ = 0;
}
static constexpr int kAudioMixRate = 44100;
// Android can pause the emulation thread for several scheduler quanta while the app is
// still foregrounded (surface callbacks, Compose work, or a CD read). Keep enough queued
// audio to cover that without changing the emulated sample clock. This is a startup/jitter
// cushion only; the authoritative mixer still produces exactly one frame's samples.
static constexpr int kAudioPrebufferSamples = kAudioMixRate * 120 / 1000;
// `audio_diag`: ~10 s of capture, sampled five times a second, four arms per process.
static constexpr int kAudioDiagFrames = 1800;
static constexpr int kAudioDiagSnapshotFrames = 30;
static constexpr int kAudioDiagPollFrames = 30;
static constexpr int kAudioDiagBudget = 16;
static constexpr size_t kAudioBytesPerSample = sizeof(int16_t) * 2;
static constexpr size_t kMaxQueuedAudioBytes = static_cast<size_t>(kAudioMixRate * sizeof(int16_t) * 2 / 2);
static constexpr size_t kAudioQueueCompactThreshold = 4096;
static constexpr std::uint32_t kMaxFrameSteps = PSX_CPU_CPS / 8u;
// The normal-speed elasticity controller holds the same 120 ms depth used to start/re-prime
// playback. It changes host sample duration, never the emulated SPU clock or CPU schedule.
static constexpr int kAudioRateTargetQueueSamples = kAudioPrebufferSamples;
static constexpr int kAudioRealtimeWindowFrames = 4;
// Frames the queue-depth correction is spread over, and how hard the whole estimate is
// smoothed. Both slow on purpose — see updateAudioRateRatio().
static constexpr double kAudioRateQueueCorrectionFrames = 16.0;
static constexpr double kAudioRateSmoothing = 0.05;
// Ratio bounds. The low end matches the 10% floor on speed_percent; the high end is a
// sanity rail, not a real limit (32x of a 44.1 kHz stream is already inaudible mush).
static constexpr double kAudioRateMinRatio = 0.1;
static constexpr double kAudioRateMaxRatio = 32.0;
// Stand-in for "no cap" in targetFrameRate(). Far above anything a PS1 core reaches, so
// waitForFrameDeadline() always finds the deadline already passed and never sleeps.
static constexpr double kUncappedFrameRate = 100000.0;
// Presentation goes through the backend abstraction (frontend/render.h); the session no
// longer owns an SDL_Texture of its own.
armsx_renderer_t* render_ = nullptr;
psx_t* psx_ = nullptr;
psx_input_t* input_ = nullptr;
// What is actually in controller port 1, and the boot analog mode the live Multitap
// toggle has to rebuild it with (settings.analog_mode_default is not in scope there).
bool multitap_attached_ = false;
bool analog_mode_default_ = false;
// A look-ahead ran last frame and its snapshot is still waiting to be put back. See
// runaheadRestore() for why the restore cannot happen at the end of the frame that took it.
bool runahead_restore_pending_ = false;
bool frame_uploaded_ = false;
std::vector<uint8_t> texture_snapshot_;
#ifdef USE_HARDWARE
armsx_hw_renderer_t* hw_renderer_ = nullptr;
// Internal-resolution rasterizer backend; null means the software path.
psx_gpu_backend_t* hw_rt_backend_ = nullptr;
bool hw_rt_is_gl_ = false;
// Remembered from the settings so checkRasterizerHealth() can rebuild a fallback
// rasterizer without a FrontendSettings in hand.
int rasterizer_mode_ = 0;
int internal_scale_ = 1;
#endif
SDL_AudioDeviceID audio_dev_ = 0;
bool audio_playback_started_ = false;
// Set by the SDL callback under the device lock; consumed by the emulation thread, which
// performs the pause/re-prime outside the callback.
bool audio_rebuffer_requested_ = false;
std::vector<uint8_t> audio_queue_;
size_t audio_queue_read_offset_ = 0;
double audio_sample_accumulator_ = 0.0;
/* Samples beginAudioFrame() committed to for the frame in flight, and therefore the
budget psx_spu_tick() was granted. Read back by queueAudioForFrame() so the two cannot
disagree about how long the frame was. */
int audio_frame_samples_ = 0;
// Bytes the device has taken since the emulation thread last looked; drives the
// fast-forward rate estimate. Written under SDL's audio lock on both sides.
size_t audio_consumed_bytes_ = 0;
// `audio_diag` only. Same locking discipline as audio_consumed_bytes_.
uint32_t audio_underruns_ = 0;
uint64_t audio_underrun_bytes_ = 0;
uint32_t audio_rebuffer_events_ = 0;
uint32_t audio_overflow_resets_ = 0;
// One-shot capture state; see runAudioDiag(). All emulation thread.
FILE* audio_diag_file_ = nullptr;
int audio_diag_frames_left_ = 0;
int audio_diag_poll_ = 0;
int audio_diag_budget_ = kAudioDiagBudget;
int audio_diag_seq_ = 0;
int audio_diag_snapshots_ = 0;
// [audio], mirrored here so the audio path never reaches back into FrontendSettings.
int audio_volume_ = 100;
int audio_ff_volume_ = 100;
bool audio_muted_ = false;
bool audio_mute_fast_forward_ = false;
bool audio_swap_channels_ = false;
// Rate converter state (see updateAudioRateRatio / resampleToDeviceRate).
double audio_rate_ratio_ = 1.0;
double audio_rate_output_estimate_ = 0.0;
double audio_rate_phase_ = 0.0;
int32_t audio_rate_accumulator_left_ = 0;
int32_t audio_rate_accumulator_right_ = 0;
int audio_rate_accumulator_count_ = 0;
int16_t audio_rate_last_left_ = 0;
int16_t audio_rate_last_right_ = 0;
Uint64 audio_realtime_last_counter_ = 0;
double audio_realtime_elapsed_seconds_ = 0.0;
size_t audio_realtime_input_samples_ = 0;
int audio_realtime_window_frames_ = 0;
bool audio_stretch_initialized_ = false;
double audio_stretch_phase_ = 1.0;
int16_t audio_stretch_previous_left_ = 0;
int16_t audio_stretch_previous_right_ = 0;
std::filesystem::path disc_path_;
std::filesystem::path exe_path_;
std::string title_;
LaunchKind launch_kind_ = LaunchKind::None;
bool paused_ = false;
// App is off-screen (backgrounded / screen off). Independent of paused_: the pause menu
// freezes the VM but keeps the device open, this closes it outright.
bool audio_suspended_ = false;
// The spec the device was opened with, kept so it can be reopened identically on the way
// back from the background.
SDL_AudioSpec audio_desired_{};
bool audio_device_ever_opened_ = false; // gate for ensureAudioDevice()'s retry
bool audio_reopen_failed_ = false; // one log line per failure run, not one per retry
Uint64 audio_reopen_next_attempt_ = 0; // SDL_GetTicks64() throttle for the retry
bool fast_forward_enabled_ = false;
// [runtime] frame pacing; see setSpeedLimits(). fast_forward_speed_ 0 = uncapped.
bool frame_limit_ = true;
int speed_percent_ = 100;
int fps_limit_ = 0;
double fast_forward_speed_ = 2.0;
// [runtime] frame_skip. Held here only because the host pushes it with the pacing policy;
// the session itself never acts on it (the app loop owns presentation).
int frame_skip_ = 0;
bool debug_view_ = false;
/* Aspect/stretch trace: the first few frames, then only when the mode actually changes,
so logcat proves which display mode is in force without spamming per frame. */
int aspect_traces_ = 0;
int last_logged_aspect_mode_ = -1;
bool last_logged_stretch_ = false;
#ifdef USE_HARDWARE
bool hardware_backend_active_ = false;
#endif
std::uint64_t vblank_counter_ = 0;
int texture_width_ = 0;
int texture_height_ = 0;
// Pre-multiplier display size, kept so the degenerate "display width reported 0"
// fallback cannot compound the internal-resolution scale frame after frame.
/* [video] deinterlace scratch. Same pitch and height as the frame it replaces; kept
across frames so a steady 480-line game allocates once. */
std::vector<uint8_t> deint_buffer_;
int texture_native_width_ = 0;
int texture_native_height_ = 0;
Uint32 texture_format_ = SDL_PIXELFORMAT_UNKNOWN;
};
class GameplayInputRouter {
public:
~GameplayInputRouter() {
if (controller_) {
SDL_GameControllerClose(controller_);
controller_ = nullptr;
}
}
void attach(psx_pad_t* pad) {
clearAll();
pad_ = pad;
}
void detach() {
clearAll();
pad_ = nullptr;
}
void onFsuiOpened() {
fsui_owns_input_ = true;
clearAll();
}
void onFsuiClosed() {
fsui_owns_input_ = false;
clearAll();
}
bool takePauseRequest() {
const bool requested = pause_requested_;
pause_requested_ = false;
return requested;
}
void tick(bool fsui_active) {
if (!fsui_active) {
flushChordButtons(SDL_GetTicks());
}
}
void processEvent(const SDL_Event& event, ArmsxSession* session, bool fsui_active) {
flushChordButtons(SDL_GetTicks());
handleControllerLifecycle(event);
if (!session || !session->valid()) {
return;
}
if (event.type == SDL_KEYDOWN) {
if (event.key.repeat == 0 && event.key.keysym.sym == SDLK_ESCAPE && !fsui_active) {
pause_requested_ = true;
return;
}
if (fsui_active || fsui_owns_input_ || event.key.repeat != 0) {
return;
}
const uint32_t mask = buttonForKey(event.key.keysym.sym);
if (mask) {
press(mask);
}
return;
}
if (event.type == SDL_KEYUP) {
if (event.key.keysym.sym == SDLK_ESCAPE) {
return;
}
if (fsui_active || fsui_owns_input_) {
return;
}
const uint32_t mask = buttonForKey(event.key.keysym.sym);
if (mask) {
release(mask);
}
return;
}
if (!controller_) {
return;
}
SDL_Joystick* joystick = SDL_GameControllerGetJoystick(controller_);
if (!joystick) {
return;
}
const SDL_JoystickID controller_id = SDL_JoystickInstanceID(joystick);
if (event.type == SDL_CONTROLLERBUTTONDOWN || event.type == SDL_CONTROLLERBUTTONUP) {
if (event.cbutton.which != controller_id) {
return;
}
const bool pressed = event.type == SDL_CONTROLLERBUTTONDOWN;
const SDL_GameControllerButton button = static_cast<SDL_GameControllerButton>(event.cbutton.button);
if (button == SDL_CONTROLLER_BUTTON_START || button == SDL_CONTROLLER_BUTTON_BACK ||
button == SDL_CONTROLLER_BUTTON_GUIDE || button == SDL_CONTROLLER_BUTTON_MISC1) {
handlePauseChordButton(button, pressed, fsui_active);
return;
}
if (fsui_active || fsui_owns_input_) {
return;
}
const uint32_t mask = buttonForController(button);
if (mask) {
if (pressed) {
press(mask);
} else {
release(mask);
}
}
return;
}
if (event.type == SDL_CONTROLLERAXISMOTION) {
if (event.caxis.which != controller_id || fsui_active || fsui_owns_input_ || !pad_) {
return;
}
const uint16_t mapped = static_cast<uint16_t>((static_cast<int>(event.caxis.value) + INT16_MAX + 1) / 0x100);
switch (event.caxis.axis) {
case SDL_CONTROLLER_AXIS_RIGHTX:
psx_pad_analog_change(pad_, 0, PSXI_AX_SDA_RIGHT_HORZ, mapped);
break;
case SDL_CONTROLLER_AXIS_RIGHTY:
psx_pad_analog_change(pad_, 0, PSXI_AX_SDA_RIGHT_VERT, mapped);
break;
case SDL_CONTROLLER_AXIS_LEFTX:
psx_pad_analog_change(pad_, 0, PSXI_AX_SDA_LEFT_HORZ, mapped);
break;
case SDL_CONTROLLER_AXIS_LEFTY:
psx_pad_analog_change(pad_, 0, PSXI_AX_SDA_LEFT_VERT, mapped);
break;
#ifdef CONTROLLER_GENERIC
case SDL_CONTROLLER_AXIS_TRIGGERLEFT:
handleTrigger(PSXI_SW_SDA_L2, trigger_left_down_, event.caxis.value);
break;
case SDL_CONTROLLER_AXIS_TRIGGERRIGHT:
handleTrigger(PSXI_SW_SDA_R2, trigger_right_down_, event.caxis.value);
break;
#endif
default:
break;
}
}
}
private:
void handleControllerLifecycle(const SDL_Event& event) {
if (event.type == SDL_CONTROLLERDEVICEADDED) {
if (!controller_ && SDL_IsGameController(event.cdevice.which)) {
controller_ = SDL_GameControllerOpen(event.cdevice.which);
}
return;
}
if (event.type == SDL_CONTROLLERDEVICEREMOVED && controller_) {
SDL_Joystick* joystick = SDL_GameControllerGetJoystick(controller_);
if (joystick && SDL_JoystickInstanceID(joystick) == event.cdevice.which) {
SDL_GameControllerClose(controller_);
controller_ = nullptr;
start_button_ = {};
select_button_ = {};
trigger_left_down_ = false;
trigger_right_down_ = false;
}
}
}
void handlePauseChordButton(SDL_GameControllerButton button, bool pressed, bool fsui_active) {
PendingChordButton* state = nullptr;
PendingChordButton* other = nullptr;
if (button == SDL_CONTROLLER_BUTTON_START) {
state = &start_button_;
other = &select_button_;
} else {
state = &select_button_;
other = &start_button_;
}
if (pressed) {
state->physical_down = true;
if (!fsui_active && !fsui_owns_input_ && !pause_latched_) {
state->pending = true;
state->pending_since = SDL_GetTicks();
if (other->physical_down && other->pending) {
pause_latched_ = true;
pause_requested_ = true;
state->pending = false;
other->pending = false;
clearAll();
}
}
return;
}
state->physical_down = false;
if (pause_latched_) {
if (!start_button_.physical_down && !select_button_.physical_down) {
pause_latched_ = false;
start_button_ = {};
select_button_ = {};
}
return;
}
if (state->pending) {
const uint32_t mask = (state == &start_button_) ? PSXI_SW_SDA_START : PSXI_SW_SDA_SELECT;
press(mask);
release(mask);
state->pending = false;
return;
}
if (state->forwarded) {
const uint32_t mask = (state == &start_button_) ? PSXI_SW_SDA_START : PSXI_SW_SDA_SELECT;
release(mask);
state->forwarded = false;
}
}
void flushChordButtons(Uint32 now) {
if (pause_latched_ || fsui_owns_input_) {
return;
}
flushOneChordButton(start_button_, select_button_, PSXI_SW_SDA_START, now);
flushOneChordButton(select_button_, start_button_, PSXI_SW_SDA_SELECT, now);
}
void flushOneChordButton(PendingChordButton& state, PendingChordButton& other, uint32_t mask, Uint32 now) {
if (!state.pending || other.physical_down || !pad_) {
return;
}
if ((now - state.pending_since) < kPauseChordGraceMs) {
return;
}
press(mask);
state.pending = false;
state.forwarded = true;
}
void press(uint32_t mask) {
if (!pad_ || !mask) {
return;
}
if ((active_digital_mask_ & mask) == 0) {
psx_pad_button_press(pad_, 0, mask);
active_digital_mask_ |= mask;
}
}
void release(uint32_t mask) {
if (!pad_ || !mask) {
return;
}
if ((active_digital_mask_ & mask) != 0) {
psx_pad_button_release(pad_, 0, mask);
active_digital_mask_ &= ~mask;
}
}
void clearAll() {
if (!pad_) {
active_digital_mask_ = 0;
return;
}
static const std::array<uint32_t, 17> masks = {
PSXI_SW_SDA_SELECT, PSXI_SW_SDA_L3, PSXI_SW_SDA_R3, PSXI_SW_SDA_START,
PSXI_SW_SDA_PAD_UP, PSXI_SW_SDA_PAD_RIGHT, PSXI_SW_SDA_PAD_DOWN, PSXI_SW_SDA_PAD_LEFT,
PSXI_SW_SDA_L2, PSXI_SW_SDA_R2, PSXI_SW_SDA_L1, PSXI_SW_SDA_R1,
PSXI_SW_SDA_TRIANGLE, PSXI_SW_SDA_CIRCLE, PSXI_SW_SDA_CROSS, PSXI_SW_SDA_SQUARE,
PSXI_SW_SDA_ANALOG,
};
for (uint32_t mask : masks) {
if ((active_digital_mask_ & mask) != 0) {
psx_pad_button_release(pad_, 0, mask);
}
}
active_digital_mask_ = 0;
psx_pad_analog_change(pad_, 0, PSXI_AX_SDA_RIGHT_HORZ, 0x80);
psx_pad_analog_change(pad_, 0, PSXI_AX_SDA_RIGHT_VERT, 0x80);
psx_pad_analog_change(pad_, 0, PSXI_AX_SDA_LEFT_HORZ, 0x80);
psx_pad_analog_change(pad_, 0, PSXI_AX_SDA_LEFT_VERT, 0x80);
}
#ifdef CONTROLLER_GENERIC
void handleTrigger(uint32_t mask, bool& down_flag, Sint16 value) {
const bool pressed = value > 8000;
if (pressed == down_flag) {
return;
}
down_flag = pressed;
if (pressed) {
press(mask);
} else {
release(mask);
}
}
#endif
static uint32_t buttonForKey(SDL_Keycode key) {
switch (key) {
case SDLK_x: return PSXI_SW_SDA_CROSS;
case SDLK_a: return PSXI_SW_SDA_SQUARE;
case SDLK_w: return PSXI_SW_SDA_TRIANGLE;
case SDLK_d: return PSXI_SW_SDA_CIRCLE;
case SDLK_RETURN: return PSXI_SW_SDA_START;
case SDLK_s: return PSXI_SW_SDA_SELECT;
case SDLK_UP: return PSXI_SW_SDA_PAD_UP;
case SDLK_DOWN: return PSXI_SW_SDA_PAD_DOWN;
case SDLK_LEFT: return PSXI_SW_SDA_PAD_LEFT;
case SDLK_RIGHT: return PSXI_SW_SDA_PAD_RIGHT;
case SDLK_q: return PSXI_SW_SDA_L1;
case SDLK_e: return PSXI_SW_SDA_R1;
case SDLK_1: return PSXI_SW_SDA_L2;
case SDLK_3: return PSXI_SW_SDA_R2;
case SDLK_z: return PSXI_SW_SDA_L3;
case SDLK_c: return PSXI_SW_SDA_R3;
case SDLK_2: return PSXI_SW_SDA_ANALOG;
default: return 0;
}
}
static uint32_t buttonForController(SDL_GameControllerButton button) {
switch (button) {
case SDL_CONTROLLER_BUTTON_A: return PSXI_SW_SDA_CROSS;
case SDL_CONTROLLER_BUTTON_X: return PSXI_SW_SDA_SQUARE;
case SDL_CONTROLLER_BUTTON_Y: return PSXI_SW_SDA_TRIANGLE;
case SDL_CONTROLLER_BUTTON_B: return PSXI_SW_SDA_CIRCLE;
case SDL_CONTROLLER_BUTTON_DPAD_UP: return PSXI_SW_SDA_PAD_UP;
case SDL_CONTROLLER_BUTTON_DPAD_DOWN: return PSXI_SW_SDA_PAD_DOWN;
case SDL_CONTROLLER_BUTTON_DPAD_LEFT: return PSXI_SW_SDA_PAD_LEFT;
case SDL_CONTROLLER_BUTTON_DPAD_RIGHT: return PSXI_SW_SDA_PAD_RIGHT;
case SDL_CONTROLLER_BUTTON_LEFTSHOULDER: return PSXI_SW_SDA_L1;
case SDL_CONTROLLER_BUTTON_RIGHTSHOULDER: return PSXI_SW_SDA_R1;
case SDL_CONTROLLER_BUTTON_LEFTSTICK: return PSXI_SW_SDA_L3;
case SDL_CONTROLLER_BUTTON_RIGHTSTICK: return PSXI_SW_SDA_R3;
default: return 0;
}
}
psx_pad_t* pad_ = nullptr;
SDL_GameController* controller_ = nullptr;
uint32_t active_digital_mask_ = 0;
PendingChordButton start_button_{};
PendingChordButton select_button_{};
bool pause_requested_ = false;
bool pause_latched_ = false;
bool fsui_owns_input_ = false;
bool trigger_left_down_ = false;
bool trigger_right_down_ = false;
};
class ArmsxApp {
public:
ArmsxApp(int argc, const char* argv[], void* external_window, void* external_renderer)
: argc_(argc), argv_(argv), external_window_(static_cast<SDL_Window*>(external_window)),
external_renderer_(static_cast<SDL_Renderer*>(external_renderer)), cli_(ScanCliFlags(argc, argv)) {}
int run() {
ARMSX_BOOTLOG("core: run() enter argc=%d external_window=%p external_renderer=%p",
argc_, static_cast<void*>(external_window_), static_cast<void*>(external_renderer_));
for (int index = 0; index < argc_; ++index) {
ARMSX_BOOTLOG("core: argv[%d]=%s", index, argv_ && argv_[index] ? argv_[index] : "(null)");
}
psxe_diag_initialize(psxe_cfg_get_pref_path());
psxe_diag_breadcrumbf("ARMSX startup argc=%d", argc_);
psxe_diag_logf("diag", "Pref path: %s", psxe_cfg_get_pref_path() ? psxe_cfg_get_pref_path() : "(none)");
// RetroAchievements. After the pref path is resolved (its state file lives there) and
// before anything can boot, so a saved login is already restoring when a game arrives.
armsx_ach_startup();
static bool log_callback_installed = false;
if (!log_callback_installed) {
log_add_callback(StructuredLogCallback, nullptr, LOG_TRACE);
SDL_LogSetOutputFunction(SdlLogOutput, nullptr);
log_callback_installed = true;
}
psxe_config_t* cfg = psxe_cfg_create();
if (!cfg) {
ARMSX_BOOTERR("core: psxe_cfg_create() failed");
return 1;
}
psxe_cfg_init(cfg);
psxe_cfg_load_defaults(cfg);
psxe_cfg_load(cfg, argc_, const_cast<const char**>(argv_));
settings_ = BuildSettings(cfg, cli_);
psxe_cfg_destroy(cfg);
applyLoggingSettings("startup");
#if defined(USE_HARDWARE)
if (const char* env_backend = std::getenv("ARMSX_GPU_BACKEND")) {
psxe_diag_breadcrumbf(
"GPU backend env override=%s resolved=%s",
env_backend,
GpuBackendTitle(settings_.gpu_backend)
);
psxe_diag_logf(
"diag",
"GPU backend env override=%s resolved=%s",
env_backend,
GpuBackendTitle(settings_.gpu_backend)
);
}
#endif
psxe_diag_breadcrumbf("Settings loaded model=%s region=%s logging_enabled=%s log_level=%d",
settings_.model.c_str(),
settings_.region.c_str(),
settings_.logging_enabled ? "true" : "false",
settings_.log_level);
ARMSX_BOOTLOG("core: settings loaded model=%s region=%s bios_override=%s bios_search=%s",
settings_.model.c_str(), settings_.region.c_str(),
settings_.bios_override.empty() ? "(none)" : settings_.bios_override.c_str(),
settings_.bios_search.empty() ? "(none)" : settings_.bios_search.c_str());
installCrashHandlers();
if (!initializeSdl()) {
ARMSX_BOOTERR("core: initializeSdl() failed: %s", SDL_GetError());
shutdown();
return 1;
}
ARMSX_BOOTLOG("core: initializeSdl() ok");
if (!initializeWindowAndRenderer()) {
ARMSX_BOOTERR("core: initializeWindowAndRenderer() failed: %s", SDL_GetError());
shutdown();
return 1;
}
ARMSX_BOOTLOG("core: initializeWindowAndRenderer() ok window=%p renderer=%p backend=%s",
static_cast<void*>(window_), static_cast<void*>(render_),
armsx_render_backend_name(armsx_renderer_backend(render_)));
if (!initializeFsui()) {
ARMSX_BOOTERR("core: initializeFsui() failed");
shutdown();
return 1;
}
logRendererBootstrap("frontend-init", kUiFrameRate);
refreshGameList(true);
g_active_app = this;
initializeWebLaunchSupport();
consumePendingLaunchArguments();
ARMSX_BOOTLOG("core: boot request=%s cli_launch=%s cli_argument=%s",
cli_.has_boot_request ? "true" : "false",
pending_cli_launch_.has_value() ? "present" : "absent",
pending_cli_argument_.empty() ? "(none)" : pending_cli_argument_.c_str());
if (cli_.has_boot_request) {
if (pending_cli_launch_.has_value()) {
const bool launched = launchSession(*pending_cli_launch_, false);
ARMSX_BOOTLOG("core: cli launchSession -> %s%s%s",
launched ? "ok" : "FAILED",
launched ? "" : " error=",
launched ? "" : (pending_error_dialog_.has_value() ? pending_error_dialog_->c_str() : "(none)"));
if (!launched) {
armsx_ach_shutdown();
shutdown();
g_active_app = nullptr;
return 1;
}
} else if (!pending_cli_argument_.empty()) {
pending_error_dialog_ = "Unsupported launch path or URI.";
ARMSX_BOOTERR("core: unsupported launch path or URI: %s", pending_cli_argument_.c_str());
armsx_ach_shutdown();
shutdown();
g_active_app = nullptr;
return 1;
}
}
consumePendingLaunchArguments();
// FSUI removed: a plain SDL event pump + frame loop replaces fsui::RunSdlMainLoop, and there
// is no in-app landing menu — the game to boot arrives via CLI args or the JNI launch queue
// (Jetpack Compose owns all menus). With nothing queued this simply shows a black window.
ARMSX_BOOTLOG("core: entering main loop running_=%s session_valid=%s",
running_ ? "true" : "false", session_.valid() ? "true" : "false");
g_host_loop_running.store(true, std::memory_order_release);
uint64_t loop_frames = 0;
while (running_) {
SDL_Event event;
while (SDL_PollEvent(&event)) {
handleEvent(event);
}
runFrame();
++loop_frames;
// Proof-of-life only: the first few frames confirm the loop actually spun up, then
// roughly once a minute so a wedged loop is still visible in a bug report without
// turning logcat into a firehose.
if (loop_frames <= 3 || (loop_frames % 3600) == 0) {
ARMSX_BOOTLOG("core: loop frame=%llu session_valid=%s",
static_cast<unsigned long long>(loop_frames),
session_.valid() ? "true" : "false");
}
}
ARMSX_BOOTLOG("core: main loop exited after %llu frames", static_cast<unsigned long long>(loop_frames));
g_host_loop_running.store(false, std::memory_order_release);
g_host_vm_active.store(false, std::memory_order_release);
// Before shutdown(): this waits briefly for an unlock submitted on the last frame to
// reach the server rather than dropping it with the process.
armsx_ach_shutdown();
shutdown();
g_active_app = nullptr;
return 0;
}
void writeCrashContext(const char* reason) const {
const ArmsxGameInfo info = session_.currentGameInfo();
psxe_diag_logf("crash", "Crash reason: %s", reason ? reason : "(unknown)");
psxe_diag_logf("crash", "Running game: %s", info.has_game ? info.title.c_str() : "(none)");
psxe_diag_logf("crash", "Game path: %s", info.path.empty() ? "(none)" : info.path.string().c_str());
psxe_diag_logf("crash", "BIOS override: %s", settings_.bios_override.empty() ? "(none)" : settings_.bios_override.c_str());
psxe_diag_logf("crash", "BIOS folder: %s", settings_.bios_search.c_str());
psxe_diag_logf("crash", "Model=%s Region=%s Expansion=%s",
settings_.model.c_str(),
settings_.region.c_str(),
settings_.exp_path.empty() ? "(none)" : settings_.exp_path.c_str());
logRendererBootstrap("crash", currentTargetFrameRate());
logCpuState();
psxe_diag_dump_breadcrumbs();
}
private:
void initializeWebLaunchSupport() {
#if defined(__EMSCRIPTEN__)
static bool initialized = false;
if (initialized) {
return;
}
initialized = true;
EM_ASM({
try {
const searchParams = new URLSearchParams(window.location.search);
let launchUri = searchParams.get('uri') || '';
if (!launchUri && window.location.hash) {
const hash = window.location.hash.startsWith('#') ? window.location.hash.substring(1) : window.location.hash;
launchUri = new URLSearchParams(hash).get('uri') || '';
}
if (launchUri.startsWith('web+armsx:')) {
launchUri = 'armsx:' + launchUri.substring('web+armsx:'.length);
}
if (window.location.protocol === 'https:' && typeof navigator !== 'undefined' &&
typeof navigator.registerProtocolHandler === 'function') {
try {
navigator.registerProtocolHandler(
'web+armsx',
window.location.origin + window.location.pathname + '?uri=%s',
'ARMSX'
);
} catch (error) {
console.warn('ARMSX protocol handler registration skipped', error);
}
}
if (launchUri && typeof Module !== 'undefined' && typeof Module.ccall === 'function') {
Module.ccall('psxe_enqueue_launch_argument', null, ['string'], [launchUri]);
}
} catch (error) {
console.warn('ARMSX web launch bootstrap failed', error);
}
});
#endif
}
void requestWebFiles(bool directory) {
#if defined(__EMSCRIPTEN__)
if (directory) {
EM_ASM({ window.ARMSXWebFiles.openDirectory(); });
} else {
EM_ASM({ window.ARMSXWebFiles.openFiles(); });
}
#else
(void)directory;
#endif
}
void queueLaunchArgument(std::string_view argument, bool close_ui, const char* error_message = "Unsupported launch path or URI.") {
const LaunchRequest request = LaunchForArgument(argument);
psxe_diag_logf("launch", "Launch argument=%s kind=%s", std::string(argument).c_str(), LaunchKindTitle(request.kind));
if (request.kind == LaunchKind::None) {
pending_error_dialog_ = error_message;
if (!session_.valid()) {
showLandingWindow();
}
return;
}
queueLaunchRequest(request, close_ui);
}
void consumePendingLaunchArguments() {
for (const std::string& message : DrainWebErrors()) {
pending_error_dialog_ = message;
if (!session_.valid()) {
showLandingWindow();
}
}
for (const std::string& argument : DrainPendingLaunchArguments()) {
queueLaunchArgument(argument, true);
}
}
void queueLaunchRequest(const LaunchRequest& request, bool close_ui) {
if (request.kind == LaunchKind::None) {
return;
}
deferred_launch_ = request;
close_ui_after_launch_ = close_ui;
}
void queueLaunchSelection(const std::string& path, std::optional<LaunchKind> forced_kind = std::nullopt) {
const std::optional<std::string> selection = NormalizedPickerSelection(path);
if (!selection.has_value()) {
return;
}
const LaunchRequest request = LaunchForArgument(*selection, forced_kind);
if (request.kind != LaunchKind::None) {
queueLaunchRequest(request, true);
}
}
void queueDiscSwapSelection(const std::string& path) {
const std::optional<std::string> selection = NormalizedPickerSelection(path);
if (!selection.has_value()) {
return;
}
deferred_change_disc_ = std::filesystem::path(*selection);
}
SDL_Renderer* sdlRenderer() const {
return armsx_renderer_sdl(render_);
}
#ifdef USE_HARDWARE
bool hardwareRendererAvailable() const {
if (!owns_renderer_ || external_renderer_ || !render_) {
return false;
}
return SupportsHardwareGpuBackend();
}
#endif
// Backend selection with a strict fallback ladder, so a device that cannot bring up the
// requested GPU backend still boots on the historical SDL path instead of failing.
// opengl -> sdl-accelerated -> software
// vulkan -> opengl -> sdl-accelerated -> software
bool createManagedRenderer(bool vsync_enabled) {
armsx_render_config_t config{};
config.vsync = vsync_enabled;
config.linear_filter = settings_.texture_scale_mode;
std::vector<armsx_render_backend_t> ladder;
#ifdef USE_HARDWARE
const armsx_render_backend_t requested = RenderBackendFor(settings_.gpu_backend);
#else
const armsx_render_backend_t requested = ARMSX_RENDER_BACKEND_SDL_SOFTWARE;
#endif
ladder.push_back(requested);
if (requested == ARMSX_RENDER_BACKEND_VULKAN) {
ladder.push_back(ARMSX_RENDER_BACKEND_OPENGL);
}
if (requested == ARMSX_RENDER_BACKEND_VULKAN || requested == ARMSX_RENDER_BACKEND_OPENGL) {
ladder.push_back(ARMSX_RENDER_BACKEND_SDL_ACCELERATED);
}
if (requested != ARMSX_RENDER_BACKEND_SDL_SOFTWARE) {
ladder.push_back(ARMSX_RENDER_BACKEND_SDL_SOFTWARE);
}
for (const armsx_render_backend_t backend : ladder) {
if (!armsx_render_backend_compiled_in(backend)) {
continue;
}
// A GL/Vulkan backend needs matching window flags; a window we did not create
// with them can only host the SDL backends. The EGL provider is exempt because
// it binds to the host's ANativeWindow, not to the SDL window.
const Uint32 needed = armsx_render_window_flags(backend);
if (needed && window_ && (SDL_GetWindowFlags(window_) & needed) != needed &&
!armsx_render_native_window()) {
psxe_diag_logf("renderer", "Skipping %s: window lacks the required SDL flags.",
armsx_render_backend_name(backend));
continue;
}
render_ = armsx_renderer_create(backend, window_, &config);
if (render_) {
break;
}
psxe_diag_logf("renderer", "Backend %s unavailable (%s); falling back.",
armsx_render_backend_name(backend), SDL_GetError());
}
owns_renderer_ = render_ != nullptr;
if (!render_) {
psxe_diag_logf(
"renderer",
"Renderer initialization failed requested_vsync=%s error=%s",
vsync_enabled ? "true" : "false",
SDL_GetError()
);
return false;
}
psxe_diag_logf(
"renderer",
"Selected presentation backend=%s driver=%s accelerated=%s",
armsx_render_backend_name(armsx_renderer_backend(render_)),
armsx_renderer_driver_name(render_),
armsx_renderer_is_accelerated(render_) ? "true" : "false"
);
managed_renderer_vsync_ = vsync_enabled;
return true;
}
void shutdownFsuiFrontend() {
}
void showLandingWindow() {
}
void showStartGameWindow() {
}
void showExitWindow() {
}
void showGameListWindow() {
}
void switchToSettingsWindow() {
}
void returnToMainWindow() {
}
void showPauseMenuWindow() {
}
void restoreUiWindow() {
}
bool recreateManagedRenderer(bool desired_vsync) {
if (!owns_renderer_ || external_renderer_ || !window_) {
return false;
}
const bool had_session = session_.valid();
const bool was_paused = had_session ? session_.paused() : false;
const FsuiWindowState restore_window = ui_window_state_;
const bool previous_vsync = managed_renderer_vsync_;
const bool should_be_paused_after_restore = had_session && (was_paused || restore_window != FsuiWindowState::None);
auto restore_previous_renderer = [&](const char* phase, const char* message) {
settings_.vsync_enabled = previous_vsync;
SaveSettings(settings_);
if (render_) {
armsx_renderer_destroy(render_);
render_ = nullptr;
}
owns_renderer_ = false;
if (!createManagedRenderer(previous_vsync) || !initializeFsui()) {
psxe_diag_logf("renderer", "Renderer recovery failed phase=%s error=%s", phase ? phase : "(none)", SDL_GetError());
running_ = false;
return false;
}
if (had_session) {
session_.rebindRenderer(render_, settings_);
session_.setPaused(should_be_paused_after_restore);
}
ui_window_state_ = restore_window;
restoreUiWindow();
resetFramePacing("renderer-recovery");
logRendererBootstrap(phase, currentTargetFrameRate());
pending_error_dialog_ = message;
return false;
};
if (had_session) {
session_.setPaused(true);
}
shutdownFsuiFrontend();
if (render_) {
armsx_renderer_destroy(render_);
render_ = nullptr;
}
owns_renderer_ = false;
if (!createManagedRenderer(desired_vsync)) {
return restore_previous_renderer("renderer-recreate-recover", "Failed to apply the requested VSync mode; restored the previous renderer.");
}
if (!initializeFsui()) {
return restore_previous_renderer("renderer-recreate-recover", "Failed to rebuild the UI after changing VSync; restored the previous renderer.");
}
if (had_session) {
session_.rebindRenderer(render_, settings_);
session_.setPaused(should_be_paused_after_restore);
}
ui_window_state_ = restore_window;
restoreUiWindow();
resetFramePacing("renderer-recreate");
logRendererBootstrap("renderer-recreate", currentTargetFrameRate());
return true;
}
void installCrashHandlers() {
#if defined(__ANDROID__)
// Android's debuggerd owns native fatal signals and writes the tombstone/backtrace that
// can identify the faulting thread and instruction. Replacing those handlers with
// ReportNativeCrash() ended in _Exit(1), so logcat only said "exited cleanly (1)" and
// erased the evidence needed to fix intermittent lifecycle crashes. The platform handler
// is both more complete and async-signal-safe; leave std::terminate at its default too so
// its SIGABRT reaches debuggerd.
return;
#else
static bool installed = false;
if (installed) {
return;
}
installed = true;
std::set_terminate([]() {
ReportNativeCrash("std::terminate");
});
auto signal_handler = [](int signal_value) {
switch (signal_value) {
case SIGABRT:
ReportNativeCrash("SIGABRT");
break;
case SIGSEGV:
ReportNativeCrash("SIGSEGV");
break;
case SIGILL:
ReportNativeCrash("SIGILL");
break;
case SIGFPE:
ReportNativeCrash("SIGFPE");
break;
default:
ReportNativeCrash("signal");
break;
}
};
std::signal(SIGABRT, signal_handler);
std::signal(SIGSEGV, signal_handler);
std::signal(SIGILL, signal_handler);
std::signal(SIGFPE, signal_handler);
#if defined(_WIN32) && !defined(UWP_TARGET)
SetUnhandledExceptionFilter(&WindowsUnhandledExceptionFilter);
#endif
#endif
}
double currentTargetFrameRate() const {
if (session_.valid() && !session_.paused()) {
return session_.targetFrameRate();
}
return kUiFrameRate;
}
void updateFramePeriod(double frame_rate) {
#if !defined(__EMSCRIPTEN__)
const double safe_rate = std::max(frame_rate, 1.0);
const uint64_t frequency = SDL_GetPerformanceFrequency();
const uint64_t ticks = std::max<uint64_t>(
1,
static_cast<uint64_t>(std::llround(static_cast<double>(frequency) / safe_rate))
);
if (frame_period_ticks_ != ticks) {
frame_period_ticks_ = ticks;
next_frame_deadline_ = 0;
psxe_diag_breadcrumbf(
"Frame pacing target updated fps=%.2f period_ms=%.3f",
safe_rate,
CounterTicksToMilliseconds(frame_period_ticks_)
);
}
#else
(void)frame_rate;
#endif
}
void waitForFrameDeadline(double frame_rate) {
#if !defined(__EMSCRIPTEN__)
updateFramePeriod(frame_rate);
const uint64_t frequency = SDL_GetPerformanceFrequency();
const uint64_t slack_ticks = std::max<uint64_t>(1, frequency / 2000u);
uint64_t now = SDL_GetPerformanceCounter();
// How late the loop arrived for THIS frame — the only honest measure of "the device
// cannot keep up", and what adaptive frame skip acts on. Sampled here because this is
// the one point that knows it: the resync branch below rewrites the deadline, and a
// frame that overran shows up as lateness at the START of the frame after it.
frame_lateness_ticks_ = (next_frame_deadline_ != 0 && now > next_frame_deadline_)
? (now - next_frame_deadline_)
: 0;
if (next_frame_deadline_ == 0) {
next_frame_deadline_ = now;
return;
}
if (frame_period_ticks_ != 0 && now > (next_frame_deadline_ + (frame_period_ticks_ * 2u))) {
psxe_diag_breadcrumbf(
"Frame pacing resync lateness_ms=%.3f",
CounterTicksToMilliseconds(now - next_frame_deadline_)
);
next_frame_deadline_ = now;
return;
}
while (now + slack_ticks < next_frame_deadline_) {
const double wait_ms = CounterTicksToMilliseconds(next_frame_deadline_ - now);
if (wait_ms > 1.5) {
SDL_Delay(static_cast<Uint32>(wait_ms - 1.0));
} else {
std::this_thread::yield();
}
now = SDL_GetPerformanceCounter();
}
while (now < next_frame_deadline_) {
std::this_thread::yield();
now = SDL_GetPerformanceCounter();
}
#else
(void)frame_rate;
#endif
}
void advanceFrameDeadline() {
#if !defined(__EMSCRIPTEN__)
if (frame_period_ticks_ == 0) {
return;
}
if (next_frame_deadline_ == 0) {
next_frame_deadline_ = SDL_GetPerformanceCounter();
}
next_frame_deadline_ += frame_period_ticks_;
#endif
}
void resetFramePacing(const char* reason) {
#if !defined(__EMSCRIPTEN__)
next_frame_deadline_ = 0;
frame_period_ticks_ = 0;
frame_lateness_ticks_ = 0;
if (reason && reason[0]) {
psxe_diag_breadcrumbf("Frame pacing reset reason=%s", reason);
}
#else
(void)reason;
#endif
}
// Park the skip counter so the next decision resolves to "present". The run limit is never
// above kMaxFrameSkip, so this is unconditional without having to know which mode is live.
void requestPresentNextFrame() {
frames_skipped_run_ = kMaxFrameSkip;
}
/**
* Frame skip. Should THIS frame's presentation be dropped?
*
* ⚠ Presentation only. The machine is always stepped to the next vblank by
* ArmsxSession::runFrame(), which is also where queueAudioForFrame() pulls the SPU — the SPU
* is front-end-pulled, so skipping an emulated frame would advance every voice cursor and
* ADSR envelope by the wrong amount and starve the device besides. What a skipped frame
* drops is updateTexture() (the dirty-row scan + the framebuffer upload, and on the GLES
* rasterizer the render-target readback) and draw() (the present itself). Guest timing,
* audio and save states are bit-identical either way.
*
* Two modes, because they answer different questions:
* fixed (1..kMaxFrameSkip) present one frame then skip N. Predictable, applies whether or
* not the device is struggling, and is what "frame skip" means to
* anyone arriving from another emulator. Also the battery lever.
* adaptive (-1) skip only when the previous frame overran its budget, and never
* more than kAdaptiveFrameSkipRun in a row. Costs nothing on a
* device that keeps up and is the one that actually rescues one
* that does not — which is why it is the mode the UI offers first.
*
* Deliberately NOT engaged when the pacer has no deadline to miss (frame limit off, or
* fast-forward with an unlimited multiplier): "late" is meaningless there, adaptive would
* skip every frame it is allowed to, and the result would read as a second, invisible
* limiter fighting the one the user switched off. Fixed skip still applies in those modes,
* because the user asked for a ratio rather than for a rescue.
*/
bool shouldSkipPresent(bool stepping) {
// A paused session presents the same still frame every tick and that IS the pause menu's
// backdrop; a pending screenshot needs the frame's real geometry through updateTexture().
if (!stepping || deferred_screenshot_) {
requestPresentNextFrame();
return false;
}
const int mode = session_.frameSkip();
if (mode == 0) {
requestPresentNextFrame();
return false;
}
const int max_run = (mode > 0) ? std::min(mode, kMaxFrameSkip) : kAdaptiveFrameSkipRun;
if (frames_skipped_run_ >= max_run) {
frames_skipped_run_ = 0;
return false;
}
if (mode < 0 && !runningBehindDeadline()) {
frames_skipped_run_ = 0;
return false;
}
frames_skipped_run_++;
frames_dropped_++;
return true;
}
// Was the loop late enough arriving for this frame that dropping a present is worth it?
// A quarter of a frame period: small enough to react before the stutter is audible in the
// pacing, large enough that ordinary scheduler jitter does not trip it.
bool runningBehindDeadline() const {
#if !defined(__EMSCRIPTEN__)
if (frame_period_ticks_ == 0) {
return false;
}
// No real deadline to miss — see shouldSkipPresent().
if (currentTargetFrameRate() >= ArmsxSession::uncappedFrameRate()) {
return false;
}
return frame_lateness_ticks_ > (frame_period_ticks_ / 4u);
#else
return false;
#endif
}
std::filesystem::path diagnosticsLogPath() const {
const char* live_path = psxe_diag_log_path();
if (live_path && live_path[0]) {
return std::filesystem::path(live_path);
}
return DefaultDiagnosticsLogPath();
}
void applyLoggingSettings(const char* reason) {
const bool enable_logs = settings_.logging_enabled;
settings_.quiet = !enable_logs;
if (!enable_logs && psxe_diag_is_enabled()) {
psxe_diag_logf("diag", "Debug logging disabled reason=%s", reason ? reason : "(none)");
}
psxe_diag_set_enabled(enable_logs ? 1 : 0);
log_set_level(settings_.log_level);
log_set_quiet(settings_.quiet ? 1 : 0);
if (enable_logs) {
psxe_diag_logf(
"diag",
"Debug logging enabled reason=%s level=%s path=%s",
reason ? reason : "(none)",
log_level_string(settings_.log_level),
diagnosticsLogPath().string().c_str()
);
}
}
void logRendererBootstrap(const char* phase, double frame_rate) const {
if (!render_) {
return;
}
SDL_RendererInfo info{};
if (SDL_Renderer* sdl = sdlRenderer()) {
SDL_GetRendererInfo(sdl, &info);
}
int output_width = 0;
int output_height = 0;
armsx_renderer_output_size(render_, &output_width, &output_height);
int refresh_rate = 0;
SDL_DisplayMode display_mode{};
if (window_) {
const int display_index = SDL_GetWindowDisplayIndex(window_);
if (display_index >= 0 && SDL_GetCurrentDisplayMode(display_index, &display_mode) == 0) {
refresh_rate = display_mode.refresh_rate;
}
}
const char* timing_title = session_.valid() ? session_.timingModeTitle() : "UI";
psxe_diag_logf(
"renderer",
"phase=%s source=%s driver=%s flags=%s output=%dx%d refresh_hz=%d timing=%s target_fps=%.2f frame_period_ms=%.3f requested_vsync=%s",
phase ? phase : "(none)",
external_renderer_ ? "external" : "internal",
armsx_renderer_driver_name(render_),
info.name ? RendererFlagsTitle(info.flags).c_str()
: armsx_render_backend_name(armsx_renderer_backend(render_)),
output_width,
output_height,
refresh_rate,
timing_title,
frame_rate,
frame_rate > 0.0 ? (1000.0 / frame_rate) : 0.0,
(owns_renderer_ && !external_renderer_) ? (managed_renderer_vsync_ ? "true" : "false") : "host-controlled"
);
}
void logUiRendererState(const char* stage) const {
// SDL-renderer-specific state dump; the GL/Vulkan backends have no equivalent.
SDL_Renderer* renderer_ = sdlRenderer();
if (!renderer_) {
return;
}
int window_width = 0;
int window_height = 0;
if (window_) {
SDL_GetWindowSize(window_, &window_width, &window_height);
}
int output_width = 0;
int output_height = 0;
SDL_GetRendererOutputSize(renderer_, &output_width, &output_height);
SDL_Rect viewport = {0, 0, 0, 0};
SDL_Rect clip_rect = {0, 0, 0, 0};
int logical_width = 0;
int logical_height = 0;
float scale_x = 0.0f;
float scale_y = 0.0f;
SDL_RenderGetViewport(renderer_, &viewport);
SDL_RenderGetClipRect(renderer_, &clip_rect);
SDL_RenderGetLogicalSize(renderer_, &logical_width, &logical_height);
SDL_RenderGetScale(renderer_, &scale_x, &scale_y);
const SDL_Texture* target = SDL_GetRenderTarget(renderer_);
const Uint32 window_flags = window_ ? SDL_GetWindowFlags(window_) : 0u;
const bool clip_enabled = SDL_RenderIsClipEnabled(renderer_) == SDL_TRUE;
const bool integer_scale = SDL_RenderGetIntegerScale(renderer_) == SDL_TRUE;
float imgui_display_x = 0.0f;
float imgui_display_y = 0.0f;
float imgui_scale_x = 0.0f;
float imgui_scale_y = 0.0f;
float imgui_font_scale = 0.0f;
int imgui_backend_flags = 0;
int imgui_config_flags = 0;
psxe_diag_logf(
"ui",
"%s window=%dx%d window_flags=0x%x output=%dx%d target=%p viewport=%d,%d %dx%d clip=%d,%d %dx%d scale=(%f,%f) logical=%dx%d clip_enabled=%s integer_scale=%s imgui_display=(%.1f,%.1f) imgui_fb_scale=(%.2f,%.2f) imgui_font_scale=%.3f backend_flags=0x%x config_flags=0x%x session_hw=%s texture=%dx%d format=%s",
stage ? stage : "(state)",
window_width,
window_height,
window_flags,
output_width,
output_height,
(const void*)target,
viewport.x,
viewport.y,
viewport.w,
viewport.h,
clip_rect.x,
clip_rect.y,
clip_rect.w,
clip_rect.h,
scale_x,
scale_y,
logical_width,
logical_height,
clip_enabled ? "true" : "false",
integer_scale ? "true" : "false",
imgui_display_x,
imgui_display_y,
imgui_scale_x,
imgui_scale_y,
imgui_font_scale,
imgui_backend_flags,
imgui_config_flags,
#ifdef USE_HARDWARE
session_.valid() ? (session_.hardwareBackendActive() ? "true" : "false") : "false",
session_.valid() ? session_.textureWidth() : 0,
session_.valid() ? session_.textureHeight() : 0,
session_.valid() ? SDL_GetPixelFormatName(session_.textureFormat()) : "(none)"
#else
"false",
0,
0,
"(none)"
#endif
);
}
void logCpuState() const {
if (!session_.valid() || !session_.psx() || !session_.psx()->cpu) {
psxe_diag_logf("crash", "CPU state unavailable.");
return;
}
const psx_cpu_t* cpu = session_.psx()->cpu;
psxe_diag_logf("crash", "r0=%08x at=%08x v0=%08x v1=%08x", cpu->r[0], cpu->r[1], cpu->r[2], cpu->r[3]);
psxe_diag_logf("crash", "a0=%08x a1=%08x a2=%08x a3=%08x", cpu->r[4], cpu->r[5], cpu->r[6], cpu->r[7]);
psxe_diag_logf("crash", "t0=%08x t1=%08x t2=%08x t3=%08x", cpu->r[8], cpu->r[9], cpu->r[10], cpu->r[11]);
psxe_diag_logf("crash", "t4=%08x t5=%08x t6=%08x t7=%08x", cpu->r[12], cpu->r[13], cpu->r[14], cpu->r[15]);
psxe_diag_logf("crash", "s0=%08x s1=%08x s2=%08x s3=%08x", cpu->r[16], cpu->r[17], cpu->r[18], cpu->r[19]);
psxe_diag_logf("crash", "s4=%08x s5=%08x s6=%08x s7=%08x", cpu->r[20], cpu->r[21], cpu->r[22], cpu->r[23]);
psxe_diag_logf("crash", "t8=%08x t9=%08x k0=%08x k1=%08x", cpu->r[24], cpu->r[25], cpu->r[26], cpu->r[27]);
psxe_diag_logf("crash", "gp=%08x sp=%08x fp=%08x ra=%08x", cpu->r[28], cpu->r[29], cpu->r[30], cpu->r[31]);
psxe_diag_logf(
"crash",
"pc=%08x next=%08x saved=%08x hi=%08x lo=%08x epc=%08x opcode=%08x",
cpu->pc,
cpu->next_pc,
cpu->saved_pc,
cpu->hi,
cpu->lo,
cpu->cop0_r[COP0_EPC],
cpu->opcode
);
}
// [audio] driver, applied just before SDL_INIT_AUDIO — the only moment SDL reads
// SDL_AUDIODRIVER. Android only; every other platform is left with SDL's own probe order,
// which is what it has always used.
//
// On Android the choice is genuinely dangerous, not merely a preference: SDL's "android"
// and "aaudio" backends reach org.libsdl.app.SDLAudioManager's static JNI glue, and with
// Compose owning the Activity there is no SDLActivity to have set it up. openslES is the
// one backend that stays entirely inside the native OpenSL ES API, which is why
// frontend/android_jni.cpp forces it before anything else runs. That host also registers
// the glue itself and hands SDLAudioManager a Context; only once it reports that it
// managed both is aaudio (whose SDL_AudioInit unconditionally enumerates devices through
// that Context) safe to select. "default" hands the choice back to SDL's probe order,
// which starts with the "android" backend and is therefore gated the same way.
void applyAudioDriverSetting() {
#if defined(__ANDROID__)
const bool embedded = []() {
std::lock_guard<std::mutex> lock(g_host_control_lock);
return g_host_embedded;
}();
if (!embedded) {
return;
}
int driver = settings_.audio_driver;
if (driver != 1 && !g_host_audio_backends_ready.load(std::memory_order_acquire)) {
ARMSX_BOOTLOG("core: audio driver '%s' needs host JNI audio glue that is not ready; using openslES",
AudioDriverToString(driver));
driver = 1;
}
switch (driver) {
case 0:
unsetenv("SDL_AUDIODRIVER");
break;
case 2:
setenv("SDL_AUDIODRIVER", "aaudio", 1);
break;
default:
setenv("SDL_AUDIODRIVER", "openslES", 1);
break;
}
#endif
}
bool initializeSdl() {
Uint32 required = SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_EVENTS | SDL_INIT_GAMECONTROLLER;
// Embedded hosts own input (Compose touch overlay + Android InputDevice routing) and
// there is no org.libsdl.app activity backing SDL's Android joystick JNI glue, so
// asking for SDL_INIT_GAMECONTROLLER there would call into a null activity class.
{
std::lock_guard<std::mutex> lock(g_host_control_lock);
if (g_host_embedded) {
required &= ~static_cast<Uint32>(SDL_INIT_GAMECONTROLLER);
}
}
psxe_diag_breadcrumbf("Initializing SDL subsystems flags=0x%x", required);
// Audio is not worth failing the whole boot over: a silent game still beats a game that
// never appears. Bring the mandatory subsystems up first, then try audio on its own.
const Uint32 mandatory = required & ~static_cast<Uint32>(SDL_INIT_AUDIO);
const bool wants_audio = (required & SDL_INIT_AUDIO) != 0;
if (!sdl_subsystems_.acquire(mandatory)) {
ARMSX_BOOTERR("core: SDL_InitSubSystem(0x%x) failed: %s", mandatory, SDL_GetError());
psxe_diag_logf("sdl", "SDL_InitSubSystem failed: %s", SDL_GetError());
return false;
}
if (wants_audio) {
applyAudioDriverSetting();
}
if (wants_audio && !sdl_subsystems_.acquire(SDL_INIT_AUDIO)) {
ARMSX_BOOTERR("core: audio init failed (continuing silently): %s", SDL_GetError());
psxe_diag_logf("sdl", "Audio init failed (continuing without audio): %s", SDL_GetError());
} else if (wants_audio) {
ARMSX_BOOTLOG("core: audio driver=%s",
SDL_GetCurrentAudioDriver() ? SDL_GetCurrentAudioDriver() : "(none)");
}
if (argc_ > 1) {
for (int index = 1; index < argc_; index++) {
const std::string_view arg(argv_[index] ? argv_[index] : "");
auto set_boot_argument = [&](std::string_view value, std::optional<LaunchKind> forced_kind = std::nullopt) {
pending_cli_argument_ = std::string(value);
pending_cli_launch_ = LaunchForArgument(value, forced_kind);
};
if (arg == "--cdrom") {
if ((index + 1) < argc_) {
set_boot_argument(argv_[index + 1] ? argv_[index + 1] : "", LaunchKind::Disc);
index++;
}
} else if (arg == "-x" || arg == "--exe") {
if ((index + 1) < argc_) {
set_boot_argument(argv_[index + 1] ? argv_[index + 1] : "", LaunchKind::Exe);
index++;
}
} else if (arg.starts_with("--cdrom=")) {
set_boot_argument(arg.substr(8), LaunchKind::Disc);
} else if (arg.starts_with("--exe=")) {
set_boot_argument(arg.substr(6), LaunchKind::Exe);
} else if (!arg.empty() && arg[0] != '-') {
set_boot_argument(arg);
}
}
}
return true;
}
bool initializeWindowAndRenderer() {
if (external_window_) {
window_ = external_window_;
} else {
int window_width = 1280;
int window_height = 720;
#if defined(__EMSCRIPTEN__)
double css_width = 0.0;
double css_height = 0.0;
if (emscripten_get_element_css_size("#canvas", &css_width, &css_height) == EMSCRIPTEN_RESULT_SUCCESS &&
css_width > 0.0 && css_height > 0.0) {
window_width = std::max(1, static_cast<int>(std::lround(css_width)));
window_height = std::max(1, static_cast<int>(std::lround(css_height)));
}
#endif
Uint32 flags = 0;
if (SupportsManagedWindowSizing()) {
flags |= SDL_WINDOW_RESIZABLE;
}
// A GL/Vulkan drawable has to be requested at window-creation time, and the GL
// attributes (ES 3.0 profile) have to be set before SDL_CreateWindow().
#ifdef USE_HARDWARE
const armsx_render_backend_t wanted = RenderBackendFor(settings_.gpu_backend);
#else
const armsx_render_backend_t wanted = ARMSX_RENDER_BACKEND_SDL_SOFTWARE;
#endif
armsx_render_prepare_window_attributes(wanted);
flags |= armsx_render_window_flags(wanted);
window_ = SDL_CreateWindow(
"ARMSX",
SDL_WINDOWPOS_CENTERED,
SDL_WINDOWPOS_CENTERED,
window_width,
window_height,
flags
);
if (!window_ && (flags & (SDL_WINDOW_OPENGL | SDL_WINDOW_VULKAN))) {
// The GPU drawable was refused; retry as a plain window so the SDL fallback
// ladder in createManagedRenderer() still has something to attach to.
psxe_diag_logf("renderer",
"Window with a %s drawable failed (%s); retrying without it.",
armsx_render_backend_name(wanted), SDL_GetError());
flags &= ~(Uint32)(SDL_WINDOW_OPENGL | SDL_WINDOW_VULKAN);
window_ = SDL_CreateWindow(
"ARMSX",
SDL_WINDOWPOS_CENTERED,
SDL_WINDOWPOS_CENTERED,
window_width,
window_height,
flags
);
}
owns_window_ = window_ != nullptr;
}
if (!window_) {
psxe_diag_logf("renderer", "Window initialization failed: %s", SDL_GetError());
return false;
}
render_ = nullptr;
#ifdef USE_HARDWARE
// An embedder-supplied SDL_Renderer is normally adopted as-is. The exception is the
// Android in-process host: it hands us a *software* renderer over an SDL_Surface that
// it then CPU-blits into its ANativeWindow. When the user asked for a GPU backend and
// that ANativeWindow was registered (armsx_render_set_native_window), we bind to the
// surface directly instead and the host's blit bridge stands down.
const bool prefer_native_gpu = external_renderer_ && armsx_render_native_window() &&
(settings_.gpu_backend == GpuBackend::OpenGL ||
settings_.gpu_backend == GpuBackend::Vulkan);
#else
const bool prefer_native_gpu = false;
#endif
if (prefer_native_gpu) {
if (!createManagedRenderer(settings_.vsync_enabled)) {
psxe_diag_logf("renderer", "GPU backend on the host surface failed; adopting the host renderer.");
} else if (!armsx_render_native_window_claimed()) {
// The ladder fell all the way back to an SDL backend on our own window, which
// is not what the host surface expects. Drop it and adopt the host renderer.
armsx_renderer_destroy(render_);
render_ = nullptr;
owns_renderer_ = false;
}
}
if (!render_ && external_renderer_) {
render_ = armsx_renderer_create_from_sdl(external_renderer_);
owns_renderer_ = false;
} else if (!render_) {
if (!createManagedRenderer(settings_.vsync_enabled)) {
return false;
}
}
if (!render_) {
psxe_diag_logf("renderer", "Renderer initialization failed: %s", SDL_GetError());
}
return render_ != nullptr;
}
bool initializeFsui() {
return true;
}
void shutdown() {
psxe_diag_breadcrumbf("Frontend shutdown");
// Close the ADPF session while still on the thread that owns it. The session is bound
// to this tid, and the next run in the same process (the Android host keeps the
// library loaded between games) gets a different one.
armsx_perf_hint_shutdown();
input_router_.detach();
// Drop the psx_t RetroAchievements reads memory through before it is freed. Covers the
// early-failure paths that reach shutdown() without ever entering the main loop.
armsx_ach_session_ended();
session_.destroy();
shutdownFsuiFrontend();
// Adopted renderers (armsx_renderer_create_from_sdl) never own the embedder's
// SDL_Renderer, so this is safe for both the managed and the external case.
if (render_) {
armsx_renderer_destroy(render_);
}
if (owns_window_ && window_) {
SDL_DestroyWindow(window_);
}
render_ = nullptr;
window_ = nullptr;
sdl_subsystems_.release();
psxe_diag_shutdown();
}
void handleEvent(const SDL_Event& event) {
if (event.type == SDL_QUIT) {
running_ = false;
return;
}
if (event.type == SDL_DROPFILE) {
if (event.drop.file) {
queueLaunchArgument(event.drop.file, true);
SDL_free(event.drop.file);
}
return;
}
if (event.type == SDL_DROPCOMPLETE) {
return;
}
// FSUI removed: input is never gated by a native menu window (Compose owns menus).
const bool fsui_active = false;
input_router_.processEvent(event, session_.valid() ? &session_ : nullptr, fsui_active);
if (event.type == SDL_KEYDOWN && session_.valid() && event.key.repeat == 0 && event.key.keysym.sym == SDLK_RETURN) {
psx_exp2_atcons_put(session_.psx()->exp2, 13);
}
// A pause request is consumed but no longer opens a native menu; the Compose overlay owns
// pause via JNI. Draining it keeps the router state clean.
if (session_.valid()) {
(void)input_router_.takePauseRequest();
}
}
void runFrame() {
// Affinity Control Mode. Here rather than once at startup because sched_setaffinity()
// acts on the CALLING thread and this is the emulation thread, and because polling an
// atomic per frame is what turns a boot-only setting into a live one. Costs one relaxed
// load while the mode is unchanged, which is every frame in the default (off) case.
armsx_affinity_apply_emulation_thread();
/* Runahead: put the machine back where the timeline really is, BEFORE anything
applies this frame's input. A snapshot carries the pad's button word, so a restore
that happened after an input pass would swallow the press outright —
applyHostPadCommands only ever sends the EDGE, so it would never be re-sent. No-op
unless [emulation] runahead is on.
Deliberately NOT gated on !paused(): the pause menu is exactly where a save state
gets taken, and a save taken while a look-ahead was still outstanding would record
the FUTURE rather than the timeline the player is on. */
if (session_.valid()) {
session_.runaheadRestore();
}
input_router_.tick(false);
if (deferred_vsync_.has_value()) {
const bool desired_vsync = *deferred_vsync_;
deferred_vsync_.reset();
if (owns_renderer_ && !external_renderer_ && desired_vsync != managed_renderer_vsync_) {
recreateManagedRenderer(desired_vsync);
if (!running_) {
return;
}
}
}
consumePendingLaunchArguments();
applyHostControlRequests();
// Backstop for a background→foreground reopen that failed. Deliberately AFTER the
// request drain so a resume that just landed gets its device on this same frame.
if (session_.valid()) {
session_.ensureAudioDevice();
}
// Save/load-state requests are parked by the JNI thread and drained by
// psx_state_service_requests() from inside psx_update(). psx_update() does not run while
// the session is paused — and the pause menu is exactly where Save/Load State is pressed.
// Drain here too, on this same (emulation) thread, or every state op taken from the menu
// would sit parked until it timed out.
if (session_.valid() && session_.paused()) {
psx_state_service_requests();
}
waitForFrameDeadline(currentTargetFrameRate());
// `touch files/logs/perf_log` sends the overlay's own snapshot to the diag log as well
// as to the UI. The numbers otherwise exist only inside Compose, which makes every
// measurement a screenshot read by eye — and the backend records a
// sweep that produced six screenshots OF THE LIBRARY because of exactly that. With the
// marker present two runs are matched on the primitive counters as numbers. Re-armed
// rather than armed once, so a UI that switches the overlay off cannot silence it.
if (perfLogEnabled() && !g_host_stats_enabled.load(std::memory_order_relaxed)) {
g_host_stats_enabled.store(true, std::memory_order_relaxed);
psx_perf_set_enabled(1);
}
// Phase timing for the overlay. Gated, not just unread: with the overlay off these
// three clock reads never happen and psx_perf's counters are not armed either.
const bool stats_enabled = g_host_stats_enabled.load(std::memory_order_relaxed);
const uint64_t phase_frame_begin = stats_enabled ? SDL_GetPerformanceCounter() : 0;
std::uint32_t session_steps = 0;
const bool session_stepping = session_.valid() && !session_.paused();
// Frame skip ([runtime] frame_skip). Decided BEFORE the step, because the work it drops
// starts at updateTexture(): the dirty-row scan, the framebuffer upload and — on the
// GLES rasterizer — the render-target readback, all of which are presentation, not
// emulation. runFrame() itself always runs; see shouldSkipPresent() for why.
const bool skip_present = shouldSkipPresent(session_stepping);
// ADPF work bracket opens HERE — after waitForFrameDeadline() has already slept off the
// limiter's share of the frame. What ADPF must be told is how long the emulation thread
// WORKED, and a frame's wall clock is work + sleep: report that and every frame looks
// like it consumed exactly its budget, which is a constant, not a signal. This span is
// the same one the perf overlay prints as `emu`.
//
// A non-stepping tick (paused, no disc, menu open) is not a frame at all; pausing the
// bracket keeps the pause out of the next report. Default off: one atomic load.
if (session_stepping) {
armsx_perf_hint_frame_begin(currentTargetFrameRate());
} else {
armsx_perf_hint_pause();
}
if (session_stepping) {
session_steps = session_.runFrame();
if (!skip_present) {
session_.updateTexture(settings_);
}
// Always: begin_frame ran inside runFrame() and the GPU rasterizer's frame has to be
// closed whether or not anyone is going to look at it.
session_.finishHardwareFrame();
}
(void)session_steps;
// ...and closes BEFORE the present. The present is real work, but on this port it runs
// on this same thread and can block in the buffer queue waiting on the display — idle
// time that would land in the report wearing work's clothes. A GPU-bound game therefore
// reports a short work duration and gets no CPU boost, which is the right answer: its
// CPU is not the thing that is short.
armsx_perf_hint_frame_end();
const uint64_t phase_emu_end = stats_enabled ? SDL_GetPerformanceCounter() : 0;
// RetroAchievements pump. Unconditional: it also drives the saved-login restore and the
// deferred disc identification, neither of which needs a stepping session.
armsx_ach_frame_update(session_stepping);
publishSessionTelemetry(session_stepping);
#if defined(USE_HARDWARE) && defined(HW_DEBUG)
if (session_.valid() && session_.hardwareBackendActive()) {
logUiRendererState("ui-pre-present");
psxe_diag_logf(
"hw",
"frame-present frame=%llu steps=%u paused=%s fast_forward=%s texture=%dx%d format=%s target_fps=%.3f",
static_cast<unsigned long long>(session_.vblankCounter()),
session_steps,
session_.paused() ? "true" : "false",
session_.fastForwardEnabled() ? "true" : "false",
session_.textureWidth(),
session_.textureHeight(),
SDL_GetPixelFormatName(session_.textureFormat()),
session_.targetFrameRate()
);
}
#endif
if (pending_error_dialog_.has_value()) {
psxe_diag_logf("ui", "error: %s", pending_error_dialog_->c_str());
pending_error_dialog_.reset();
}
// Present the emulated frame through the active backend. No imgui/FSUI pass — the
// Jetpack Compose front-end owns every menu/overlay.
//
// ...unless the app is off-screen. Drawing to a surface nobody can see burns real
// battery: with the VM parked by the background pause, the loop was still measured
// accumulating ~6.5 jiffies/s of system time purely uploading and posting frames to
// an invisible window. Skipping the draw is safe because there is no observer to go
// stale for — and the first frame after resume repaints it anyway.
//
// Deliberately its own flag, not `paused_` and not `audio_suspended_`: the pause
// menu is on-screen and MUST keep presenting, and [audio] background_playback can
// legitimately keep audio alive while the screen is off.
const bool presentation_suspended =
g_host_presentation_suspended.load(std::memory_order_acquire);
// Coming back on-screen: arm the resume probe for the next few uploads. Deliberately
// counted in UPLOADS rather than seconds — a session that comes back paused does not
// upload until the user resumes it, which is exactly the frame worth sampling.
if (g_presentation_was_suspended && !presentation_suspended) {
if (resumeProbeEnabled()) {
g_resume_probe_frames = 8;
}
psxe_diag_logf("renderer", "presentation resumed (window generation %lu)",
armsx_render_native_window_generation());
} else if (!g_presentation_was_suspended && presentation_suspended) {
psxe_diag_logf("renderer", "presentation suspended (window generation %lu)",
armsx_render_native_window_generation());
}
g_presentation_was_suspended = presentation_suspended;
// ...or unless frame skip dropped this one. Same shape as the suspend above and for the
// same reason, but a different question: suspended means "nobody can see it", skipped
// means "the device cannot afford to draw every one". Note the ORDER — a suspended frame
// still has to hit the pacing wait below, so skip cannot short-circuit it.
if (presentation_suspended) {
// Nothing to post, but the loop still has to pace itself or it becomes a spin.
waitForFrameDeadline(currentTargetFrameRate());
} else if (skip_present) {
// Nothing at all: no upload happened, nothing is posted, and the surface keeps the
// last frame that was. Deliberately NO extra pacing wait — the saved time is the
// whole point, and advanceFrameDeadline() below keeps the emulated cadence exact.
} else if (session_.valid()) {
session_.draw(settings_);
} else if (render_) {
// No session: still hand the host a (black) frame every tick so an embedded
// surface keeps getting posted instead of freezing on stale contents.
armsx_renderer_present_blank(render_);
}
g_host_vm_active.store(session_.valid(), std::memory_order_release);
// Only count frames that were actually posted. The counter is what the host polls to
// decide the VM is alive and advancing (the auto-load-on-boot gate reads it), so
// ticking it while nothing is being drawn would report progress that is not happening.
// A skipped frame therefore does NOT tick it — which is also what makes the counter the
// instrument that proves frame skip is really skipping.
if (session_.valid() && !presentation_suspended && !skip_present) {
g_host_presented_frames.fetch_add(1u, std::memory_order_relaxed);
}
// When a GPU backend owns the host's ANativeWindow it has already posted the frame
// (eglSwapBuffers / vkQueuePresentKHR). Running the host's CPU blit bridge on top of
// that would fight the buffer queue for the same surface.
if (!presentation_suspended && !skip_present && !armsx_render_native_window_claimed()) {
HostNotifyFramePresented();
}
publishFrameSkipTrace(session_stepping);
publishFrameStats(stats_enabled, session_stepping, phase_frame_begin, phase_emu_end);
applyDeferredActions();
advanceFrameDeadline();
}
// Frame skip's own measurement, in the diag log rather than the OSD.
//
// "It looks smoother" is not evidence, and the OSD's FPS row deliberately keeps counting
// EMULATED frames (that is what "is the game running at full speed" means), so nothing on
// screen would show whether a single present was actually dropped. This prints the ratio
// roughly every four seconds while skipping is armed: emulated frames in the window against
// the ones that got posted, plus the pacing lateness adaptive mode is reacting to.
//
// Costs nothing while frame_skip = 0 — the counters only move when a frame is skipped, and
// psxe_diag_logf() is itself suppressed unless [runtime] logging_enabled is on.
void publishFrameSkipTrace(bool stepping) {
if (!stepping) {
frame_skip_trace_frames_ = 0;
frames_dropped_ = 0;
return;
}
if (session_.frameSkip() == 0) {
frame_skip_trace_frames_ = 0;
frames_dropped_ = 0;
return;
}
if (++frame_skip_trace_frames_ < kFrameSkipTraceFrames) {
return;
}
psxe_diag_logf("timing",
"frame skip mode=%d window_frames=%u dropped=%u presented_total=%u "
"late_ms=%.2f target_fps=%.2f",
session_.frameSkip(),
frame_skip_trace_frames_,
frames_dropped_,
g_host_presented_frames.load(std::memory_order_relaxed),
CounterTicksToMilliseconds(frame_lateness_ticks_),
currentTargetFrameRate());
frame_skip_trace_frames_ = 0;
frames_dropped_ = 0;
}
// Full performance overlay: drain the core's per-frame work counters, add the host phase
// timing measured around them, and publish the pair as one snapshot for psxe_host_stats().
//
// Accumulated over the same half-second window the frame rate uses and divided by the frames
// in it, so every number the overlay shows is a per-frame average rather than whichever
// single frame happened to be current when the UI polled. WORST_FRAME_MS is the exception —
// averaging a spike away is precisely the thing that hides a stutter.
//
// Nothing here runs while the overlay is off: psx_perf's counters are not armed, so the
// whole core carries zero instrumentation, and the phase timers in runFrame() are skipped.
void publishFrameStats(bool enabled, bool stepped, uint64_t frame_begin, uint64_t emu_end) {
if (!enabled) {
if (stats_window_frames_) {
stats_window_frames_ = 0;
stats_window_start_ = 0;
stats_worst_frame_ticks_ = 0;
stats_accum_ = {};
stats_prev_frame_begin_ = 0;
}
return;
}
psx_perf_counters_t counters{};
psx_perf_take_frame(&counters);
if (!stepped) {
// A paused or closed session did no guest work; keep the last published snapshot
// rather than averaging zeroes into it, and restart the window on resume.
stats_prev_frame_begin_ = 0;
return;
}
const uint64_t now = SDL_GetPerformanceCounter();
const uint64_t frequency = std::max<uint64_t>(1, SDL_GetPerformanceFrequency());
// The frame's own wall clock: begin-to-begin, so it includes the limiter sleep that
// precedes the emulation step. The first frame of a window has no predecessor.
const uint64_t frame_ticks = stats_prev_frame_begin_ ? (frame_begin - stats_prev_frame_begin_) : 0;
stats_prev_frame_begin_ = frame_begin;
stats_accum_.emu_ticks += emu_end - frame_begin;
stats_accum_.present_ticks += now - emu_end;
stats_accum_.frame_ticks += frame_ticks;
stats_accum_.counters.cpu_instructions += counters.cpu_instructions;
stats_accum_.counters.cpu_cycles += counters.cpu_cycles;
stats_accum_.counters.gte_ops += counters.gte_ops;
for (int i = 0; i < PSX_PERF_PRIM_KINDS; ++i)
stats_accum_.counters.gpu_primitives[i] += counters.gpu_primitives[i];
stats_accum_.counters.gpu_raster_pixels += counters.gpu_raster_pixels;
stats_accum_.counters.gpu_vram_words += counters.gpu_vram_words;
stats_accum_.counters.spu_samples += counters.spu_samples;
stats_accum_.counters.spu_voice_samples += counters.spu_voice_samples;
stats_accum_.counters.mdec_macroblocks += counters.mdec_macroblocks;
stats_accum_.counters.mdec_blocks += counters.mdec_blocks;
stats_accum_.counters.cdrom_sectors += counters.cdrom_sectors;
for (int i = 0; i < PSX_PERF_DMA_CHANNELS; ++i)
stats_accum_.counters.dma_words[i] += counters.dma_words[i];
if (frame_ticks > stats_worst_frame_ticks_)
stats_worst_frame_ticks_ = frame_ticks;
++stats_window_frames_;
if (stats_window_start_ == 0) {
stats_window_start_ = now;
return;
}
if ((now - stats_window_start_) < (frequency / 2u))
return;
const double frames = static_cast<double>(stats_window_frames_);
const double ms_per_tick = 1000.0 / static_cast<double>(frequency);
const auto per_frame = [frames](uint64_t total) {
return static_cast<double>(total) / frames;
};
double out[PSXE_HOST_STAT_COUNT] = {};
const double frame_ms = per_frame(stats_accum_.frame_ticks) * ms_per_tick;
const double emu_ms = per_frame(stats_accum_.emu_ticks) * ms_per_tick;
const double present_ms = per_frame(stats_accum_.present_ticks) * ms_per_tick;
out[PSXE_HOST_STAT_FRAME_MS] = frame_ms;
out[PSXE_HOST_STAT_EMU_MS] = emu_ms;
out[PSXE_HOST_STAT_PRESENT_MS] = present_ms;
out[PSXE_HOST_STAT_IDLE_MS] = std::max(0.0, frame_ms - emu_ms - present_ms);
out[PSXE_HOST_STAT_WORST_FRAME_MS] = static_cast<double>(stats_worst_frame_ticks_) * ms_per_tick;
const psx_perf_counters_t& c = stats_accum_.counters;
out[PSXE_HOST_STAT_CPU_INSTRUCTIONS] = per_frame(c.cpu_instructions);
out[PSXE_HOST_STAT_CPU_CYCLES] = per_frame(c.cpu_cycles);
out[PSXE_HOST_STAT_GTE_OPS] = per_frame(c.gte_ops);
out[PSXE_HOST_STAT_GPU_TRIANGLES] = per_frame(c.gpu_primitives[PSX_PERF_PRIM_TRIANGLE]);
out[PSXE_HOST_STAT_GPU_RECTS] = per_frame(c.gpu_primitives[PSX_PERF_PRIM_RECT]);
out[PSXE_HOST_STAT_GPU_LINES] = per_frame(c.gpu_primitives[PSX_PERF_PRIM_LINE]);
out[PSXE_HOST_STAT_GPU_PIXELS] = per_frame(c.gpu_raster_pixels);
out[PSXE_HOST_STAT_GPU_VRAM_WORDS] = per_frame(c.gpu_vram_words);
// Voices are per generated SAMPLE, so this divides by samples, not frames: the result
// is how many of the 24 voices were sounding on average, which is what "SPU load" means.
out[PSXE_HOST_STAT_SPU_VOICES] = c.spu_samples
? (static_cast<double>(c.spu_voice_samples) / static_cast<double>(c.spu_samples))
: 0.0;
out[PSXE_HOST_STAT_MDEC_MACROBLOCKS] = per_frame(c.mdec_macroblocks);
out[PSXE_HOST_STAT_MDEC_BLOCKS] = per_frame(c.mdec_blocks);
out[PSXE_HOST_STAT_CDROM_SECTORS] = per_frame(c.cdrom_sectors);
uint64_t dma_total = 0;
for (int i = 0; i < PSX_PERF_DMA_CHANNELS; ++i)
dma_total += c.dma_words[i];
out[PSXE_HOST_STAT_DMA_WORDS] = per_frame(dma_total);
out[PSXE_HOST_STAT_DMA_GPU_WORDS] = per_frame(c.dma_words[PSX_PERF_DMA_GPU]);
out[PSXE_HOST_STAT_DMA_SPU_WORDS] = per_frame(c.dma_words[PSX_PERF_DMA_SPU]);
out[PSXE_HOST_STAT_DMA_MDEC_WORDS] =
per_frame(c.dma_words[PSX_PERF_DMA_MDEC_IN] + c.dma_words[PSX_PERF_DMA_MDEC_OUT]);
out[PSXE_HOST_STAT_DMA_CDROM_WORDS] = per_frame(c.dma_words[PSX_PERF_DMA_CDROM]);
out[PSXE_HOST_STAT_DMA_OTC_WORDS] = per_frame(c.dma_words[PSX_PERF_DMA_OTC]);
psx_t* machine = session_.psx();
if (machine) {
out[PSXE_HOST_STAT_WIDTH] = static_cast<double>(psx_get_display_width(machine));
out[PSXE_HOST_STAT_HEIGHT] = static_cast<double>(psx_get_display_height(machine));
}
/* Host device usage. Sampled here rather than on the UI side because the poll is
rate-limited internally and this is already the once-per-window publish point. The
unavailable sentinel is negative and is passed through untouched — see host_usage.h
on why a missing GPU counter must not be substituted with something plausible. */
{
armsx_host_usage_t usage{};
armsx_host_usage_sample(&usage);
out[PSXE_HOST_STAT_HOST_CPU_PERCENT] = usage.cpu_percent;
out[PSXE_HOST_STAT_HOST_CPU_CORES] = static_cast<double>(usage.cpu_cores);
out[PSXE_HOST_STAT_HOST_RAM_MB] = usage.ram_mb;
out[PSXE_HOST_STAT_HOST_RAM_AVAILABLE_MB] = usage.ram_available_mb;
out[PSXE_HOST_STAT_HOST_GPU_PERCENT] = usage.gpu_percent;
}
/* Read from the GPU rather than from settings.internal_scale: the requested scale and
the live one differ whenever the rasterizer fell back (no GL context, or the
downgrade fired). The OSD must show what is running, not what was asked for. */
{
psx_gpu_t* scale_gpu = machine ? psx_get_gpu(machine) : nullptr;
out[PSXE_HOST_STAT_INTERNAL_SCALE] =
scale_gpu ? (double)psx_gpu_resolution_scale(scale_gpu) : 1.0;
}
{
std::lock_guard<std::mutex> lock(g_host_stats_lock);
std::memcpy(g_host_stats, out, sizeof(out));
g_host_stats_published = true;
}
if (perfLogEnabled()) {
psxe_diag_logf(
"perf",
"fps=%.1f frame=%.2f emu=%.2f present=%.2f idle=%.2f worst=%.2f "
"tri=%.0f rect=%.0f line=%.0f px=%.0f vramwords=%.0f disp=%.0fx%.0f frames=%u",
frame_ms > 0.0 ? (1000.0 / frame_ms) : 0.0, frame_ms, emu_ms, present_ms,
out[PSXE_HOST_STAT_IDLE_MS], out[PSXE_HOST_STAT_WORST_FRAME_MS],
out[PSXE_HOST_STAT_GPU_TRIANGLES], out[PSXE_HOST_STAT_GPU_RECTS],
out[PSXE_HOST_STAT_GPU_LINES], out[PSXE_HOST_STAT_GPU_PIXELS],
out[PSXE_HOST_STAT_GPU_VRAM_WORDS], out[PSXE_HOST_STAT_WIDTH],
out[PSXE_HOST_STAT_HEIGHT], stats_window_frames_);
}
stats_window_start_ = now;
stats_window_frames_ = 0;
stats_worst_frame_ticks_ = 0;
stats_accum_ = {};
}
// Publish what the emulation loop knows about itself for an embedded host to poll
// (psxe_host_measured_fps / psxe_host_nominal_frame_rate). The Android front-end draws its
// OSD in Compose, so this is the only route those numbers have out of the core.
//
// The measured rate is averaged over a window rather than derived from the last frame: a
// per-frame reciprocal jitters far too much to read, and this is a number a human stares at.
// Everything resets the moment the session stops stepping (paused, closed, or between games)
// so a resumed game can never briefly show the rate it had before the pause.
void publishSessionTelemetry(bool stepped) {
if (!stepped) {
fps_window_start_ = 0;
fps_window_frames_ = 0;
g_host_measured_fps.store(0.0f, std::memory_order_relaxed);
g_host_nominal_fps.store(
session_.valid() ? static_cast<float>(session_.frameRate()) : 0.0f,
std::memory_order_relaxed);
return;
}
g_host_nominal_fps.store(static_cast<float>(session_.frameRate()), std::memory_order_relaxed);
const uint64_t now = SDL_GetPerformanceCounter();
if (fps_window_start_ == 0) {
fps_window_start_ = now;
fps_window_frames_ = 0;
return;
}
++fps_window_frames_;
const uint64_t frequency = std::max<uint64_t>(1, SDL_GetPerformanceFrequency());
const uint64_t elapsed = now - fps_window_start_;
if (elapsed < (frequency / 2u)) {
return; // Half-second window: settled enough to read, quick enough to react.
}
const double seconds = static_cast<double>(elapsed) / static_cast<double>(frequency);
g_host_measured_fps.store(
static_cast<float>(static_cast<double>(fps_window_frames_) / seconds),
std::memory_order_relaxed);
fps_window_start_ = now;
fps_window_frames_ = 0;
}
// Fast-forward has to escape the DISPLAY's refresh rate, not just the frame-pacing deadline.
// targetFrameRate() doubles the deadline, but with vsync on the present blocks anyway
// (eglSwapInterval(1) / VK_PRESENT_MODE_FIFO), so on a 60 Hz panel a ~59.94 fps game
// fast-forwards to ~60 fps — i.e. not at all, which is exactly how it reads on a phone. The
// swap interval is therefore dropped for the duration and restored from the user's setting
// afterwards. Only ever touched on a fast-forward edge, so a normal session keeps whatever
// the renderer was built with. Safe on every backend: GL sets the swap interval live, Vulkan
// just invalidates the swapchain so the next present rebuilds it with the new present mode,
// and the SDL blit-bridge backend implements no set_vsync at all (a no-op).
void applyPresentVsync(bool fast_forward) {
if (!render_ || fast_forward_vsync_dropped_ == fast_forward) {
return;
}
fast_forward_vsync_dropped_ = fast_forward;
armsx_renderer_set_vsync(render_, settings_.vsync_enabled && !fast_forward);
psxe_diag_breadcrumbf("Present vsync %s for fast-forward",
fast_forward ? "dropped" : "restored");
}
// Drain everything the embedded host parked since the last frame. Runs on the
// emulation thread, so it is the only place that touches the psx_t / SDL state.
void applyHostControlRequests() {
std::vector<HostPadCommand> pad_commands;
bool pause_pending = false;
bool pause_value = false;
bool audio_suspend_pending = false;
bool audio_suspend_value = false;
bool shutdown_pending = false;
bool reset_pending = false;
bool fast_forward_pending = false;
bool fast_forward_value = false;
bool speed_limits_pending = false;
bool speed_limit_frame_limit = true;
int speed_limit_percent = 100;
int speed_limit_fps = 0;
double speed_limit_fast_forward = 2.0;
int speed_limit_frame_skip = 0;
bool screenshot_pending = false;
bool disc_swap_pending = false;
std::string disc_swap_path;
std::string screenshot_path;
bool multitap_pending = false;
bool multitap_value = false;
bool rewind_config_pending = false;
bool rewind_config_enabled = false;
int rewind_config_seconds = PSX_REWIND_DEFAULT_SECONDS;
int rewind_config_frequency = PSX_REWIND_DEFAULT_FREQUENCY;
bool runahead_pending = false;
int runahead_value = 0;
{
std::lock_guard<std::mutex> lock(g_host_control_lock);
pad_commands.swap(g_host_pad_queue);
std::swap(pause_pending, g_host_pause_pending);
pause_value = g_host_pause_value;
std::swap(audio_suspend_pending, g_host_audio_suspend_pending);
audio_suspend_value = g_host_audio_suspend_value;
std::swap(shutdown_pending, g_host_shutdown_pending);
std::swap(reset_pending, g_host_reset_pending);
std::swap(fast_forward_pending, g_host_fast_forward_pending);
fast_forward_value = g_host_fast_forward_value;
std::swap(speed_limits_pending, g_host_speed_limits_pending);
speed_limit_frame_limit = g_host_speed_limit_frame_limit;
speed_limit_percent = g_host_speed_limit_percent;
speed_limit_fps = g_host_speed_limit_fps;
speed_limit_fast_forward = g_host_speed_limit_fast_forward;
speed_limit_frame_skip = g_host_speed_limit_frame_skip;
std::swap(screenshot_pending, g_host_screenshot_pending);
screenshot_path.swap(g_host_screenshot_path);
std::swap(disc_swap_pending, g_host_disc_swap_pending);
disc_swap_path.swap(g_host_disc_swap_path);
std::swap(multitap_pending, g_host_multitap_pending);
multitap_value = g_host_multitap_value;
std::swap(rewind_config_pending, g_host_rewind_config_pending);
rewind_config_enabled = g_host_rewind_config_enabled;
rewind_config_seconds = g_host_rewind_config_seconds;
rewind_config_frequency = g_host_rewind_config_frequency;
std::swap(runahead_pending, g_host_runahead_pending);
runahead_value = g_host_runahead_value;
}
/* Multitap BEFORE the pad commands: a toggle in the same pass as a press must reach
the device that is actually going to be in the port, not the one being thrown away
(whose held-button state goes with it). */
if (multitap_pending) {
settings_.multitap = multitap_value;
if (session_.valid() && session_.setMultitapEnabled(multitap_value)) {
/* Every held bit belonged to the device that was just destroyed. */
for (int i = 0; i < kHostMaxPlayers; i++) {
host_pad_mask_player_[i] = 0;
host_pad_pressed_prev_pass_[i] = 0;
}
}
}
if (rewind_config_pending) {
settings_.rewind = rewind_config_enabled;
settings_.rewind_seconds = rewind_config_seconds;
settings_.rewind_frequency = rewind_config_frequency;
psx_rewind_configure(rewind_config_enabled ? 1 : 0, rewind_config_seconds,
rewind_config_frequency);
}
if (runahead_pending) {
settings_.runahead = runahead_value;
psx_runahead_configure(runahead_value);
}
applyHostPadCommands(pad_commands);
// [audio] background_playback is the user opting IN to a game that keeps running with the
// screen off. Default off: quiet, underrunning audio out of a sleeping device is never
// what anyone wanted, and a PS1 core burning a core in your pocket even less so.
// Checked here rather than in psxe_host_set_audio_suspended() so the live settings are
// in scope, and so the toggle applies to a request that is already parked.
const bool suspend_audio = audio_suspend_value && !settings_.audio_background_playback;
// Un-suspend BEFORE the resume and suspend AFTER the pause, so the device is never asked
// to carry frames it cannot play: coming back, the stream is running again before the VM
// produces its first sample; going away, the VM has already stopped producing.
if (audio_suspend_pending && !suspend_audio && session_.valid()) {
session_.setAudioSuspended(false);
}
if (pause_pending && session_.valid()) {
session_.setPaused(pause_value);
if (!pause_value) {
resetFramePacing("host-resume");
}
}
if (audio_suspend_pending && suspend_audio && session_.valid()) {
session_.setAudioSuspended(true);
}
if (reset_pending) {
deferred_reset_ = true;
}
// Speed limits before the fast-forward edge: engaging fast-forward in the same pass
// that changed its multiplier must use the NEW multiplier, not the previous one.
if (speed_limits_pending) {
settings_.frame_limit = speed_limit_frame_limit;
settings_.speed_percent = speed_limit_percent;
settings_.fps_limit = speed_limit_fps;
settings_.fast_forward_speed = speed_limit_fast_forward;
settings_.frame_skip = speed_limit_frame_skip;
if (session_.valid()) {
session_.setSpeedLimits(speed_limit_frame_limit, speed_limit_percent, speed_limit_fps,
speed_limit_fast_forward, speed_limit_frame_skip);
resetFramePacing("host-speed-limits");
}
// Whatever run of skipped presents was in flight belongs to the old policy; the
// frame after a change must be drawn so the user sees the setting land.
requestPresentNextFrame();
}
if (fast_forward_pending) {
deferred_fast_forward_value_ = fast_forward_value;
}
if (screenshot_pending) {
deferred_screenshot_ = true;
deferred_screenshot_path_ = std::move(screenshot_path);
}
if (disc_swap_pending && !disc_swap_path.empty()) {
/* swapDisc() clears fast-forward and re-tags the launch kind itself. A failure is
logged rather than dialogued: on Android there is no modal to show it in, and the
running game simply keeps the disc it had — which is the correct outcome. */
if (!session_.swapDisc(std::filesystem::path(disc_swap_path))) {
log_error("disc swap failed: %s", disc_swap_path.c_str());
} else {
resetFramePacing("change-disc");
}
}
if (shutdown_pending) {
running_ = false;
}
}
// A press and its release can both land inside one frame (a quick tap on the touch
// overlay, a macro, a turbo pulse, `adb shell input keyevent`). Applying both would
// leave the guest seeing nothing at all, so a release whose press happened in this
// pass — or in the one before it — is pushed back for the next frame.
//
// TWO frames, not one, deliberately. One frame is not reliably observable: the guest
// polls the pad over the serial link at a phase of its own choosing, and a tap that
// opened and closed inside a single emulated frame was missed roughly half the time
// (measured on Crash Bandicoot's title menu — two injected d-pad taps moved the
// selection one step). A real controller press already spans several frames, so the
// extra frame costs held input nothing: prev_pass is empty by the time its release
// arrives, and the release is applied immediately.
void applyHostPadCommands(std::vector<HostPadCommand>& commands) {
// Consume the previous pass's presses even on an empty drain, so a long-held
// button's release is never delayed by a stale mask.
uint32_t pressed_prev_pass[kHostMaxPlayers];
for (int i = 0; i < kHostMaxPlayers; i++) {
pressed_prev_pass[i] = host_pad_pressed_prev_pass_[i];
host_pad_pressed_prev_pass_[i] = 0;
}
if (commands.empty()) {
return;
}
psx_pad_t* pad = session_.valid() ? session_.pad() : nullptr;
if (!pad) {
for (int i = 0; i < kHostMaxPlayers; i++) {
host_pad_mask_player_[i] = 0;
}
return;
}
uint32_t pressed_this_pass[kHostMaxPlayers] = {};
std::vector<HostPadCommand> deferred;
for (const HostPadCommand& command : commands) {
if (command.kind == HostPadCommandKind::Analog) {
const int stick = command.stick != 0 ? 1 : 0;
const uint16_t x = static_cast<uint16_t>(std::clamp(command.x, 0, 0xFF));
const uint16_t y = static_cast<uint16_t>(std::clamp(command.y, 0, 0xFF));
psx_pad_analog_change_player(pad, 0, command.player,
stick == 0 ? PSXI_AX_SDA_LEFT_HORZ : PSXI_AX_SDA_RIGHT_HORZ, x);
psx_pad_analog_change_player(pad, 0, command.player,
stick == 0 ? PSXI_AX_SDA_LEFT_VERT : PSXI_AX_SDA_RIGHT_VERT, y);
continue;
}
if (!command.mask) {
continue;
}
// Held-button bookkeeping is PER PLAYER. Sharing one mask across a multitap
// would let player 2's release cancel player 1's identical press — the two are
// different buttons on different pads and only look the same as a bit.
const int player = std::clamp(command.player, 0, kHostMaxPlayers - 1);
uint32_t& held = host_pad_mask_player_[player];
if (command.pressed) {
if ((held & command.mask) == 0) {
psx_pad_button_press_player(pad, 0, player, command.mask);
held |= command.mask;
}
pressed_this_pass[player] |= command.mask;
continue;
}
if (((pressed_this_pass[player] | pressed_prev_pass[player]) & command.mask) != 0) {
deferred.push_back(command);
continue;
}
if ((held & command.mask) != 0) {
psx_pad_button_release_player(pad, 0, player, command.mask);
held &= ~command.mask;
}
}
for (int i = 0; i < kHostMaxPlayers; i++) {
host_pad_pressed_prev_pass_[i] = pressed_this_pass[i];
}
if (!deferred.empty()) {
std::lock_guard<std::mutex> lock(g_host_control_lock);
g_host_pad_queue.insert(g_host_pad_queue.begin(), deferred.begin(), deferred.end());
}
}
void applyDeferredActions() {
if (deferred_launch_.has_value()) {
const LaunchRequest request = *deferred_launch_;
deferred_launch_.reset();
psxe_diag_breadcrumbf("Deferred launch kind=%s path=%s",
LaunchKindTitle(request.kind),
request.path.empty() ? "(none)" : request.path.string().c_str());
if (request.kind != LaunchKind::None) {
launchSession(request, close_ui_after_launch_);
}
close_ui_after_launch_ = false;
}
if (deferred_exit_to_library_) {
deferred_exit_to_library_ = false;
exitToLibrary();
}
if (deferred_reset_) {
deferred_reset_ = false;
if (session_.valid()) {
session_.reset();
session_.setPaused(false);
resetFramePacing("deferred-reset");
}
}
if (deferred_change_disc_.has_value()) {
const std::filesystem::path path = *deferred_change_disc_;
deferred_change_disc_.reset();
if (path.empty()) {
returnToMainWindow();
return;
}
if (!session_.swapDisc(path)) {
pending_error_dialog_ = "Failed to swap to the selected disc image.";
} else {
session_.setPaused(false);
resetFramePacing("change-disc");
refreshGameList(false);
// A new disc is a different RA game: drop the old set and re-identify.
armsx_ach_session_started(session_.psx());
returnToMainWindow();
}
}
if (deferred_screenshot_) {
deferred_screenshot_ = false;
// An embedded host (JNI saveScreenshot) supplies its own destination; the
// built-in path keeps the timestamped snap/ folder. Either way the payload is
// written by SDL_SaveBMP, so a caller asking for ".png" still gets BMP bytes.
if (!deferred_screenshot_path_.empty()) {
const std::filesystem::path target(deferred_screenshot_path_);
deferred_screenshot_path_.clear();
try {
if (target.has_parent_path()) {
std::filesystem::create_directories(target.parent_path());
}
if (!session_.saveScreenshot(target)) {
pending_error_dialog_ = "Failed to write the screenshot.";
}
} catch (...) {
pending_error_dialog_ = "Failed to prepare the screenshot folder.";
}
} else {
const std::filesystem::path snap_dir = DefaultBrowseDirectory() / "snap";
try {
std::filesystem::create_directories(snap_dir);
const std::time_t now = std::time(nullptr);
char name[64] = {};
std::strftime(name, sizeof(name), "armsx-%Y%m%d-%H%M%S.bmp", std::localtime(&now));
if (!session_.saveScreenshot(snap_dir / name)) {
pending_error_dialog_ = "Failed to write the screenshot.";
}
} catch (...) {
pending_error_dialog_ = "Failed to prepare the screenshot folder.";
}
}
}
// The single fast-forward entry point. There used to be a second one — a
// deferred_fast_forward_toggle_ flag with its own copy of this block, meant for an
// in-core hotkey — but nothing ever set it (the toggle lives in the Compose front-end
// and arrives through psxe_host_set_fast_forward as an absolute value), so it was two
// code paths where only one ran. The toggle is now resolved by the caller.
if (deferred_fast_forward_value_.has_value()) {
const bool enabled = *deferred_fast_forward_value_;
deferred_fast_forward_value_.reset();
if (session_.valid() && session_.fastForwardEnabled() != enabled) {
session_.setFastForwardEnabled(enabled);
resetFramePacing("host-fast-forward");
}
// Outside the guard above: a request that arrives with no session (the front-end
// clears fast-forward as it closes a game) still has to hand the swap interval back.
applyPresentVsync(session_.valid() && session_.fastForwardEnabled());
}
}
bool launchSession(const LaunchRequest& request, bool close_ui) {
std::string error;
psxe_diag_breadcrumbf("Launching session kind=%s path=%s close_ui=%s",
LaunchKindTitle(request.kind),
request.path.empty() ? "(none)" : request.path.string().c_str(),
close_ui ? "true" : "false");
if (!session_.create(render_, settings_, request, error)) {
pending_error_dialog_ = error;
psxe_diag_logf("launch", "Session launch failed kind=%s error=%s", LaunchKindTitle(request.kind), error.c_str());
if (request.kind == LaunchKind::Bios) {
showLandingWindow();
} else {
showGameListWindow();
}
return false;
}
input_router_.attach(session_.pad());
resetFramePacing("launch-session");
applyWindowMetrics();
logRendererBootstrap("session-launch", session_.frameRate());
refreshGameList(false);
// Publish the machine to RetroAchievements. Identification (disc hash + set download)
// is deferred to the first frame update so the disc read never lands inside a boot.
armsx_ach_session_started(session_.psx());
if (close_ui) {
returnToMainWindow();
}
return true;
}
void exitToLibrary() {
psxe_diag_breadcrumbf("Exit to library requested");
input_router_.detach();
armsx_ach_session_ended();
session_.destroy();
resetFramePacing("exit-to-library");
showLandingWindow();
}
void refreshGameList(bool full_rescan) {
(void)full_rescan;
}
void applyWindowMetrics() {
if (!SupportsManagedWindowSizing() || !owns_window_ || !window_) {
return;
}
int width = 1280;
int height = 720;
if (session_.valid()) {
if (session_.debugView()) {
width = PSX_GPU_FB_WIDTH;
height = PSX_GPU_FB_HEIGHT;
} else if (settings_.display_aspect == 2) {
height = std::max(240, settings_.upscale_height);
width = static_cast<int>((16.0f / 9.0f) * static_cast<float>(height));
} else if (settings_.display_aspect == 1) {
width = 320 * std::max(1, settings_.scale);
height = width;
} else {
int base_width = 320;
if (session_.psx()) {
const int display_width = static_cast<int>(psx_get_dmode_width(session_.psx()));
if (display_width == 256 || display_width == 320) {
base_width = display_width;
} else if (display_width == 368) {
base_width = 384;
}
}
width = base_width * std::max(1, settings_.scale);
height = 240 * std::max(1, settings_.scale);
}
}
SDL_SetWindowSize(window_, width, height);
}
std::filesystem::path initialBrowseDirectory() const {
if (session_.valid()) {
const ArmsxGameInfo info = session_.currentGameInfo();
if (!info.path.empty() && info.path.has_parent_path()) {
return info.path.parent_path();
}
}
if (!settings_.ui_state.game_list_paths.empty()) {
return settings_.ui_state.game_list_paths.front();
}
if (!settings_.ui_state.game_list_recursive_paths.empty()) {
return settings_.ui_state.game_list_recursive_paths.front();
}
return DefaultBrowseDirectory();
}
std::filesystem::path browseDirectoryForPath(const std::string& configured_path, bool expect_directory) const {
std::error_code ec;
if (!configured_path.empty()) {
const std::filesystem::path candidate(configured_path);
if (std::filesystem::exists(candidate, ec)) {
if (expect_directory && std::filesystem::is_directory(candidate, ec)) {
return candidate;
}
if (!expect_directory) {
if (std::filesystem::is_directory(candidate, ec)) {
return candidate;
}
if (candidate.has_parent_path() && std::filesystem::exists(candidate.parent_path(), ec)) {
return candidate.parent_path();
}
}
}
if (candidate.has_parent_path() && std::filesystem::exists(candidate.parent_path(), ec)) {
return candidate.parent_path();
}
}
return initialBrowseDirectory();
}
int argc_ = 0;
const char* const* argv_ = nullptr;
SDL_Window* external_window_ = nullptr;
SDL_Renderer* external_renderer_ = nullptr;
CliFlags cli_{};
FrontendSettings settings_{};
bool running_ = true;
armsx::SdlSubsystemLease sdl_subsystems_{};
bool owns_window_ = false;
bool owns_renderer_ = false;
SDL_Window* window_ = nullptr;
armsx_renderer_t* render_ = nullptr;
ArmsxSession session_{};
GameplayInputRouter input_router_{};
std::optional<std::string> pending_error_dialog_{};
std::optional<LaunchRequest> deferred_launch_{};
std::optional<std::filesystem::path> deferred_change_disc_{};
std::optional<bool> deferred_vsync_{};
std::optional<std::string> pending_settings_page_restore_{};
std::optional<LaunchRequest> pending_cli_launch_{};
std::string pending_cli_argument_;
bool close_ui_after_launch_ = false;
bool deferred_exit_to_library_ = false;
bool deferred_reset_ = false;
bool deferred_screenshot_ = false;
// Embedded-host (JNI) additions: an explicit screenshot destination, an absolute
// fast-forward request, and the digital pad bits the host currently holds down.
std::string deferred_screenshot_path_;
std::optional<bool> deferred_fast_forward_value_{};
// Digital bits the host currently holds down, PER PLAYER (a multitap carries four
// pads behind one port). Index 0 is the only one that ever moves without a tap.
uint32_t host_pad_mask_player_[kHostMaxPlayers] = {};
// Bits pressed by the PREVIOUS applyHostPadCommands pass — a tap's release is held
// back until they have aged out, so every press is visible to the guest for two
// emulated frames (see applyHostPadCommands).
uint32_t host_pad_pressed_prev_pass_[kHostMaxPlayers] = {};
FsuiWindowState ui_window_state_ = FsuiWindowState::None;
bool managed_renderer_vsync_ = DefaultVsyncEnabled();
uint64_t next_frame_deadline_ = 0;
uint64_t frame_period_ticks_ = 0;
// How late the loop arrived for the current frame, sampled in waitForFrameDeadline(). 0
// whenever the limiter had headroom to sleep. Adaptive frame skip is the only reader.
uint64_t frame_lateness_ticks_ = 0;
// Frame skip ([runtime] frame_skip) — see shouldSkipPresent(). frames_skipped_run_ counts
// consecutive dropped presents and is parked at kMaxFrameSkip to mean "draw the next one";
// the other two are the diag trace's window.
int frames_skipped_run_ = kMaxFrameSkip;
uint32_t frames_dropped_ = 0;
uint32_t frame_skip_trace_frames_ = 0;
// ~4 s at 60 Hz. Long enough that the ratio is readable, rare enough that the trace cannot
// become the thing that costs the frame.
static constexpr uint32_t kFrameSkipTraceFrames = 240;
// True while applyPresentVsync() is holding the swap interval down for fast-forward.
bool fast_forward_vsync_dropped_ = false;
// Rolling window behind psxe_host_measured_fps() — see publishSessionTelemetry().
uint64_t fps_window_start_ = 0;
uint32_t fps_window_frames_ = 0;
// Rolling window behind psxe_host_stats() — see publishFrameStats(). Everything here is
// untouched (and the counters unarmed) while the performance overlay is off.
struct StatsAccumulator {
uint64_t emu_ticks = 0;
uint64_t present_ticks = 0;
uint64_t frame_ticks = 0;
psx_perf_counters_t counters{};
};
StatsAccumulator stats_accum_{};
uint64_t stats_window_start_ = 0;
uint64_t stats_prev_frame_begin_ = 0;
uint64_t stats_worst_frame_ticks_ = 0;
uint32_t stats_window_frames_ = 0;
};
std::string ModuleNameFromPath(const char* path) {
if (!path || !path[0]) {
return "(unknown)";
}
return std::filesystem::path(path).filename().string();
}
void WriteNativeStackTraceImpl() {
#if defined(_WIN32)
void* frames[64] = {};
const USHORT frame_count = CaptureStackBackTrace(0, static_cast<DWORD>(std::size(frames)), frames, nullptr);
psxe_diag_logf("crash", "Native stack trace (%u frames):", static_cast<unsigned int>(frame_count));
#if !defined(UWP_TARGET)
HANDLE process = GetCurrentProcess();
SymInitialize(process, nullptr, TRUE);
char symbol_buffer[sizeof(SYMBOL_INFO) + MAX_SYM_NAME] = {};
auto* symbol = reinterpret_cast<SYMBOL_INFO*>(symbol_buffer);
symbol->SizeOfStruct = sizeof(SYMBOL_INFO);
symbol->MaxNameLen = MAX_SYM_NAME;
#endif
for (USHORT index = 0; index < frame_count; index++) {
const DWORD64 address = static_cast<DWORD64>(reinterpret_cast<uintptr_t>(frames[index]));
HMODULE module = nullptr;
char module_path[MAX_PATH] = {};
DWORD64 module_offset = 0;
if (GetModuleHandleExA(
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCSTR>(frames[index]),
&module) != 0) {
GetModuleFileNameA(module, module_path, static_cast<DWORD>(std::size(module_path)));
module_offset = address - static_cast<DWORD64>(reinterpret_cast<uintptr_t>(module));
}
#if !defined(UWP_TARGET)
DWORD64 displacement = 0;
if (SymFromAddr(process, address, &displacement, symbol) != 0) {
psxe_diag_logf(
"crash",
" #%u %p %s!%s+0x%llx module+0x%llx",
static_cast<unsigned int>(index),
frames[index],
module_path[0] ? ModuleNameFromPath(module_path).c_str() : "(unknown)",
symbol->Name,
static_cast<unsigned long long>(displacement),
static_cast<unsigned long long>(module_offset)
);
continue;
}
#endif
psxe_diag_logf(
"crash",
" #%u %p %s+0x%llx",
static_cast<unsigned int>(index),
frames[index],
module_path[0] ? ModuleNameFromPath(module_path).c_str() : "(unknown)",
static_cast<unsigned long long>(module_offset)
);
}
#if !defined(UWP_TARGET)
SymCleanup(process);
#endif
#elif defined(PSXE_HAS_EXECINFO)
void* frames[64] = {};
const int frame_count = backtrace(frames, static_cast<int>(std::size(frames)));
psxe_diag_logf("crash", "Native stack trace (%d frames):", frame_count);
for (int index = 0; index < frame_count; index++) {
Dl_info info{};
if (dladdr(frames[index], &info) != 0 && info.dli_fname) {
const uintptr_t symbol_offset =
info.dli_saddr
? (reinterpret_cast<uintptr_t>(frames[index]) - reinterpret_cast<uintptr_t>(info.dli_saddr))
: 0u;
psxe_diag_logf(
"crash",
" #%d %p %s %s+0x%zx",
index,
frames[index],
ModuleNameFromPath(info.dli_fname).c_str(),
info.dli_sname ? info.dli_sname : "(unknown)",
symbol_offset
);
} else {
psxe_diag_logf("crash", " #%d %p", index, frames[index]);
}
}
#else
psxe_diag_logf("crash", "Native stack trace unavailable on this platform build.");
#endif
}
[[noreturn]] void ReportNativeCrash(const char* reason) {
if (g_crash_reporting.exchange(true)) {
std::_Exit(1);
}
psxe_diag_logf("crash", "Native crash captured: %s", reason ? reason : "(unknown)");
if (g_active_app) {
g_active_app->writeCrashContext(reason);
} else {
psxe_diag_logf("crash", "No active app context available.");
psxe_diag_dump_breadcrumbs();
}
WriteNativeStackTraceImpl();
psxe_diag_shutdown();
std::_Exit(1);
}
} // namespace
extern "C" void psxe_diag_write_native_stacktrace(void) {
WriteNativeStackTraceImpl();
}
extern "C" int psxe_run(int argc, const char* argv[], void* external_window, void* external_renderer) {
ArmsxApp app(argc, argv, external_window, external_renderer);
return app.run();
}
extern "C" PSXE_API void psxe_enqueue_launch_argument(const char* argument) {
if (!argument || !*argument) {
return;
}
EnqueuePendingLaunchArgument(argument);
}
// ---------------------------------------------------------------------------
// Embedded-host control API (see the g_host_* block near the top of this file).
//
// Every entry point below is safe to call from any thread at any time — including
// before the emulation loop starts and after it has exited. Requests are parked under
// g_host_control_lock and applied by ArmsxApp::applyHostControlRequests() on the
// emulation thread. frontend/android_jni.cpp is the first consumer.
// ---------------------------------------------------------------------------
namespace {
// The embedder's button code space (inherited from the Android/PS2 front-end: Android
// KEYCODE_* values for the pad, plus 200 for the DualShock analog-mode button) mapped onto
// the PS1 pad's PSXI_SW_SDA_* bits.
uint32_t HostPadMaskForCode(int code) {
switch (code) {
case 19: return PSXI_SW_SDA_PAD_UP; // KEYCODE_DPAD_UP
case 20: return PSXI_SW_SDA_PAD_DOWN; // KEYCODE_DPAD_DOWN
case 21: return PSXI_SW_SDA_PAD_LEFT; // KEYCODE_DPAD_LEFT
case 22: return PSXI_SW_SDA_PAD_RIGHT; // KEYCODE_DPAD_RIGHT
case 96: return PSXI_SW_SDA_CROSS; // KEYCODE_BUTTON_A
case 97: return PSXI_SW_SDA_CIRCLE; // KEYCODE_BUTTON_B
case 99: return PSXI_SW_SDA_SQUARE; // KEYCODE_BUTTON_X
case 100: return PSXI_SW_SDA_TRIANGLE; // KEYCODE_BUTTON_Y
case 102: return PSXI_SW_SDA_L1; // KEYCODE_BUTTON_L1
case 103: return PSXI_SW_SDA_R1; // KEYCODE_BUTTON_R1
case 104: return PSXI_SW_SDA_L2; // KEYCODE_BUTTON_L2
case 105: return PSXI_SW_SDA_R2; // KEYCODE_BUTTON_R2
case 106: return PSXI_SW_SDA_L3; // KEYCODE_BUTTON_THUMBL
case 107: return PSXI_SW_SDA_R3; // KEYCODE_BUTTON_THUMBR
case 108: return PSXI_SW_SDA_START; // KEYCODE_BUTTON_START
case 109: return PSXI_SW_SDA_SELECT; // KEYCODE_BUTTON_SELECT
case 200: return PSXI_SW_SDA_ANALOG; // DualShock analog-mode button
default: return 0;
}
}
// Codes 110-113 (left stick) and 120-123 (right stick) arrive as four independent
// per-direction magnitudes in 0..32767. The PS1 pad wants two 0x00..0xFF axes centred on
// 0x80, so the magnitudes are accumulated here and collapsed into one axis pair.
constexpr int kHostStickFullRange = 32767;
// [player][stick][up,right,down,left]. Per player because each pad behind a multitap has
// its own two sticks; sharing one accumulator made player 2's stick move player 1's.
int g_host_stick_dir[kHostMaxPlayers][2][4] = {};
bool HostStickSlotForCode(int code, int* stick, int* slot) {
switch (code) {
case 110: *stick = 0; *slot = 0; return true; // left up
case 111: *stick = 0; *slot = 1; return true; // left right
case 112: *stick = 0; *slot = 2; return true; // left down
case 113: *stick = 0; *slot = 3; return true; // left left
case 120: *stick = 1; *slot = 0; return true; // right up
case 121: *stick = 1; *slot = 1; return true; // right right
case 122: *stick = 1; *slot = 2; return true; // right down
case 123: *stick = 1; *slot = 3; return true; // right left
default: return false;
}
}
int HostAxisByte(int positive, int negative) {
const int delta = std::clamp(positive, 0, kHostStickFullRange) - std::clamp(negative, 0, kHostStickFullRange);
const int scaled = (delta * 127) / kHostStickFullRange;
return std::clamp(0x80 + scaled, 0x00, 0xFF);
}
} // namespace
extern "C" PSXE_API void psxe_host_set_embedded(int enabled) {
std::lock_guard<std::mutex> lock(g_host_control_lock);
g_host_embedded = enabled != 0;
}
extern "C" PSXE_API void psxe_host_set_present_callback(void (*callback)(void*), void* user) {
std::lock_guard<std::mutex> lock(g_host_control_lock);
g_host_present_callback = callback;
g_host_present_user = user;
}
extern "C" PSXE_API void psxe_host_set_paused(int paused) {
std::lock_guard<std::mutex> lock(g_host_control_lock);
g_host_pause_pending = true;
g_host_pause_value = paused != 0;
}
// The app went off-screen (backgrounded, or the screen was switched off) / came back.
//
// Closes the SDL audio device outright, which is the only thing that actually stops the stream
// on Android — see ArmsxSession::closeAudioDevice(). Separate from psxe_host_set_paused() because the two are
// genuinely different states: the pause menu freezes the VM with the app still on screen, this
// is the app not being on screen at all. The host front-end normally asks for both.
//
// Honoured (or deliberately ignored) in applyHostControlRequests(), where [audio]
// background_playback is in scope.
extern "C" PSXE_API void psxe_host_set_audio_suspended(int suspended) {
{
std::lock_guard<std::mutex> lock(g_host_control_lock);
g_host_audio_suspend_pending = true;
g_host_audio_suspend_value = suspended != 0;
}
// ★ The PGO counter flush for an instrumented build, and the one that actually matters.
// Going off-screen is the last callback guaranteed to arrive before Android may SIGKILL the
// process, and SIGKILL means compiler-rt's atexit writer never runs — an instrumented build
// without this hook profiles a whole play session and then loses all of it. Deliberately
// OUTSIDE the lock above: this does file I/O, and g_host_control_lock is taken on the
// emulation thread every loop tick. No-op in a normal build (see frontend/pgo.c).
if (suspended) {
armsx_pgo_flush("background");
}
}
// 0 = classic 4:3, 1 = square 1:1, 2 = wide 16:9; anything negative drops back to
// settings.toml's [video] display_aspect. Applies on the next presented frame — no VM
// restart, because nothing in the VM depends on it.
// App off-screen: stop drawing and posting frames entirely. Separate from
// psxe_host_set_paused() (the pause menu is on-screen and must keep presenting) and from
// psxe_host_set_audio_suspended() ([audio] background_playback can keep audio alive with the
// screen off). Cheap and immediate — read once per loop tick, gates nothing but presentation.
extern "C" PSXE_API void psxe_host_set_presentation_suspended(int suspended) {
g_host_presentation_suspended.store(suspended != 0, std::memory_order_release);
}
// Live custom ratio (width/height) used while the mode is 3. <= 0 clears the override and
// falls back to settings.toml's [video] display_aspect_custom. Applies on the next presented
// frame, like the mode itself.
// Live integer-scaling override; negative clears it back to settings.toml. Applies on the
// next presented frame — it only changes the destination rect.
extern "C" PSXE_API void psxe_host_set_portrait_render_top(int enabled) {
g_host_portrait_top.store(enabled ? 1 : 0, std::memory_order_release);
}
extern "C" PSXE_API void psxe_host_set_portrait_render_top_inset(int pixels) {
g_host_portrait_top_inset.store(pixels > 0 ? pixels : 0, std::memory_order_release);
}
extern "C" PSXE_API void psxe_host_set_integer_scaling(int enabled) {
g_host_integer_scaling.store(enabled < 0 ? -1 : (enabled != 0 ? 1 : 0), std::memory_order_release);
}
extern "C" PSXE_API void psxe_host_set_display_aspect_custom(float ratio) {
g_host_display_aspect_custom.store(ratio > 0.0f ? ratio : 0.0f, std::memory_order_release);
}
extern "C" PSXE_API void psxe_host_set_display_aspect(int mode) {
g_host_display_aspect.store(mode >= 0 && mode <= 3 ? mode : -1, std::memory_order_release);
}
// 1 = fill the window and ignore the aspect, 0 = letterbox/pillarbox to it, negative =
// defer to settings.toml's [video] stretch_mode.
extern "C" PSXE_API void psxe_host_set_stretch_mode(int enabled) {
g_host_stretch_mode.store(enabled < 0 ? -1 : (enabled != 0 ? 1 : 0), std::memory_order_release);
}
/* ---- [video] display features, live ------------------------------------------------------
Each takes a negative value to mean "clear the override and go back to settings.toml".
All three only affect how the finished frame is presented, so they apply on the very next
presented frame with no VM interaction at all — safe with no session running. */
extern "C" PSXE_API void psxe_host_set_deinterlace(int mode) {
g_host_deinterlace.store(mode >= 0 && mode <= 2 ? mode : -1, std::memory_order_release);
}
extern "C" PSXE_API void psxe_host_set_overscan_crop(int mode) {
g_host_overscan_crop.store(mode >= 0 && mode <= 2 ? mode : -1, std::memory_order_release);
}
/* Degrees (0/90/180/270), matching settings.toml, converted to quarter turns here so every
caller speaks one language. Anything that is not a right angle clears the override. */
extern "C" PSXE_API void psxe_host_set_display_rotation(int degrees) {
int turns = -1;
switch (degrees) {
case 0: turns = 0; break;
case 90: turns = 1; break;
case 180: turns = 2; break;
case 270: turns = 3; break;
default: turns = -1; break;
}
g_host_display_rotation.store(turns, std::memory_order_release);
}
/* The GTE widescreen hack is CORE state, not presentation: it changes the vertices the
machine produces, so it is applied straight to psx/cpu.c's global rather than parked as a
present-time override. Safe while the VM runs — the next projected vertex simply uses the
new scale, exactly like PGXP's toggle. */
extern "C" PSXE_API void psxe_host_set_widescreen_hack(int enabled) {
psx_cpu_set_widescreen_hack(enabled ? 1 : 0);
}
/* Texture dumping / replacement (psx/texrep.h). PARKED, not applied: the reconfigure frees
decoded replacement images and the rasterizers hold pointers into them, so it has to happen
on the emulation thread. ArmsxApp::applyPendingTextureOptions() drains this once a frame. */
extern "C" PSXE_API void psxe_host_set_texture_options(int dump, int replace, const char* dir) {
{
std::lock_guard<std::mutex> lock(g_host_texture_lock);
g_host_texture_dir = (dir && *dir) ? dir : "";
}
g_host_texture_dump.store(dump ? 1 : 0, std::memory_order_release);
g_host_texture_replace.store(replace ? 1 : 0, std::memory_order_release);
g_host_texture_pending.store(true, std::memory_order_release);
}
/* GLES rasterizer options. Forwarded verbatim; the backend clamps and owns the semantics. */
extern "C" PSXE_API void psxe_host_set_gl_video_options(int texture_filter, int downsample,
int line_detect) {
#ifdef USE_HARDWARE
armsx_hw_gl_set_video_options(texture_filter, downsample, line_detect);
#else
(void)texture_filter;
(void)downsample;
(void)line_detect;
#endif
}
extern "C" PSXE_API void psxe_host_request_shutdown(void) {
{
std::lock_guard<std::mutex> lock(g_host_control_lock);
g_host_shutdown_pending = true;
// A shutdown must never be blocked behind a stale pause.
g_host_pause_pending = true;
g_host_pause_value = false;
// ...nor behind a stale background-suspend. Shutting down straight out of the background
// (swipe-kill, Close Game from the pause menu after a screen-off) has to leave the stream
// un-parked so the teardown below can close it cleanly.
g_host_audio_suspend_pending = true;
g_host_audio_suspend_value = false;
}
// SDL_PushEvent is thread-safe; this also breaks a loop parked in SDL_PollEvent.
SDL_Event quit{};
quit.type = SDL_QUIT;
SDL_PushEvent(&quit);
}
extern "C" PSXE_API void psxe_host_request_reset(void) {
std::lock_guard<std::mutex> lock(g_host_control_lock);
g_host_reset_pending = true;
}
extern "C" PSXE_API void psxe_host_set_fast_forward(int enabled) {
std::lock_guard<std::mutex> lock(g_host_control_lock);
g_host_fast_forward_pending = true;
g_host_fast_forward_value = enabled != 0;
}
// The whole frame-pacing policy in one call, so the deferred pump needs one flag instead of
// four and a host that changes two of them at once can never have them applied a frame apart.
// frame_limit 0 runs unthrottled (the front-end's "Frame limit" switch)
// speed_percent 10..1000, % of the game's own rate ("Emulation speed")
// fps_limit extra absolute ceiling in fps, 0 = off ("Frame rate cap")
// fast_forward_speed multiplier used instead while fast-forward is engaged, 0 = uncapped
// frame_skip presents to DROP, not speed: 0 off, 1..5 fixed, -1 adaptive ("Frame skip")
extern "C" PSXE_API void psxe_host_set_speed_limits(int frame_limit,
int speed_percent,
int fps_limit,
float fast_forward_speed,
int frame_skip) {
std::lock_guard<std::mutex> lock(g_host_control_lock);
g_host_speed_limits_pending = true;
g_host_speed_limit_frame_limit = frame_limit != 0;
g_host_speed_limit_percent = speed_percent;
g_host_speed_limit_fps = fps_limit;
g_host_speed_limit_fast_forward = static_cast<double>(fast_forward_speed);
g_host_speed_limit_frame_skip = frame_skip;
}
extern "C" PSXE_API void psxe_host_request_disc_swap(const char* path) {
std::lock_guard<std::mutex> lock(g_host_control_lock);
g_host_disc_swap_pending = path && *path;
g_host_disc_swap_path = (path && *path) ? path : "";
}
extern "C" PSXE_API void psxe_host_request_screenshot(const char* path) {
std::lock_guard<std::mutex> lock(g_host_control_lock);
g_host_screenshot_pending = true;
g_host_screenshot_path = path && *path ? path : "";
}
// Declared by an embedded host once SDLAudioManager's JNI glue AND its static Context are in
// place. Without both, only openslES is safe in-process; see applyAudioDriverSetting().
extern "C" PSXE_API void psxe_host_set_audio_backends_ready(int ready) {
g_host_audio_backends_ready.store(ready != 0, std::memory_order_release);
}
extern "C" PSXE_API int psxe_host_vm_active(void) {
return g_host_vm_active.load(std::memory_order_acquire) ? 1 : 0;
}
extern "C" PSXE_API int psxe_host_loop_running(void) {
return g_host_loop_running.load(std::memory_order_acquire) ? 1 : 0;
}
// Session telemetry. Pure reads of what the emulation loop published on its last frame — safe
// from any thread, and 0 whenever nothing is running. See publishSessionTelemetry().
extern "C" PSXE_API float psxe_host_measured_fps(void) {
return g_host_measured_fps.load(std::memory_order_relaxed);
}
extern "C" PSXE_API float psxe_host_nominal_frame_rate(void) {
return g_host_nominal_fps.load(std::memory_order_relaxed);
}
extern "C" PSXE_API unsigned int psxe_host_presented_frames(void) {
return g_host_presented_frames.load(std::memory_order_relaxed);
}
// Arm/disarm the full performance overlay. Also arms psx/perf.c's counters, so switching it
// off really does remove the instrumentation from the core rather than merely hiding the
// numbers. Safe from any thread: psx_perf_set_enabled() only writes a plain int and zeroes a
// counter block the emulation thread will overwrite on its next frame anyway.
//
// IDEMPOTENT, and that is load-bearing rather than tidiness: psx_perf_set_enabled() zeroes the
// counter block on every call and the disable path zeroes the snapshot, so a caller that simply
// re-asserted the state it wanted would keep wiping live measurements. Because it could not,
// the UI had to arm this on an EDGE (a Compose DisposableEffect keyed on "in a game" + "a
// statistics row is on"), while GameOsd.reset() disarms it unconditionally on every VM stop —
// including the stop half of a RESTART. When the STOPPED/RUNNING pair lands inside one
// recomposition the keys never change, the edge never fires, and the counters stay disarmed for
// the rest of the session: every row reads 0 forever while the game plays normally. With this
// a no-op, the overlay can re-assert its arm on the poll tick and the state converges.
extern "C" PSXE_API void psxe_host_set_stats_enabled(int enabled) {
const bool on = enabled != 0;
if (g_host_stats_enabled.exchange(on, std::memory_order_relaxed) == on) {
return;
}
psx_perf_set_enabled(on ? 1 : 0);
if (!on) {
std::lock_guard<std::mutex> lock(g_host_stats_lock);
std::memset(g_host_stats, 0, sizeof(g_host_stats));
g_host_stats_published = false;
}
}
// Copy the latest snapshot into [out]. Returns the number of doubles written (0 when the
// overlay has never been armed, or before the first window closed). Layout: PSXE_HOST_STAT_*.
extern "C" PSXE_API unsigned int psxe_host_stats(double* out, unsigned int count) {
if (!out || count == 0) {
return 0;
}
const unsigned int n = count < (unsigned int)PSXE_HOST_STAT_COUNT
? count
: (unsigned int)PSXE_HOST_STAT_COUNT;
std::lock_guard<std::mutex> lock(g_host_stats_lock);
/* The documented "0 until the first window closed" — it was documented but never
implemented, so an unpublished (all-zero) snapshot was handed over as if it were a
reading, and the UI's own "not ready yet, print --" state could never be reached. */
if (!g_host_stats_published) {
return 0;
}
std::memcpy(out, g_host_stats, n * sizeof(double));
return n;
}
extern "C" PSXE_API void psxe_host_pad_button_player(int player, int code, int range, int pressed) {
const int target = std::clamp(player, 0, kHostMaxPlayers - 1);
std::lock_guard<std::mutex> lock(g_host_control_lock);
int stick = 0;
int slot = 0;
if (HostStickSlotForCode(code, &stick, &slot)) {
// range is the direction's magnitude; a release (pressed == 0) zeroes it.
g_host_stick_dir[target][stick][slot] = pressed ? std::clamp(range, 0, kHostStickFullRange) : 0;
HostPadCommand command{};
command.kind = HostPadCommandKind::Analog;
command.stick = stick;
command.player = target;
command.x = HostAxisByte(g_host_stick_dir[target][stick][1], g_host_stick_dir[target][stick][3]);
command.y = HostAxisByte(g_host_stick_dir[target][stick][2], g_host_stick_dir[target][stick][0]);
g_host_pad_queue.push_back(command);
return;
}
const uint32_t mask = HostPadMaskForCode(code);
if (!mask) {
return;
}
HostPadCommand command{};
command.kind = HostPadCommandKind::Digital;
command.mask = mask;
command.pressed = pressed != 0;
command.player = target;
g_host_pad_queue.push_back(command);
}
extern "C" PSXE_API void psxe_host_pad_button(int code, int range, int pressed) {
psxe_host_pad_button_player(0, code, range, pressed);
}
extern "C" PSXE_API void psxe_host_pad_analog_player(int player, int stick, int x, int y) {
const int target = std::clamp(player, 0, kHostMaxPlayers - 1);
std::lock_guard<std::mutex> lock(g_host_control_lock);
HostPadCommand command{};
command.kind = HostPadCommandKind::Analog;
command.stick = stick != 0 ? 1 : 0;
command.player = target;
command.x = std::clamp(x, 0x00, 0xFF);
command.y = std::clamp(y, 0x00, 0xFF);
g_host_pad_queue.push_back(command);
}
extern "C" PSXE_API void psxe_host_pad_analog(int stick, int x, int y) {
psxe_host_pad_analog_player(0, stick, x, y);
}
extern "C" PSXE_API void psxe_host_reset_pad_state(void) {
std::lock_guard<std::mutex> lock(g_host_control_lock);
g_host_pad_queue.clear();
std::memset(g_host_stick_dir, 0, sizeof(g_host_stick_dir));
}
/* ---- [input] multitap, [emulation] rewind / runahead ------------------------------------
The first three park a request for the emulation thread (they allocate, free, or rebuild
the device the SIO reads through). The last two are the hold-to-rewind engagement, which
is read once per frame straight out of an atomic — see the note by g_host_rewind_active. */
extern "C" PSXE_API void psxe_host_set_multitap(int enabled) {
std::lock_guard<std::mutex> lock(g_host_control_lock);
g_host_multitap_pending = true;
g_host_multitap_value = enabled != 0;
}
extern "C" PSXE_API void psxe_host_set_rewind(int enabled, int seconds, int frequency) {
std::lock_guard<std::mutex> lock(g_host_control_lock);
g_host_rewind_config_pending = true;
g_host_rewind_config_enabled = enabled != 0;
g_host_rewind_config_seconds = seconds;
g_host_rewind_config_frequency = frequency;
}
extern "C" PSXE_API void psxe_host_set_runahead(int frames) {
std::lock_guard<std::mutex> lock(g_host_control_lock);
g_host_runahead_pending = true;
g_host_runahead_value = frames;
}
extern "C" PSXE_API void psxe_host_set_rewind_active(int active) {
g_host_rewind_active.store(active != 0, std::memory_order_release);
}
extern "C" PSXE_API void psxe_host_rewind_step(void) {
g_host_rewind_step_requests.fetch_add(1, std::memory_order_acq_rel);
}
/* Bytes ONE snapshot takes: measured once a game is running, an estimate before that. The
front-end multiplies by seconds x frequency to show what the control actually costs. */
extern "C" PSXE_API unsigned int psxe_host_rewind_snapshot_bytes(void) {
return static_cast<unsigned int>(psx_rewind_snapshot_bytes());
}
/* Bytes the ring is holding right now, so a UI can show the real figure rather than the
worst case. 0 when rewind is off — nothing is allocated then. */
extern "C" PSXE_API unsigned int psxe_host_rewind_bytes_used(void) {
return static_cast<unsigned int>(psx_rewind_bytes_used());
}
extern "C" PSXE_API void psxe_wasm_on_file(const char* path) {
if (!path || !*path) {
return;
}
psxe_enqueue_launch_argument(path);
}
extern "C" PSXE_API void psxe_wasm_on_error(const char* message) {
const char* text = message && *message ? message : "Unknown browser file error.";
psxe_diag_logf("web", "Browser file access failed: %s", text);
EnqueueWebError(text);
}
#if defined(__ANDROID__)
extern "C" JNIEXPORT void JNICALL Java_com_nanodata_armsx_EmulatorActivity_nativeEnqueueLaunchArgument(
JNIEnv* env,
jclass,
jstring argument
) {
if (!env || !argument) {
return;
}
const char* utf = env->GetStringUTFChars(argument, nullptr);
if (!utf) {
return;
}
psxe_enqueue_launch_argument(utf);
env->ReleaseStringUTFChars(argument, utf);
}
#endif
// ---------------------------------------------------------------------------
// Process entry points.
//
// `external_main` MUST keep the plain `int(int, char**)` main signature. SDL's Android
// glue (SDLActivity.nativeRunMain -> the symbol named by getMainFunction(), which
// com.nanodata.armsx.EmulatorActivity sets to "external_main") calls it through a
// SDL_main_func function pointer of exactly that shape. When this used to be the 4-arg
// form, parameters 3 and 4 arrived as whatever happened to be in x2/x3 — garbage that
// initializeWindowAndRenderer() then adopted as an SDL_Window/SDL_Renderer, so
// ArmsxApp::run() bailed out with 1 and the process vanished with no crash and no log.
//
// Hosts that really do own the window/renderer (PSVita, and any future embedder) call
// `external_main_ex` instead.
// ---------------------------------------------------------------------------
extern "C" PSXE_API int external_main_ex(int argc, const char* argv[], void* external_window, void* external_renderer) {
return psxe_run(argc, argv, external_window, external_renderer);
}
extern "C" PSXE_API int external_main(int argc, const char* argv[]) {
return psxe_run(argc, argv, nullptr, nullptr);
}
#ifndef __DLL_BUILD
int main(int argc, const char* argv[]) {
return psxe_run(argc, argv, nullptr, nullptr);
}
#endif