#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #if defined(_WIN32) #ifndef NOMINMAX #define NOMINMAX #endif #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN #endif #include #if !defined(UWP_TARGET) #include #endif #elif !defined(__EMSCRIPTEN__) && !defined(__ANDROID__) && !defined(PSVITA_TARGET) #if defined(__has_include) #if __has_include() #include #include #define PSXE_HAS_EXECINFO 1 #endif #endif #endif #if defined(__EMSCRIPTEN__) #include #endif #if defined(__ANDROID__) #include #endif extern "C" { #include "../psx/psx.h" #include "../psx/dev/cdrom/cdrom.h" #include "../psx/dev/gpu.h" #include "../psx/dev/input.h" #include "../psx/dev/pad.h" #include "../psx/dev/timer.h" #include "../psx/input/sda.h" #include "common.h" #include "config.h" #include "toml.h" } #include "IconsFontAwesome5.h" #include "fsui/backend_sdl.hpp" #include "fsui/fsui.hpp" #include "fsui/imgui_fullscreen.hpp" #include "imgui.h" #undef main namespace { constexpr Uint32 kPauseChordGraceMs = 120; constexpr std::string_view kCustomFsuiAppIconPath = "icons/FsuiAppIcon.png"; 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 } class ArmsxApp; ArmsxApp* g_active_app = nullptr; std::atomic_bool g_crash_reporting{false}; std::mutex g_pending_launch_lock; std::vector g_pending_launch_arguments; constexpr double kUiFrameRate = 60.0; 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; } 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(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(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(ticks) * 1000.0) / static_cast(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* 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 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 lock(g_pending_launch_lock); g_pending_launch_arguments.push_back(std::move(argument)); } std::vector DrainPendingLaunchArguments() { std::lock_guard lock(g_pending_launch_lock); std::vector pending; pending.swap(g_pending_launch_arguments); return pending; } 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(); bool texture_scale_mode = false; bool debug_panel = false; bool stretch_mode = false; int display_aspect = 0; int upscale_height = 480; fsui::UiState ui_state{ .theme = "Dark", .prompt_icon_pack = fsui::PromptIconPack::Auto, .background_image_path = {}, .default_game_view = 0, .game_sort = 2, .game_sort_reverse = false, .game_list_paths = {}, .game_list_recursive_paths = {}, .covers_path = {}, .show_inputs_overlay = false, .show_settings_overlay = false, .show_performance_overlay = false, }; }; 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(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(value[begin]))) { begin++; } while ((end > begin) && std::isspace(static_cast(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(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& 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(input)), std::istreambuf_iterator()); 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(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(value[index]); const unsigned char rhs = static_cast(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(value[index]); if ((ch == '%') && ((index + 2) < value.size())) { const int hi = HexValue(static_cast(value[index + 1])); const int lo = HexValue(static_cast(value[index + 2])); if (hi >= 0 && lo >= 0) { decoded.push_back(static_cast((hi << 4) | lo)); index += 2; continue; } } decoded.push_back(ch == '+' ? ' ' : static_cast(ch)); } return decoded; } std::optional 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; } bool ReadSdlFileBytes(std::string_view path, std::vector& bytes) { SDL_RWops* handle = SDL_RWFromFile(std::string(path).c_str(), "rb"); if (!handle) { return false; } bytes.clear(); std::array buffer{}; for (;;) { const size_t read = SDL_RWread(handle, buffer.data(), 1, buffer.size()); if (read == 0) { break; } bytes.insert(bytes.end(), buffer.begin(), buffer.begin() + static_cast(read)); } SDL_RWclose(handle); return !bytes.empty(); } std::string MaterializeBundledFsuiAppIcon() { const std::filesystem::path destination = DefaultBrowseDirectory() / kCustomFsuiAppIconPath; std::error_code ec; if (std::filesystem::exists(destination, ec) && std::filesystem::is_regular_file(destination, ec)) { return destination.string(); } std::vector bytes; if (!ReadSdlFileBytes(kCustomFsuiAppIconPath, bytes)) { return {}; } std::filesystem::create_directories(destination.parent_path(), ec); if (ec) { return {}; } std::ofstream output(destination, std::ios::binary | std::ios::trunc); if (!output.good()) { return {}; } output.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); if (!output.good()) { return {}; } return destination.string(); } std::string ResolveFsuiAppIconPath() { const std::filesystem::path relative(kCustomFsuiAppIconPath); std::error_code ec; auto try_path = [&](const std::filesystem::path& candidate) -> std::string { if (candidate.empty()) { return {}; } ec.clear(); if (std::filesystem::exists(candidate, ec) && std::filesystem::is_regular_file(candidate, ec)) { return candidate.lexically_normal().string(); } return {}; }; #ifdef PSVITA_TARGET const char* vita_base = "app0:/"; struct VitaBaseDeleter { void operator()(const char*) const {} }; std::unique_ptr base_path(vita_base); #else std::unique_ptr base_path(SDL_GetBasePath(), &SDL_free); #endif if (base_path) { const std::filesystem::path base(base_path.get()); if (std::string resolved = try_path(base / relative); !resolved.empty()) { return resolved; } if (std::string resolved = try_path((base / ".." / "Resources" / relative).lexically_normal()); !resolved.empty()) { return resolved; } } if (std::string resolved = try_path(relative); !resolved.empty()) { return resolved; } if (std::string resolved = try_path(std::filesystem::current_path(ec) / relative); !resolved.empty()) { return resolved; } if (std::string resolved = MaterializeBundledFsuiAppIcon(); !resolved.empty()) { return resolved; } return std::string(kCustomFsuiAppIconPath); } bool IsDiscPath(const std::filesystem::path& path) { const std::string ext = ToLower(path.extension().string()); return ext == ".cue" || ext == ".bin" || ext == ".iso" || ext == ".img"; } bool IsExePath(const std::filesystem::path& path) { const std::string ext = ToLower(path.extension().string()); return ext == ".exe" || ext == ".ps-exe" || ext == ".psexe"; } 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); } 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"; default: return "classic"; } } const char* AspectTitle(int aspect) { switch (aspect) { case 1: return "Square"; case 2: return "Wide 16:9"; default: return "Classic"; } } 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"; } } 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; } 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 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 europe_models = { "scph1002", "scph5502", "scph5552", "scph7002", "scph7502", "scph9002", "scph102a", "scph102b", }; static const std::array 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 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 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(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 kind = std::nullopt; if (const std::optional 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 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 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 { request = LaunchForPath(std::filesystem::path(trimmed)); } 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; } std::vector BuildChoices(const std::vector& titles, int selected_index) { std::vector options; options.reserve(titles.size()); for (size_t index = 0; index < titles.size(); index++) { fsui::SettingsChoiceOption option; option.title = titles[index]; option.selected = (static_cast(index) == selected_index); options.push_back(std::move(option)); } return options; } std::vector BuildAspectChoices(int current) { return BuildChoices({"Classic", "Square", "Wide 16:9"}, current); } std::vector BuildUpscaleChoices(int current_height) { const std::vector heights = {480, 720, 1080, 1440, 2160}; const std::vector labels = {"480p", "720p", "1080p", "1440p", "2160p"}; int selected = 0; for (size_t index = 0; index < heights.size(); index++) { if (heights[index] == current_height) { selected = static_cast(index); break; } } return BuildChoices(labels, selected); } std::vector BuildScaleChoices(int current_scale) { const std::vector scales = {1, 2, 3, 4, 5, 6}; std::vector labels; labels.reserve(scales.size()); int selected = 0; for (size_t index = 0; index < scales.size(); index++) { labels.push_back(std::to_string(scales[index]) + "x"); if (scales[index] == current_scale) { selected = static_cast(index); } } return BuildChoices(labels, selected); } std::vector BuildRegionChoices(const std::string& current_region) { const std::vector values = {"auto", "ntsc", "pal"}; const std::vector labels = {"Auto", "NTSC", "PAL"}; const std::string current = ToLower(current_region); int selected = 0; for (size_t index = 0; index < values.size(); index++) { if (values[index] == current) { selected = static_cast(index); break; } } return BuildChoices(labels, selected); } std::vector 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::vector BuildModelChoices(const std::string& current_model) { const std::vector models = ModelChoices(); const std::string current = NormalizeModel(current_model); int selected = 0; for (size_t index = 0; index < models.size(); index++) { if (NormalizeModel(models[index]) == current) { selected = static_cast(index); break; } } return BuildChoices(models, selected); } std::vector BuildLogLevelChoices(int current_level) { const std::vector labels = {"Trace", "Debug", "Info", "Warn", "Error", "Fatal"}; const int clamped = std::clamp(current_level, static_cast(LOG_TRACE), static_cast(LOG_FATAL)); return BuildChoices(labels, clamped); } std::time_t FileTimeToTimeT(const std::filesystem::file_time_type& value) { using namespace std::chrono; const auto adjusted = time_point_cast( 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& out) { out.clear(); if (!array) { return; } const int count = toml_array_nelem(array); out.reserve(static_cast(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); } } 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("", "--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; } 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(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(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; } } } 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; } 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 { settings.display_aspect = 0; } free(display_aspect.u.s); } 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* 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); } 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(default_view.u.i); } if (sort.ok) { settings.ui_state.game_sort = static_cast(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; 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.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; settings.upscale_height = cfg ? cfg->upscale_height : 480; 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); settings.scale = std::max(1, settings.scale); settings.log_level = std::clamp(settings.log_level, static_cast(LOG_TRACE), static_cast(LOG_FATAL)); settings.logging_enabled = !settings.quiet; 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; } bool SaveSettings(const FrontendSettings& 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& paths) { out << "["; for (size_t index = 0; index < paths.size(); index++) { if (index) { out << ", "; } out << "\"" << EscapeTomlString(paths[index].string()) << "\""; } 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" << "[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\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" << " 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" << " wide_upscale = \"" << UpscaleToString(settings.upscale_height) << "\"\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)); 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++) { const uint32_t value = psx_spu_get_sample(spu); const int16_t left = static_cast(value & 0xffff); const int16_t right = static_cast(value >> 16); *reinterpret_cast(&buffer[(sample << 2) + 0]) += left; *reinterpret_cast(&buffer[(sample << 2) + 2]) += right; } } class ArmsxSession { public: ~ArmsxSession() { destroy(); } bool create(SDL_Renderer* renderer, const FrontendSettings& settings, const LaunchRequest& request, std::string& error) { destroy(); if (!renderer) { error = "SDL renderer is not initialized."; return false; } renderer_ = 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 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_gpu_t* gpu = psx_get_gpu(psx_); 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_cdrom_set_region(psx_get_cdrom(psx_), RegionFromSettings(settings)); input_ = psx_input_create(); if (input_) { psx_input_init(input_); } pad_device_ = psxi_sda_create(); if (!input_ || !pad_device_) { error = "Failed to create the controller input bridge."; destroy(); return false; } psxi_sda_init(pad_device_, SDA_MODEL_DIGITAL); psxi_sda_init_input(pad_device_, input_); psx_pad_attach_joy(psx_->pad, 0, input_); pad_device_ = nullptr; 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; 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() ); SDL_AudioSpec desired{}; SDL_AudioSpec obtained{}; desired.freq = 44100; desired.format = AUDIO_S16SYS; desired.channels = 2; desired.samples = CD_SECTOR_SIZE >> 2; desired.callback = AudioUpdate; desired.userdata = this; audio_dev_ = SDL_OpenAudioDevice(nullptr, 0, &desired, &obtained, 0); paused_ = false; fast_forward_enabled_ = false; debug_view_ = false; texture_width_ = 0; texture_height_ = 0; texture_format_ = SDL_PIXELFORMAT_UNKNOWN; if (audio_dev_) { audio_sample_accumulator_ = 0.0; audio_queue_.clear(); audio_queue_read_offset_ = 0; updateAudioPlaybackState(); } updateTexture(settings); return true; } void destroy() { 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() ); } if (audio_dev_) { SDL_LockAudioDevice(audio_dev_); resetAudioQueueLocked(); SDL_UnlockAudioDevice(audio_dev_); SDL_PauseAudioDevice(audio_dev_, 1); SDL_CloseAudioDevice(audio_dev_); audio_dev_ = 0; } if (texture_) { SDL_DestroyTexture(texture_); texture_ = nullptr; } const bool input_attached = psx_ && psx_->pad && (psx_->pad->joy_slot[0] == input_); if (psx_) { psx_destroy(psx_); psx_ = nullptr; } if (input_ && !input_attached) { psx_input_destroy(input_); } if (pad_device_) { psxi_sda_destroy(pad_device_); pad_device_ = nullptr; } input_ = nullptr; renderer_ = 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; } bool valid() const { return psx_ != nullptr; } psx_t* psx() const { return psx_; } psx_pad_t* pad() const { return psx_ ? psx_->pad : nullptr; } void rebindRenderer(SDL_Renderer* renderer, const FrontendSettings& settings) { renderer_ = renderer; if (texture_) { SDL_DestroyTexture(texture_); texture_ = nullptr; } texture_width_ = 0; texture_height_ = 0; texture_format_ = SDL_PIXELFORMAT_UNKNOWN; updateTexture(settings); } void setPaused(bool paused) { paused_ = paused; if (audio_dev_) { if (paused || fast_forward_enabled_) { SDL_LockAudioDevice(audio_dev_); resetAudioQueueLocked(); SDL_UnlockAudioDevice(audio_dev_); } updateAudioPlaybackState(); } } bool paused() const { return paused_; } void setFastForwardEnabled(bool enabled) { if (fast_forward_enabled_ == enabled) { return; } fast_forward_enabled_ = enabled; clearQueuedAudio(); if (audio_dev_) { updateAudioPlaybackState(); } } bool fastForwardEnabled() const { return fast_forward_enabled_; } void runFrame() { if (!psx_ || paused_) { return; } 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()); return; } } queueAudioForFrame(); } 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(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); 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; } fsui::CurrentGameInfo currentGameInfo() const { fsui::CurrentGameInfo 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; } std::vector settingsOverlayLines(const FrontendSettings& settings) const { std::vector lines; lines.push_back(fsui::OverlayTextLine{.text = std::string("Aspect: ") + AspectTitle(settings.display_aspect)}); lines.push_back(fsui::OverlayTextLine{.text = std::string("Video: ") + RendererValueTitle(settings)}); lines.push_back(fsui::OverlayTextLine{.text = std::string("VSync: ") + BoolTitle(settings.vsync_enabled)}); lines.push_back(fsui::OverlayTextLine{.text = std::string("Debug View: ") + BoolTitle(debug_view_)}); lines.push_back(fsui::OverlayTextLine{.text = std::string("Fast Forward: ") + (fast_forward_enabled_ ? "2x" : "Off")}); return lines; } std::vector performanceOverlayLines(const FrontendSettings& settings) const { (void)settings; std::vector lines; char fps[64] = {}; char video[64] = {}; std::snprintf(fps, sizeof(fps), "FPS: %.1f", ImGui::GetIO().Framerate); std::snprintf(video, sizeof(video), "Video: %dx%d", texture_width_, texture_height_); lines.push_back(fsui::OverlayTextLine{.text = fps}); lines.push_back(fsui::OverlayTextLine{.text = video}); lines.push_back(fsui::OverlayTextLine{.text = std::string("State: ") + (paused_ ? "Paused" : "Running")}); if (fast_forward_enabled_) { lines.push_back(fsui::OverlayTextLine{.text = "Speed: 2x"}); } return lines; } void updateTexture(const FrontendSettings& settings) { if (!psx_ || !renderer_) { return; } const int next_width = debug_view_ ? PSX_GPU_FB_WIDTH : static_cast(psx_get_display_width(psx_)); const int next_height = debug_view_ ? PSX_GPU_FB_HEIGHT : static_cast(psx_get_display_height(psx_)); const Uint32 next_format = debug_view_ || !psx_get_display_format(psx_) ? SDL_PIXELFORMAT_BGR555 : SDL_PIXELFORMAT_RGB24; if ((next_width != texture_width_) || (next_height != texture_height_) || (next_format != texture_format_) || !texture_) { if (texture_) { SDL_DestroyTexture(texture_); texture_ = nullptr; } texture_ = SDL_CreateTexture(renderer_, next_format, SDL_TEXTUREACCESS_STREAMING, next_width, next_height); texture_width_ = next_width; texture_height_ = next_height; texture_format_ = next_format; } if (!texture_) { return; } if (settings.texture_scale_mode) { #if SDL_VERSION_ATLEAST(2, 0, 12) SDL_SetTextureScaleMode(texture_, SDL_ScaleModeLinear); #endif } else { #if SDL_VERSION_ATLEAST(2, 0, 12) SDL_SetTextureScaleMode(texture_, SDL_ScaleModeNearest); #endif } void* display_buffer = debug_view_ ? psx_get_vram(psx_) : psx_get_display_buffer(psx_); if (!debug_view_ && ((psx_->gpu->disp_y + texture_height_) > PSX_GPU_FB_HEIGHT)) { display_buffer = psx_get_vram(psx_); } SDL_UpdateTexture(texture_, nullptr, display_buffer, PSX_GPU_FB_STRIDE); } void draw(const FrontendSettings& settings) { if (!texture_) { return; } const ImVec2 display = ImGui::GetIO().DisplaySize; const float display_width = std::max(display.x, 1.0f); const float display_height = std::max(display.y, 1.0f); float aspect = 4.0f / 3.0f; if (debug_view_) { aspect = static_cast(texture_width_) / static_cast(std::max(texture_height_, 1)); } else if (settings.display_aspect == 2) { aspect = 16.0f / 9.0f; } else if (settings.display_aspect == 1) { aspect = 1.0f; } else { aspect = static_cast(psx_get_display_aspect(psx_)); } float target_width = display_width; float target_height = display_height; float offset_x = 0.0f; float offset_y = 0.0f; if (!settings.stretch_mode) { target_width = display_width; target_height = target_width / aspect; if (target_height > display_height) { target_height = display_height; target_width = target_height * aspect; } offset_x = (display_width - target_width) * 0.5f; offset_y = (display_height - target_height) * 0.5f; } ImDrawList* draw_list = ImGui::GetBackgroundDrawList(); draw_list->AddRectFilled(ImVec2(0.0f, 0.0f), display, IM_COL32(0, 0, 0, 255)); draw_list->AddImage( reinterpret_cast(texture_), ImVec2(offset_x, offset_y), ImVec2(offset_x + target_width, offset_y + target_height) ); } 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_; void* source = debug_view_ ? psx_get_vram(psx_) : psx_get_display_buffer(psx_); if (!debug_view_ && ((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(surface->pitch); const uint8_t* src = static_cast(source); uint8_t* dst = static_cast(surface->pixels); for (int row = 0; row < height; row++) { std::memcpy(dst + (static_cast(row) * static_cast(surface->pitch)), src + (static_cast(row) * static_cast(PSX_GPU_FB_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(psx_gpu_frame_rate(psx_->gpu)); } double targetFrameRate() const { return frameRate() * (fast_forward_enabled_ ? 2.0 : 1.0); } 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(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)); auto* session = static_cast(userdata); if (!session) { return; } session->consumeQueuedAudio(buffer, static_cast(size)); } void updateAudioPlaybackState() { if (!audio_dev_) { return; } SDL_PauseAudioDevice(audio_dev_, (paused_ || fast_forward_enabled_) ? 1 : 0); } void queueAudioForFrame() { if (!psx_ || !audio_dev_ || fast_forward_enabled_) { return; } audio_sample_accumulator_ += static_cast(kAudioMixRate) / frameRate(); const int sample_count = static_cast(audio_sample_accumulator_); if (sample_count <= 0) { return; } audio_sample_accumulator_ -= static_cast(sample_count); const size_t byte_count = static_cast(sample_count) * sizeof(int16_t) * 2; if (byte_count == 0) { return; } std::vector frame_audio(byte_count); MixPsxAudio(psx_, frame_audio.data(), static_cast(frame_audio.size())); SDL_LockAudioDevice(audio_dev_); compactAudioQueueLocked(); if ((audio_queue_.size() - audio_queue_read_offset_) > kMaxQueuedAudioBytes) { resetAudioQueueLocked(); } audio_queue_.insert(audio_queue_.end(), frame_audio.begin(), frame_audio.end()); SDL_UnlockAudioDevice(audio_dev_); } void consumeQueuedAudio(uint8_t* buffer, size_t size) { const size_t available = audio_queue_.size() - audio_queue_read_offset_; const size_t to_copy = std::min(size, available); if (to_copy > 0) { std::memcpy(buffer, audio_queue_.data() + audio_queue_read_offset_, to_copy); audio_queue_read_offset_ += to_copy; } if (audio_queue_read_offset_ >= audio_queue_.size()) { resetAudioQueueLocked(); } else if (audio_queue_read_offset_ >= kAudioQueueCompactThreshold) { compactAudioQueueLocked(); } } void clearQueuedAudio() { if (!audio_dev_) { resetAudioQueueLocked(); return; } SDL_LockAudioDevice(audio_dev_); resetAudioQueueLocked(); SDL_UnlockAudioDevice(audio_dev_); } void resetAudioQueueLocked() { audio_sample_accumulator_ = 0.0; audio_queue_.clear(); audio_queue_read_offset_ = 0; } void compactAudioQueueLocked() { if (audio_queue_read_offset_ == 0) { return; } if (audio_queue_read_offset_ >= audio_queue_.size()) { resetAudioQueueLocked(); return; } audio_queue_.erase(audio_queue_.begin(), audio_queue_.begin() + static_cast(audio_queue_read_offset_)); audio_queue_read_offset_ = 0; } static constexpr int kAudioMixRate = 44100; static constexpr size_t kMaxQueuedAudioBytes = static_cast(kAudioMixRate * sizeof(int16_t) * 2 / 2); static constexpr size_t kAudioQueueCompactThreshold = 4096; static constexpr std::uint32_t kMaxFrameSteps = PSX_CPU_CPS / 8u; SDL_Renderer* renderer_ = nullptr; psx_t* psx_ = nullptr; psx_input_t* input_ = nullptr; psxi_sda_t* pad_device_ = nullptr; SDL_Texture* texture_ = nullptr; SDL_AudioDeviceID audio_dev_ = 0; std::vector audio_queue_; size_t audio_queue_read_offset_ = 0; double audio_sample_accumulator_ = 0.0; std::filesystem::path disc_path_; std::filesystem::path exe_path_; std::string title_; LaunchKind launch_kind_ = LaunchKind::None; bool paused_ = false; bool fast_forward_enabled_ = false; bool debug_view_ = false; std::uint64_t vblank_counter_ = 0; int texture_width_ = 0; int texture_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()); } } std::vector buildInputOverlay() const { fsui::InputOverlayDeviceState device; device.title = "P1"; device.bindings = { fsui::InputOverlayBinding{.label = "Up", .glyph = "^", .kind = fsui::InputOverlayBindingKind::Button, .value = (active_digital_mask_ & PSXI_SW_SDA_PAD_UP) ? 1.0f : 0.0f}, fsui::InputOverlayBinding{.label = "Down", .glyph = "v", .kind = fsui::InputOverlayBindingKind::Button, .value = (active_digital_mask_ & PSXI_SW_SDA_PAD_DOWN) ? 1.0f : 0.0f}, fsui::InputOverlayBinding{.label = "Left", .glyph = "<", .kind = fsui::InputOverlayBindingKind::Button, .value = (active_digital_mask_ & PSXI_SW_SDA_PAD_LEFT) ? 1.0f : 0.0f}, fsui::InputOverlayBinding{.label = "Right", .glyph = ">", .kind = fsui::InputOverlayBindingKind::Button, .value = (active_digital_mask_ & PSXI_SW_SDA_PAD_RIGHT) ? 1.0f : 0.0f}, fsui::InputOverlayBinding{.label = "Start", .glyph = "S", .kind = fsui::InputOverlayBindingKind::Button, .value = (active_digital_mask_ & PSXI_SW_SDA_START) ? 1.0f : 0.0f}, fsui::InputOverlayBinding{.label = "Select", .glyph = "=", .kind = fsui::InputOverlayBindingKind::Button, .value = (active_digital_mask_ & PSXI_SW_SDA_SELECT) ? 1.0f : 0.0f}, }; return {device}; } 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(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((static_cast(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 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(external_window)), external_renderer_(static_cast(external_renderer)), cli_(ScanCliFlags(argc, argv)) {} int run() { 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)"); 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) { return 1; } psxe_cfg_init(cfg); psxe_cfg_load_defaults(cfg); psxe_cfg_load(cfg, argc_, const_cast(argv_)); settings_ = BuildSettings(cfg, cli_); psxe_cfg_destroy(cfg); applyLoggingSettings("startup"); 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); installCrashHandlers(); if (!initializeSdl() || !initializeWindowAndRenderer() || !initializeFsui()) { shutdown(); return 1; } logRendererBootstrap("frontend-init", kUiFrameRate); refreshGameList(true); g_active_app = this; initializeWebLaunchSupport(); consumePendingLaunchArguments(); if (cli_.has_boot_request) { if (pending_cli_launch_.has_value()) { launchSession(*pending_cli_launch_, false); } else if (!pending_cli_argument_.empty()) { pending_error_dialog_ = "Unsupported launch path or URI."; } } consumePendingLaunchArguments(); if (!session_.valid()) { showLandingWindow(); } fsui::SdlMainLoopCallbacks loop; loop.handle_event = [this](const SDL_Event& event) { handleEvent(event); }; loop.run_frame = [this]() { runFrame(); }; loop.should_continue = [this]() { return running_; }; loop.on_exit = [this]() { shutdown(); }; fsui::RunSdlMainLoop(loop); g_active_app = nullptr; return 0; } void writeCrashContext(const char* reason) const { const fsui::CurrentGameInfo 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 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& 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 forced_kind = std::nullopt) { const std::optional 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 selection = NormalizedPickerSelection(path); if (!selection.has_value()) { return; } deferred_change_disc_ = std::filesystem::path(*selection); } Uint32 managedRendererFlags(bool vsync_enabled) const { Uint32 renderer_flags = SDL_RENDERER_ACCELERATED; #if !defined(__EMSCRIPTEN__) if (vsync_enabled) { renderer_flags |= SDL_RENDERER_PRESENTVSYNC; } #endif return renderer_flags; } bool createManagedRenderer(bool vsync_enabled) { renderer_ = SDL_CreateRenderer(window_, -1, managedRendererFlags(vsync_enabled)); owns_renderer_ = renderer_ != nullptr; if (!renderer_) { psxe_diag_logf( "renderer", "Renderer initialization failed requested_vsync=%s error=%s", vsync_enabled ? "true" : "false", SDL_GetError() ); return false; } managed_renderer_vsync_ = vsync_enabled; return true; } void shutdownFsuiFrontend() { if (fsui_initialized_) { fsui::Shutdown(true); fsui_initialized_ = false; } ImGuiFullscreen::ClearFileSelectorExtraRoots(); imgui_backend_.shutdown(); if (ImGui::GetCurrentContext()) { ImGui::DestroyContext(); } } void showLandingWindow() { ui_window_state_ = FsuiWindowState::Landing; fsui::ShowLandingWindow(); } void showStartGameWindow() { ui_window_state_ = FsuiWindowState::StartGame; fsui::ShowStartGameWindow(); } void showExitWindow() { ui_window_state_ = FsuiWindowState::Exit; fsui::ShowExitWindow(); } void showGameListWindow() { ui_window_state_ = FsuiWindowState::GameList; fsui::ShowGameListWindow(); } void switchToSettingsWindow() { ui_window_state_ = FsuiWindowState::Settings; fsui::SwitchToSettings(); } void returnToMainWindow() { ui_window_state_ = session_.valid() ? FsuiWindowState::None : FsuiWindowState::Landing; fsui::ReturnToMainWindow(); } void showPauseMenuWindow() { ui_window_state_ = FsuiWindowState::PauseMenu; fsui::OpenPauseMenu(); } void restoreUiWindow() { switch (ui_window_state_) { case FsuiWindowState::Landing: showLandingWindow(); break; case FsuiWindowState::StartGame: showStartGameWindow(); break; case FsuiWindowState::Exit: showExitWindow(); break; case FsuiWindowState::GameList: showGameListWindow(); break; case FsuiWindowState::Settings: switchToSettingsWindow(); break; case FsuiWindowState::PauseMenu: showPauseMenuWindow(); break; case FsuiWindowState::None: default: break; } } 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 (renderer_) { SDL_DestroyRenderer(renderer_); renderer_ = 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(renderer_, 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 (renderer_) { SDL_DestroyRenderer(renderer_); renderer_ = 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(renderer_, 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() { 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 } 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( 1, static_cast(std::llround(static_cast(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(1, frequency / 2000u); uint64_t now = SDL_GetPerformanceCounter(); 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(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; if (reason && reason[0]) { psxe_diag_breadcrumbf("Frame pacing reset reason=%s", reason); } #else (void)reason; #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 (!renderer_) { return; } SDL_RendererInfo info{}; SDL_GetRendererInfo(renderer_, &info); int output_width = 0; int output_height = 0; SDL_GetRendererOutputSize(renderer_, &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", info.name ? info.name : "(unknown)", RendererFlagsTitle(info.flags).c_str(), 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 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 ); } bool initializeSdl() { const Uint32 required = SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_EVENTS | SDL_INIT_GAMECONTROLLER; psxe_diag_breadcrumbf("Initializing SDL subsystems flags=0x%x", required); if (SDL_WasInit(0) == 0) { owns_sdl_ = true; if (SDL_Init(required) != 0) { psxe_diag_logf("sdl", "SDL_Init failed: %s", SDL_GetError()); return false; } } else if (SDL_InitSubSystem(required) != 0) { psxe_diag_logf("sdl", "SDL_InitSubSystem failed: %s", SDL_GetError()); return false; } 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 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(std::lround(css_width))); window_height = std::max(1, static_cast(std::lround(css_height))); } #endif Uint32 flags = 0; if (SupportsManagedWindowSizing()) { flags |= SDL_WINDOW_RESIZABLE; } 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; } if (external_renderer_) { renderer_ = external_renderer_; owns_renderer_ = false; } else { renderer_ = nullptr; if (!createManagedRenderer(settings_.vsync_enabled)) { return false; } } if (!renderer_) { psxe_diag_logf("renderer", "Renderer initialization failed: %s", SDL_GetError()); } return renderer_ != nullptr; } bool initializeFsui() { IMGUI_CHECKVERSION(); ImGui::CreateContext(); ImGuiIO& io = ImGui::GetIO(); io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; io.IniFilename = nullptr; fsui::SdlImGuiBackendConfig backend_config; backend_config.window = window_; backend_config.sdl_renderer = renderer_; backend_config.renderer_backend = fsui::RendererBackend::SDLRenderer; #if defined(__EMSCRIPTEN__) // SDL_Renderer on Emscripten renders correctly when the canvas backing store // matches the CSS size instead of a DPR-scaled framebuffer. backend_config.web_canvas.max_device_pixel_ratio = 1.0; #endif if (!imgui_backend_.initialize(backend_config)) { return false; } #if defined(__EMSCRIPTEN__) fsui::SyncSdl2WebCanvasLayout(window_, backend_config.web_canvas, true); #endif fsui::FontStack fonts; if (!fsui::BuildDefaultFontStack(fsui::DescribeSdl2FontBootstrap(window_), fonts)) { return false; } fsui::UiContext context; context.window = window_; context.fonts = fonts; context.app_title = "ARMSX"; context.app_icon_path = ResolveFsuiAppIconPath(); context.platform_backend = fsui::PlatformBackendKind::SDL2; context.renderer_backend = fsui::RendererBackend::SDLRenderer; fsui::Host host; host.ui_state = &settings_.ui_state; host.has_running_game = [this]() { return session_.valid(); }; host.get_current_game = [this]() { return session_.currentGameInfo(); }; host.get_game_list = [this]() { return game_list_; }; host.refresh_game_list = [this](bool full_rescan) { refreshGameList(full_rescan); }; host.persist_ui_state = [this](bool reload) { if (reload) { refreshGameList(true); } SaveSettings(settings_); }; host.set_paused = [this](bool paused) { if (!session_.valid()) { return; } session_.setPaused(paused); resetFramePacing(paused ? "fsui-pause" : "fsui-unpause"); if (paused) { input_router_.onFsuiOpened(); } }; host.resume_game = [this]() { if (session_.valid()) { session_.setPaused(false); resetFramePacing("fsui-resume"); } }; host.reset_system = [this]() { if (session_.valid()) { session_.reset(); resetFramePacing("fsui-reset"); } }; host.exit_to_library = [this]() { exitToLibrary(); }; host.request_quit = [this]() { running_ = false; }; host.request_classic_ui = []() {}; host.close_selector = []() {}; host.detect_prompt_icon_pack = []() { return fsui::DetectPromptIconPackFromSDL(); }; host.detect_swap_north_west_gamepad_buttons = []() { return false; }; host.runtime_overlay_options = fsui::RuntimeOverlayOptions{ .show_inputs = true, .show_settings = true, .show_performance = true, }; host.get_input_overlay_devices = [this]() { return input_router_.buildInputOverlay(); }; host.get_settings_overlay_lines = [this]() { return session_.settingsOverlayLines(settings_); }; host.get_performance_overlay_lines = [this]() { return session_.performanceOverlayLines(settings_); }; host.get_landing_items = [this]() { return buildLandingItems(); }; host.get_start_items = [this]() { return buildStartItems(); }; host.get_exit_items = [this]() { return buildExitItems(); }; host.get_pause_items = [this]() { return buildPauseItems(); }; host.get_game_launch_options = [this](const fsui::GameEntry& entry) { return buildGameLaunchOptions(entry); }; host.get_settings_pages = [this](fsui::SettingsScope scope) { return buildSettingsPages(scope); }; context.host = std::move(host); ImGuiFullscreen::SetFileSelectorExtraRoots({ ImGuiFullscreen::FileSelectorRootEntry{ .title = "App Folder", .path = DefaultBrowseDirectory().string(), }, }); if (!fsui::Initialize(context)) { return false; } fsui_initialized_ = true; return true; } void shutdown() { psxe_diag_breadcrumbf("Frontend shutdown"); input_router_.detach(); session_.destroy(); shutdownFsuiFrontend(); if (owns_renderer_ && renderer_) { SDL_DestroyRenderer(renderer_); } if (owns_window_ && window_) { SDL_DestroyWindow(window_); } renderer_ = nullptr; window_ = nullptr; if (owns_sdl_) { SDL_Quit(); } psxe_diag_shutdown(); } void handleEvent(const SDL_Event& event) { imgui_backend_.processEvent(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; } const bool fsui_active = fsui::HasActiveWindow(); input_router_.processEvent(event, session_.valid() ? &session_ : nullptr, fsui_active); if (event.type == SDL_KEYDOWN && !fsui_active && session_.valid() && event.key.repeat == 0 && event.key.keysym.sym == SDLK_RETURN) { psx_exp2_atcons_put(session_.psx()->exp2, 13); } if (session_.valid() && !fsui_active && input_router_.takePauseRequest()) { openPauseMenu(); } } void runFrame() { const bool fsui_was_active = fsui::HasActiveWindow(); input_router_.tick(fsui_was_active); 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(); waitForFrameDeadline(currentTargetFrameRate()); if (session_.valid() && !session_.paused()) { session_.runFrame(); session_.updateTexture(settings_); } imgui_backend_.newFrame(); ImGui::NewFrame(); if (session_.valid()) { session_.draw(settings_); } if (pending_error_dialog_.has_value()) { ImGuiFullscreen::OpenInfoMessageDialog("ARMSX", *pending_error_dialog_); pending_error_dialog_.reset(); } fsui::Render(); for (const fsui::Command& command : fsui::ConsumeCommands()) { applyCommand(command); } applyDeferredActions(); ImGui::Render(); imgui_backend_.renderDrawData(ImVec4(0.0f, 0.0f, 0.0f, 1.0f)); const bool fsui_is_active = fsui::HasActiveWindow(); if (fsui_was_active && !fsui_is_active) { ui_window_state_ = FsuiWindowState::None; if (session_.valid()) { input_router_.onFsuiClosed(); } } else if (!fsui_was_active && fsui_is_active && session_.valid()) { input_router_.onFsuiOpened(); } advanceFrameDeadline(); } void applyCommand(const fsui::Command& command) { psxe_diag_breadcrumbf("FSUI command type=%d", static_cast(command.type)); switch (command.type) { case fsui::CommandType::LaunchPath: queueLaunchRequest(LaunchForPath(command.path), true); break; case fsui::CommandType::RequestQuit: if (session_.valid()) { exitToLibrary(); } else { running_ = false; } break; case fsui::CommandType::Resume: if (session_.valid()) { ui_window_state_ = FsuiWindowState::None; session_.setPaused(false); resetFramePacing("command-resume"); } break; case fsui::CommandType::Reset: if (session_.valid()) { session_.reset(); session_.setPaused(false); resetFramePacing("command-reset"); } break; case fsui::CommandType::ExitToLibrary: exitToLibrary(); break; case fsui::CommandType::CloseSelector: case fsui::CommandType::ExitFullscreenUI: case fsui::CommandType::SwitchShell: case fsui::CommandType::Custom: default: break; } } 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); returnToMainWindow(); } } if (deferred_screenshot_) { deferred_screenshot_ = false; 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."; } } if (deferred_fast_forward_toggle_) { deferred_fast_forward_toggle_ = false; if (session_.valid()) { session_.setFastForwardEnabled(!session_.fastForwardEnabled()); session_.setPaused(false); resetFramePacing("toggle-fast-forward"); returnToMainWindow(); } } } 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(renderer_, 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); if (close_ui) { returnToMainWindow(); } return true; } void exitToLibrary() { psxe_diag_breadcrumbf("Exit to library requested"); input_router_.detach(); session_.destroy(); resetFramePacing("exit-to-library"); showLandingWindow(); } void openPauseMenu() { if (!session_.valid()) { return; } session_.setPaused(true); resetFramePacing("open-pause-menu"); input_router_.onFsuiOpened(); showPauseMenuWindow(); } void refreshGameList(bool full_rescan) { (void)full_rescan; game_list_.clear(); std::vector seen_game_paths; std::vector> scan_roots; auto add_scan_root = [&](const std::filesystem::path& root, bool recursive) { if (root.empty()) { return; } for (auto& entry : scan_roots) { if (PathsMatch(entry.first, root)) { entry.second = entry.second || recursive; return; } } scan_roots.emplace_back(root, recursive); }; auto scan_root = [&](const std::filesystem::path& root, bool recursive) { try { if (!std::filesystem::exists(root)) { return; } auto append_entry = [&](const std::filesystem::directory_entry& item) { if (!item.is_regular_file()) { return; } const std::filesystem::path path = item.path(); if (!IsDiscPath(path) && !IsExePath(path)) { return; } if (IsLikelyBiosImagePath(path, settings_)) { return; } if (ToLower(path.extension().string()) == ".bin" && CueDirectoryReferencesImage(path)) { return; } if (PathListContains(seen_game_paths, path)) { return; } seen_game_paths.push_back(path); fsui::GameEntry entry; entry.path = path; entry.type = IsExePath(path) ? fsui::GameEntryType::Homebrew : fsui::GameEntryType::Application; entry.title = StemToTitle(path); entry.title_sort = ToLower(entry.title); entry.english_title = entry.title; entry.title_id = NormalizeModel(entry.title); entry.file_size = item.file_size(); entry.modified_time = FileTimeToTimeT(item.last_write_time()); const int region = RegionFromSettings(settings_); if (region == CDR_REGION_EUROPE) { entry.region = "Europe"; entry.region_flag_path = "icons/flags/eu.png"; } else if (region == CDR_REGION_JAPAN) { entry.region = "Japan"; entry.region_flag_path = "icons/flags/jp.png"; } else { entry.region = "North America"; entry.region_flag_path = "icons/flags/us.png"; } game_list_.push_back(std::move(entry)); }; if (recursive) { for (const auto& item : std::filesystem::recursive_directory_iterator(root)) { append_entry(item); } } else { for (const auto& item : std::filesystem::directory_iterator(root)) { append_entry(item); } } } catch (...) { } }; for (const auto& root : settings_.ui_state.game_list_paths) { add_scan_root(root, false); } for (const auto& root : settings_.ui_state.game_list_recursive_paths) { add_scan_root(root, true); } #if defined(UWP_TARGET) add_scan_root(DefaultBrowseDirectory(), true); #endif for (const auto& [root, recursive] : scan_roots) { scan_root(root, recursive); } std::sort(game_list_.begin(), game_list_.end(), [](const fsui::GameEntry& left, const fsui::GameEntry& right) { if (left.title_sort == right.title_sort) { return left.path < right.path; } return left.title_sort < right.title_sort; }); } std::vector buildLandingItems() { std::vector items; fsui::MenuItemDescriptor game_list; game_list.id = "game-list"; game_list.icon_path = fsui::GetBuiltInStartupIconPath(fsui::BuiltInStartupIcon::GameList); game_list.title = "Game List"; game_list.summary = "Browse library folders and boot a title through the native SDL shell."; game_list.on_activate = [this]() { showGameListWindow(); }; items.push_back(std::move(game_list)); fsui::MenuItemDescriptor start; start.id = "start-game"; start.icon_path = fsui::GetBuiltInStartupIconPath(fsui::BuiltInStartupIcon::StartGame); start.title = "Start Game"; start.summary = "Boot a disc, PS-X EXE, or the BIOS directly."; start.on_activate = [this]() { showStartGameWindow(); }; items.push_back(std::move(start)); fsui::MenuItemDescriptor settings; settings.id = "settings"; settings.icon_path = fsui::GetBuiltInStartupIconPath(fsui::BuiltInStartupIcon::Settings); settings.title = "Settings"; settings.summary = "Edit the real ARMSX runtime, BIOS, video, and library settings."; settings.on_activate = [this]() { switchToSettingsWindow(); }; items.push_back(std::move(settings)); fsui::MenuItemDescriptor exit; exit.id = "exit"; exit.icon_path = fsui::GetBuiltInStartupIconPath(fsui::BuiltInStartupIcon::Exit); exit.title = "Exit"; exit.summary = "Quit ARMSX."; exit.on_activate = [this]() { showExitWindow(); }; items.push_back(std::move(exit)); return items; } std::vector buildStartItems() { std::vector items; auto default_dir = initialBrowseDirectory(); fsui::MenuItemDescriptor start_file; start_file.id = "start-file"; start_file.icon_path = fsui::GetBuiltInStartupIconPath(fsui::BuiltInStartupIcon::StartFile); start_file.title = "Start File"; start_file.summary = "Pick a disc image or PS-X EXE from the managed filesystem."; start_file.on_activate = [this, default_dir]() { ImGuiFullscreen::OpenFileSelector( "Start File", false, [this](const std::string& path) { queueLaunchSelection(path); }, {".cue", ".bin", ".iso", ".img", ".exe", ".ps-exe", ".psexe"}, default_dir.string() ); }; items.push_back(std::move(start_file)); if (!settings_.default_exe_path.empty()) { fsui::MenuItemDescriptor start_saved_exe; start_saved_exe.id = "start-default-exe"; start_saved_exe.icon_path = fsui::GetBuiltInStartupIconPath(fsui::BuiltInStartupIcon::StartFile); start_saved_exe.title = "Start Saved PS-X EXE"; start_saved_exe.summary = settings_.default_exe_path; start_saved_exe.on_activate = [this]() { deferred_launch_ = LaunchRequest{ .kind = LaunchKind::Exe, .path = std::filesystem::path(settings_.default_exe_path), .label = StemToTitle(settings_.default_exe_path), }; close_ui_after_launch_ = true; }; items.push_back(std::move(start_saved_exe)); } fsui::MenuItemDescriptor start_disc; start_disc.id = "start-disc"; start_disc.icon_path = fsui::GetBuiltInStartupIconPath(fsui::BuiltInStartupIcon::StartDisc); start_disc.title = "Start Disc"; start_disc.summary = "Pick a disc image directly."; start_disc.on_activate = [this, default_dir]() { ImGuiFullscreen::OpenFileSelector( "Start Disc", false, [this](const std::string& path) { queueLaunchSelection(path, LaunchKind::Disc); }, {".cue", ".bin", ".iso", ".img"}, default_dir.string() ); }; items.push_back(std::move(start_disc)); fsui::MenuItemDescriptor start_bios; start_bios.id = "start-bios"; start_bios.icon_path = fsui::GetBuiltInStartupIconPath(fsui::BuiltInStartupIcon::StartBios); start_bios.title = "Start BIOS"; start_bios.summary = "Boot into the BIOS without media."; start_bios.on_activate = [this]() { deferred_launch_ = LaunchRequest{.kind = LaunchKind::Bios, .path = {}, .label = "PlayStation BIOS"}; close_ui_after_launch_ = true; }; items.push_back(std::move(start_bios)); fsui::MenuItemDescriptor back; back.id = "back"; back.icon_path = fsui::GetBuiltInStartupIconPath(fsui::BuiltInStartupIcon::Back); back.title = "Back"; back.summary = "Return to the landing screen."; back.on_activate = [this]() { showLandingWindow(); }; items.push_back(std::move(back)); return items; } std::vector buildExitItems() { std::vector items; fsui::MenuItemDescriptor quit; quit.id = "quit"; quit.icon_path = fsui::GetBuiltInStartupIconPath(fsui::BuiltInStartupIcon::Exit); quit.title = "Quit"; quit.summary = "Close ARMSX."; quit.command = fsui::MakeRequestQuitCommand(); items.push_back(std::move(quit)); fsui::MenuItemDescriptor back; back.id = "back"; back.icon_path = fsui::GetBuiltInStartupIconPath(fsui::BuiltInStartupIcon::Back); back.title = "Back"; back.summary = "Return to the landing screen."; back.on_activate = [this]() { showLandingWindow(); }; items.push_back(std::move(back)); return items; } std::vector buildPauseItems() { std::vector items; fsui::MenuItemDescriptor resume; resume.id = "resume"; resume.title = ICON_FA_PLAY " Resume"; resume.summary = "Return to gameplay."; resume.command = fsui::MakeResumeCommand(); items.push_back(std::move(resume)); fsui::MenuItemDescriptor reset; reset.id = "reset"; reset.title = ICON_FA_SYNC " Reset"; reset.summary = "Soft-reset the active session."; reset.command = fsui::MakeResetCommand(); items.push_back(std::move(reset)); fsui::MenuItemDescriptor change_disc; change_disc.id = "change-disc"; change_disc.title = ICON_FA_COMPACT_DISC " Change Disc"; change_disc.summary = "Swap to another disc image and resume."; change_disc.on_activate = [this]() { ImGuiFullscreen::OpenFileSelector( "Change Disc", false, [this](const std::string& path) { queueDiscSwapSelection(path); }, {".cue", ".bin", ".iso", ".img"}, initialBrowseDirectory().string() ); }; items.push_back(std::move(change_disc)); fsui::MenuItemDescriptor screenshot; screenshot.id = "screenshot"; screenshot.title = ICON_FA_CAMERA " Save Screenshot"; screenshot.summary = "Write a BMP screenshot to the managed snapshot folder."; screenshot.on_activate = [this]() { deferred_screenshot_ = true; }; items.push_back(std::move(screenshot)); fsui::MenuItemDescriptor settings; settings.id = "settings"; settings.title = ICON_FA_SLIDERS_H " Settings"; settings.summary = "Open the in-game settings pages."; settings.on_activate = [this]() { switchToSettingsWindow(); }; items.push_back(std::move(settings)); fsui::MenuItemDescriptor fast_forward; fast_forward.id = "fast-forward"; fast_forward.title = ICON_FA_FAST_FORWARD " Fast Forward"; fast_forward.summary = session_.fastForwardEnabled() ? "Current speed is 2x. Click to return to normal speed and resume gameplay." : "Current speed is normal. Click to resume gameplay at 2x."; fast_forward.on_activate = [this]() { deferred_fast_forward_toggle_ = true; }; items.push_back(std::move(fast_forward)); fsui::MenuItemDescriptor quit; quit.id = "quit-game"; quit.title = ICON_FA_POWER_OFF " Quit Game"; quit.summary = "Destroy the active session and return to the FSUI shell."; quit.command = fsui::MakeExitToLibraryCommand(); items.push_back(std::move(quit)); return items; } std::vector buildGameLaunchOptions(const fsui::GameEntry&) { return {}; } std::vector buildSettingsPages(fsui::SettingsScope scope) { std::vector pages; if (scope == fsui::SettingsScope::PerGame) { pages = buildPerGameSettingsPages(); } else { pages = buildGlobalSettingsPages(); } if (pending_settings_page_restore_.has_value()) { const std::string restore_id = *pending_settings_page_restore_; const auto match = std::find_if(pages.begin(), pages.end(), [&](const fsui::SettingsPageDescriptor& page) { return page.id == restore_id; }); if (match != pages.end()) { std::rotate(pages.begin(), match, match + 1); } pending_settings_page_restore_.reset(); } return pages; } std::vector buildGlobalSettingsPages() { std::vector pages; fsui::SettingsPageDescriptor bios_page; bios_page.id = "BIOS"; bios_page.scope = fsui::SettingsScope::Global; bios_page.built_in_page = fsui::BuiltInSettingsPage::BIOS; bios_page.build_rows = [this]() { std::vector rows; fsui::SettingsRowDescriptor model; model.kind = fsui::SettingsRowKind::Choice; model.id = "model"; model.title = ICON_FA_MICROCHIP " Model"; model.summary = "Select the preferred BIOS model when using a BIOS folder."; model.value = settings_.model; model.dialog_title = model.title; model.choices = BuildModelChoices(settings_.model); model.on_choice = [this](int index) { const auto models = ModelChoices(); if (index >= 0 && index < static_cast(models.size())) { settings_.model = models[static_cast(index)]; SaveSettings(settings_); } }; rows.push_back(std::move(model)); fsui::SettingsRowDescriptor folder; folder.kind = fsui::SettingsRowKind::Action; folder.id = "bios-folder"; folder.title = ICON_FA_FOLDER " BIOS Folder"; if (settings_.bios_search.empty()) { folder.summary = AppendBrowseRootHint("Choose a folder that contains BIOS files."); } else { folder.summary = settings_.bios_search; } folder.on_activate = [this]() { ImGuiFullscreen::OpenFileSelector( "BIOS Folder", true, [this](const std::string& path) { if (const std::optional selection = NormalizedPickerSelection(path)) { settings_.bios_search = *selection; SaveSettings(settings_); } }, {}, browseDirectoryForPath(settings_.bios_search, true).string() ); }; rows.push_back(std::move(folder)); fsui::SettingsRowDescriptor override; override.kind = fsui::SettingsRowKind::Action; override.id = "bios-override"; override.title = ICON_FA_FILE " BIOS Override"; override.summary = settings_.bios_override.empty() ? "Auto-select from the BIOS folder." : settings_.bios_override; override.on_activate = [this]() { const std::string browse_seed = settings_.bios_override.empty() ? settings_.bios_search : settings_.bios_override; ImGuiFullscreen::OpenFileSelector( "BIOS Override", false, [this](const std::string& path) { if (const std::optional selection = NormalizedPickerSelection(path)) { settings_.bios_override = *selection; SaveSettings(settings_); } }, {".bin", ".rom"}, browseDirectoryForPath(browse_seed, false).string() ); }; rows.push_back(std::move(override)); fsui::SettingsRowDescriptor clear_override; clear_override.kind = fsui::SettingsRowKind::Action; clear_override.id = "clear-bios-override"; clear_override.title = ICON_FA_TIMES " Clear BIOS Override"; clear_override.summary = "Fall back to the BIOS folder + preferred model."; clear_override.enabled = !settings_.bios_override.empty(); clear_override.on_activate = [this]() { settings_.bios_override.clear(); SaveSettings(settings_); }; rows.push_back(std::move(clear_override)); return rows; }; pages.push_back(std::move(bios_page)); fsui::SettingsPageDescriptor emulation_page; emulation_page.id = "Emulation"; emulation_page.scope = fsui::SettingsScope::Global; emulation_page.built_in_page = fsui::BuiltInSettingsPage::Emulation; emulation_page.build_rows = [this]() { std::vector rows; fsui::SettingsRowDescriptor region; region.kind = fsui::SettingsRowKind::Choice; region.id = "region"; region.title = ICON_FA_GLOBE " Region"; region.summary = "Select the firmware region policy used for disc boot."; region.value = ToLower(settings_.region); region.dialog_title = region.title; region.choices = BuildRegionChoices(settings_.region); region.on_choice = [this](int index) { static const std::array values = {"auto", "ntsc", "pal"}; if (index >= 0 && index < static_cast(values.size())) { settings_.region = values[static_cast(index)]; SaveSettings(settings_); } }; rows.push_back(std::move(region)); fsui::SettingsRowDescriptor expansion; expansion.kind = fsui::SettingsRowKind::Action; expansion.id = "expansion-rom"; expansion.title = ICON_FA_MEMORY " Expansion ROM"; expansion.summary = settings_.exp_path.empty() ? "No expansion ROM selected." : settings_.exp_path; expansion.on_activate = [this]() { ImGuiFullscreen::OpenFileSelector( "Expansion ROM", false, [this](const std::string& path) { if (const std::optional selection = NormalizedPickerSelection(path)) { settings_.exp_path = *selection; SaveSettings(settings_); } }, {".bin", ".rom"}, initialBrowseDirectory().string() ); }; rows.push_back(std::move(expansion)); fsui::SettingsRowDescriptor default_exe; default_exe.kind = fsui::SettingsRowKind::Action; default_exe.id = "default-exe"; default_exe.title = ICON_FA_FILE_CODE " Default PS-X EXE"; default_exe.summary = settings_.default_exe_path.empty() ? "No default executable selected." : settings_.default_exe_path; default_exe.on_activate = [this]() { ImGuiFullscreen::OpenFileSelector( "Default PS-X EXE", false, [this](const std::string& path) { if (const std::optional selection = NormalizedPickerSelection(path)) { settings_.default_exe_path = *selection; SaveSettings(settings_); } }, {".exe", ".ps-exe", ".psexe"}, initialBrowseDirectory().string() ); }; rows.push_back(std::move(default_exe)); return rows; }; pages.push_back(std::move(emulation_page)); fsui::SettingsPageDescriptor graphics_page; graphics_page.id = "Graphics"; graphics_page.scope = fsui::SettingsScope::Global; graphics_page.built_in_page = fsui::BuiltInSettingsPage::Graphics; graphics_page.build_rows = [this]() { return buildGraphicsRows(SupportsManagedWindowSizing()); }; pages.push_back(std::move(graphics_page)); fsui::SettingsPageDescriptor folders_page; folders_page.id = "Folders"; folders_page.scope = fsui::SettingsScope::Global; folders_page.built_in_page = fsui::BuiltInSettingsPage::Folders; folders_page.build_rows = [this]() { std::vector rows; #if defined(UWP_TARGET) fsui::SettingsRowDescriptor browse_notice; browse_notice.kind = fsui::SettingsRowKind::Notice; browse_notice.id = "uwp-filesystem-roots"; browse_notice.title = ICON_FA_FOLDER_OPEN " Mounted Drives"; browse_notice.summary = "Mounted drives are available from the file selector. Use Filesystem Roots or Parent Directory to switch drives."; rows.push_back(std::move(browse_notice)); #endif fsui::SettingsRowDescriptor add_folder; add_folder.kind = fsui::SettingsRowKind::Action; add_folder.id = "add-folder"; add_folder.title = ICON_FA_FOLDER_PLUS " Add Library Folder"; add_folder.summary = AppendBrowseRootHint("Scan only the selected directory."); add_folder.on_activate = [this]() { ImGuiFullscreen::OpenFileSelector( "Add Library Folder", true, [this](const std::string& path) { if (const std::optional selection = NormalizedPickerSelection(path)) { settings_.ui_state.game_list_paths.emplace_back(*selection); refreshGameList(true); SaveSettings(settings_); } }, {}, initialBrowseDirectory().string() ); }; rows.push_back(std::move(add_folder)); fsui::SettingsRowDescriptor add_recursive; add_recursive.kind = fsui::SettingsRowKind::Action; add_recursive.id = "add-recursive-folder"; add_recursive.title = ICON_FA_FOLDER_PLUS " Add Recursive Folder"; add_recursive.summary = AppendBrowseRootHint("Scan the selected directory and its children."); add_recursive.on_activate = [this]() { ImGuiFullscreen::OpenFileSelector( "Add Recursive Library Folder", true, [this](const std::string& path) { if (const std::optional selection = NormalizedPickerSelection(path)) { settings_.ui_state.game_list_recursive_paths.emplace_back(*selection); refreshGameList(true); SaveSettings(settings_); } }, {}, initialBrowseDirectory().string() ); }; rows.push_back(std::move(add_recursive)); fsui::SettingsRowDescriptor refresh; refresh.kind = fsui::SettingsRowKind::Action; refresh.id = "refresh-library"; refresh.title = ICON_FA_SYNC " Refresh Library"; refresh.summary = "Rescan every configured library folder."; refresh.on_activate = [this]() { refreshGameList(true); }; rows.push_back(std::move(refresh)); auto append_folder_rows = [&](const std::vector& folders, bool recursive) { for (size_t index = 0; index < folders.size(); index++) { fsui::SettingsRowDescriptor row; row.kind = fsui::SettingsRowKind::Action; row.id = std::string(recursive ? "recursive-" : "folder-") + std::to_string(index); row.title = WithHiddenId( recursive ? ICON_FA_FOLDER_OPEN " Recursive Folder" : ICON_FA_FOLDER " Library Folder", row.id ); row.summary = folders[index].string(); row.on_activate = [this, index, recursive]() { auto& list = recursive ? settings_.ui_state.game_list_recursive_paths : settings_.ui_state.game_list_paths; if (index < list.size()) { list.erase(list.begin() + static_cast(index)); refreshGameList(true); SaveSettings(settings_); } }; rows.push_back(std::move(row)); } }; append_folder_rows(settings_.ui_state.game_list_paths, false); append_folder_rows(settings_.ui_state.game_list_recursive_paths, true); return rows; }; pages.push_back(std::move(folders_page)); fsui::SettingsPageDescriptor advanced_page; advanced_page.id = "Advanced"; advanced_page.scope = fsui::SettingsScope::Global; advanced_page.built_in_page = fsui::BuiltInSettingsPage::Advanced; advanced_page.build_rows = [this]() { std::vector rows; fsui::SettingsRowDescriptor logging_enabled; logging_enabled.kind = fsui::SettingsRowKind::Toggle; logging_enabled.id = "logging-enabled"; logging_enabled.title = ICON_FA_BUG " Debug Logging"; logging_enabled.summary = "Write diagnostic logs for troubleshooting only. Leave this off during normal play because it can reduce performance."; logging_enabled.toggle_value = settings_.logging_enabled; logging_enabled.on_toggle = [this](bool value) { settings_.logging_enabled = value; applyLoggingSettings("settings-toggle"); SaveSettings(settings_); }; rows.push_back(std::move(logging_enabled)); if (settings_.logging_enabled) { fsui::SettingsRowDescriptor log_level; log_level.kind = fsui::SettingsRowKind::Choice; log_level.id = "log-level"; log_level.title = ICON_FA_TERMINAL " Log Level"; log_level.summary = "Choose how much detail ARMSX writes to the debug log."; log_level.value = log_level_string(settings_.log_level); log_level.dialog_title = log_level.title; log_level.choices = BuildLogLevelChoices(settings_.log_level); log_level.on_choice = [this](int index) { settings_.log_level = std::clamp(index, static_cast(LOG_TRACE), static_cast(LOG_FATAL)); applyLoggingSettings("settings-log-level"); SaveSettings(settings_); }; rows.push_back(std::move(log_level)); fsui::SettingsRowDescriptor log_path; log_path.kind = fsui::SettingsRowKind::Notice; log_path.id = "log-path"; log_path.title = ICON_FA_FILE_ALT " Log File"; log_path.summary = diagnosticsLogPath().string(); rows.push_back(std::move(log_path)); } fsui::SettingsRowDescriptor settings_path; settings_path.kind = fsui::SettingsRowKind::Notice; settings_path.id = "settings-path"; settings_path.title = ICON_FA_FILE_ALT " Settings File"; settings_path.summary = settings_.settings_path; rows.push_back(std::move(settings_path)); return rows; }; pages.push_back(std::move(advanced_page)); return pages; } std::vector buildPerGameSettingsPages() { std::vector pages; fsui::SettingsPageDescriptor summary; summary.id = "Summary"; summary.scope = fsui::SettingsScope::PerGame; summary.built_in_page = fsui::BuiltInSettingsPage::Summary; summary.build_rows = [this]() { std::vector rows; const fsui::CurrentGameInfo info = session_.currentGameInfo(); fsui::SettingsRowDescriptor title; title.kind = fsui::SettingsRowKind::Notice; title.id = "current-game"; title.title = info.title; title.summary = info.path.empty() ? info.subtitle : info.path.string(); rows.push_back(std::move(title)); return rows; }; pages.push_back(std::move(summary)); fsui::SettingsPageDescriptor graphics; graphics.id = "Graphics"; graphics.scope = fsui::SettingsScope::PerGame; graphics.built_in_page = fsui::BuiltInSettingsPage::Graphics; graphics.build_rows = [this]() { return buildGraphicsRows(false); }; pages.push_back(std::move(graphics)); fsui::SettingsPageDescriptor emulation; emulation.id = "Emulation"; emulation.scope = fsui::SettingsScope::PerGame; emulation.built_in_page = fsui::BuiltInSettingsPage::Emulation; emulation.build_rows = [this]() { std::vector rows; fsui::SettingsRowDescriptor reset; reset.kind = fsui::SettingsRowKind::Action; reset.id = "reset-now"; reset.title = ICON_FA_SYNC " Reset"; reset.summary = "Soft-reset the active session."; reset.on_activate = [this]() { deferred_reset_ = true; }; rows.push_back(std::move(reset)); fsui::SettingsRowDescriptor disc; disc.kind = fsui::SettingsRowKind::Action; disc.id = "change-disc-now"; disc.title = ICON_FA_COMPACT_DISC " Change Disc"; disc.summary = "Swap to a different disc image."; disc.on_activate = [this]() { ImGuiFullscreen::OpenFileSelector( "Change Disc", false, [this](const std::string& path) { queueDiscSwapSelection(path); }, {".cue", ".bin", ".iso", ".img"}, initialBrowseDirectory().string() ); }; rows.push_back(std::move(disc)); fsui::SettingsRowDescriptor screenshot; screenshot.kind = fsui::SettingsRowKind::Action; screenshot.id = "save-screenshot-now"; screenshot.title = ICON_FA_CAMERA " Save Screenshot"; screenshot.summary = "Write a BMP screenshot to the snapshot folder."; screenshot.on_activate = [this]() { deferred_screenshot_ = true; }; rows.push_back(std::move(screenshot)); fsui::SettingsRowDescriptor exit_library; exit_library.kind = fsui::SettingsRowKind::Action; exit_library.id = "exit-library-now"; exit_library.title = ICON_FA_FOLDER_OPEN " Exit To Library"; exit_library.summary = "Destroy the session and return to the landing screen."; exit_library.on_activate = [this]() { deferred_exit_to_library_ = true; }; rows.push_back(std::move(exit_library)); return rows; }; pages.push_back(std::move(emulation)); return pages; } std::vector buildGraphicsRows(bool include_scale) { std::vector rows; #if defined(__EMSCRIPTEN__) const bool can_control_vsync = false; #else const bool can_control_vsync = owns_renderer_ && !external_renderer_; #endif if (include_scale) { fsui::SettingsRowDescriptor scale; scale.kind = fsui::SettingsRowKind::Choice; scale.id = "display-scale"; scale.title = ICON_FA_EXPAND " Display Scale"; scale.summary = "Resize the managed desktop window."; scale.value = std::to_string(settings_.scale) + "x"; scale.dialog_title = scale.title; scale.choices = BuildScaleChoices(settings_.scale); scale.on_choice = [this](int index) { static const std::array values = {1, 2, 3, 4, 5, 6}; if (index >= 0 && index < static_cast(values.size())) { settings_.scale = values[static_cast(index)]; applyWindowMetrics(); SaveSettings(settings_); } }; rows.push_back(std::move(scale)); } fsui::SettingsRowDescriptor vsync; vsync.kind = fsui::SettingsRowKind::Toggle; vsync.id = "vsync"; vsync.title = ICON_FA_SYNC " VSync"; #if defined(__EMSCRIPTEN__) vsync.summary = "Synchronize presentation to the display refresh rate. The browser controls this on web builds."; #else vsync.summary = can_control_vsync ? "Synchronize presentation to the display refresh rate to reduce tearing. This applies immediately." : "Synchronize presentation to the display refresh rate. This renderer is host-controlled here."; #endif vsync.toggle_value = settings_.vsync_enabled; vsync.enabled = can_control_vsync; vsync.on_toggle = [this](bool value) { settings_.vsync_enabled = value; pending_settings_page_restore_ = "Graphics"; SaveSettings(settings_); deferred_vsync_ = value; }; rows.push_back(std::move(vsync)); fsui::SettingsRowDescriptor filter; filter.kind = fsui::SettingsRowKind::Toggle; filter.id = "texture-filter"; filter.title = ICON_FA_ADJUST " Texture Scaling / Bilinear"; filter.summary = "Use linear filtering for the SDL presentation texture."; filter.toggle_value = settings_.texture_scale_mode; filter.on_toggle = [this](bool value) { settings_.texture_scale_mode = value; SaveSettings(settings_); }; rows.push_back(std::move(filter)); fsui::SettingsRowDescriptor stretch; stretch.kind = fsui::SettingsRowKind::Toggle; stretch.id = "stretch"; stretch.title = ICON_FA_EXPAND_ARROWS_ALT " Stretch"; stretch.summary = "Fill the SDL window instead of preserving the content aspect ratio."; stretch.toggle_value = settings_.stretch_mode; stretch.on_toggle = [this](bool value) { settings_.stretch_mode = value; SaveSettings(settings_); }; rows.push_back(std::move(stretch)); fsui::SettingsRowDescriptor aspect; aspect.kind = fsui::SettingsRowKind::Choice; aspect.id = "aspect"; aspect.title = ICON_FA_TV " Display Aspect"; aspect.summary = "Choose the presentation aspect ratio."; aspect.value = AspectTitle(settings_.display_aspect); aspect.dialog_title = aspect.title; aspect.choices = BuildAspectChoices(settings_.display_aspect); aspect.on_choice = [this](int index) { settings_.display_aspect = std::clamp(index, 0, 2); applyWindowMetrics(); SaveSettings(settings_); }; rows.push_back(std::move(aspect)); fsui::SettingsRowDescriptor upscale; upscale.kind = fsui::SettingsRowKind::Choice; upscale.id = "wide-upscale"; upscale.title = ICON_FA_EXPAND " Wide Upscale"; upscale.summary = "Choose the wide-mode output height."; upscale.value = UpscaleToString(settings_.upscale_height); upscale.dialog_title = upscale.title; upscale.choices = BuildUpscaleChoices(settings_.upscale_height); upscale.on_choice = [this](int index) { static const std::array heights = {480, 720, 1080, 1440, 2160}; if (index >= 0 && index < static_cast(heights.size())) { settings_.upscale_height = heights[static_cast(index)]; applyWindowMetrics(); SaveSettings(settings_); } }; rows.push_back(std::move(upscale)); fsui::SettingsRowDescriptor debug_panel; debug_panel.kind = fsui::SettingsRowKind::Toggle; debug_panel.id = "debug-panel"; debug_panel.title = ICON_FA_TACHOMETER_ALT " Debug Panel"; debug_panel.summary = "Expose the FSUI runtime settings/performance overlays."; debug_panel.toggle_value = settings_.debug_panel; debug_panel.on_toggle = [this](bool value) { settings_.debug_panel = value; settings_.ui_state.show_settings_overlay = value; settings_.ui_state.show_performance_overlay = value; SaveSettings(settings_); }; rows.push_back(std::move(debug_panel)); if (session_.valid()) { fsui::SettingsRowDescriptor debug_view; debug_view.kind = fsui::SettingsRowKind::Toggle; debug_view.id = "debug-view"; debug_view.title = ICON_FA_BUG " VRAM Debug View"; debug_view.summary = "Render the raw PSX VRAM buffer instead of the display output."; debug_view.toggle_value = session_.debugView(); debug_view.on_toggle = [this](bool value) { session_.setDebugView(value); applyWindowMetrics(); }; rows.push_back(std::move(debug_view)); } return rows; } 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((16.0f / 9.0f) * static_cast(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(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 fsui::CurrentGameInfo 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; bool owns_sdl_ = false; bool owns_window_ = false; bool owns_renderer_ = false; SDL_Window* window_ = nullptr; SDL_Renderer* renderer_ = nullptr; fsui::SdlImGuiBackend imgui_backend_{}; ArmsxSession session_{}; GameplayInputRouter input_router_{}; std::vector game_list_{}; std::optional pending_error_dialog_{}; std::optional deferred_launch_{}; std::optional deferred_change_disc_{}; std::optional deferred_vsync_{}; std::optional pending_settings_page_restore_{}; std::optional 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; bool deferred_fast_forward_toggle_ = false; bool fsui_initialized_ = false; FsuiWindowState ui_window_state_ = FsuiWindowState::None; bool managed_renderer_vsync_ = DefaultVsyncEnabled(); uint64_t next_frame_deadline_ = 0; uint64_t frame_period_ticks_ = 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(std::size(frames)), frames, nullptr); psxe_diag_logf("crash", "Native stack trace (%u frames):", static_cast(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_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(reinterpret_cast(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(frames[index]), &module) != 0) { GetModuleFileNameA(module, module_path, static_cast(std::size(module_path))); module_offset = address - static_cast(reinterpret_cast(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(index), frames[index], module_path[0] ? ModuleNameFromPath(module_path).c_str() : "(unknown)", symbol->Name, static_cast(displacement), static_cast(module_offset) ); continue; } #endif psxe_diag_logf( "crash", " #%u %p %s+0x%llx", static_cast(index), frames[index], module_path[0] ? ModuleNameFromPath(module_path).c_str() : "(unknown)", static_cast(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(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(frames[index]) - reinterpret_cast(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); } extern "C" PSXE_API void psxe_wasm_on_file(const char* path) { if (!path || !*path) { return; } psxe_enqueue_launch_argument(path); } #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 extern "C" PSXE_API int external_main(int argc, const char* argv[], void* external_window, void* external_renderer) { return psxe_run(argc, argv, external_window, external_renderer); } #ifndef __DLL_BUILD int main(int argc, const char* argv[]) { return external_main(argc, argv, nullptr, nullptr); } #endif