mirror of
https://github.com/ARMSX2/ARMSX1.git
synced 2026-08-24 16:53:35 -07:00
4708 lines
161 KiB
C++
4708 lines
161 KiB
C++
#include <SDL.h>
|
|
#include <SDL_gamecontroller.h>
|
|
#include <SDL_render.h>
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <atomic>
|
|
#include <csignal>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <cctype>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <ctime>
|
|
#include <exception>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <memory>
|
|
#include <mutex>
|
|
#include <optional>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <thread>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
#if defined(_WIN32)
|
|
#ifndef NOMINMAX
|
|
#define NOMINMAX
|
|
#endif
|
|
#ifndef WIN32_LEAN_AND_MEAN
|
|
#define WIN32_LEAN_AND_MEAN
|
|
#endif
|
|
#include <windows.h>
|
|
#if !defined(UWP_TARGET)
|
|
#include <dbghelp.h>
|
|
#endif
|
|
#elif !defined(__EMSCRIPTEN__) && !defined(__ANDROID__) && !defined(PSVITA_TARGET)
|
|
#if defined(__has_include)
|
|
#if __has_include(<execinfo.h>)
|
|
#include <dlfcn.h>
|
|
#include <execinfo.h>
|
|
#define PSXE_HAS_EXECINFO 1
|
|
#endif
|
|
#endif
|
|
#endif
|
|
|
|
#if defined(__EMSCRIPTEN__)
|
|
#include <emscripten/html5.h>
|
|
#endif
|
|
|
|
#if defined(__ANDROID__)
|
|
#include <jni.h>
|
|
#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<std::string> 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<int>(priority), message ? message : "");
|
|
}
|
|
|
|
[[noreturn]] void ReportNativeCrash(const char* reason);
|
|
|
|
#if defined(_WIN32) && !defined(UWP_TARGET)
|
|
LONG WINAPI WindowsUnhandledExceptionFilter(EXCEPTION_POINTERS* exception_info) {
|
|
const DWORD code = exception_info && exception_info->ExceptionRecord
|
|
? exception_info->ExceptionRecord->ExceptionCode
|
|
: 0u;
|
|
char reason[64] = {};
|
|
SDL_snprintf(reason, sizeof(reason), "SEH 0x%08lx", static_cast<unsigned long>(code));
|
|
ReportNativeCrash(reason);
|
|
return EXCEPTION_EXECUTE_HANDLER;
|
|
}
|
|
#endif
|
|
|
|
double CounterTicksToMilliseconds(uint64_t ticks) {
|
|
const uint64_t frequency = SDL_GetPerformanceFrequency();
|
|
if (frequency == 0) {
|
|
return 0.0;
|
|
}
|
|
|
|
return (static_cast<double>(ticks) * 1000.0) / static_cast<double>(frequency);
|
|
}
|
|
|
|
std::string RendererFlagsTitle(Uint32 flags) {
|
|
std::string title;
|
|
|
|
auto append = [&](const char* value) {
|
|
if (!title.empty()) {
|
|
title.append("|");
|
|
}
|
|
title.append(value);
|
|
};
|
|
|
|
if (flags & SDL_RENDERER_SOFTWARE) {
|
|
append("software");
|
|
}
|
|
if (flags & SDL_RENDERER_ACCELERATED) {
|
|
append("accelerated");
|
|
}
|
|
if (flags & SDL_RENDERER_PRESENTVSYNC) {
|
|
append("present-vsync");
|
|
}
|
|
if (flags & SDL_RENDERER_TARGETTEXTURE) {
|
|
append("target-texture");
|
|
}
|
|
|
|
if (title.empty()) {
|
|
title = "none";
|
|
}
|
|
|
|
return title;
|
|
}
|
|
|
|
[[noreturn]] void ReportNativeCrash(const char* reason);
|
|
void WriteNativeStackTraceImpl();
|
|
|
|
enum class LaunchKind {
|
|
None,
|
|
Bios,
|
|
Disc,
|
|
Exe,
|
|
};
|
|
|
|
const char* 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<std::mutex> lock(g_pending_launch_lock);
|
|
g_pending_launch_arguments.push_back(std::move(argument));
|
|
}
|
|
|
|
std::vector<std::string> DrainPendingLaunchArguments() {
|
|
std::lock_guard<std::mutex> lock(g_pending_launch_lock);
|
|
std::vector<std::string> pending;
|
|
pending.swap(g_pending_launch_arguments);
|
|
return pending;
|
|
}
|
|
|
|
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<char>(std::tolower(ch));
|
|
});
|
|
return out;
|
|
}
|
|
|
|
std::string Trim(std::string_view value) {
|
|
size_t begin = 0;
|
|
size_t end = value.size();
|
|
|
|
while ((begin < end) && std::isspace(static_cast<unsigned char>(value[begin]))) {
|
|
begin++;
|
|
}
|
|
|
|
while ((end > begin) && std::isspace(static_cast<unsigned char>(value[end - 1]))) {
|
|
end--;
|
|
}
|
|
|
|
return std::string(value.substr(begin, end - begin));
|
|
}
|
|
|
|
std::string NormalizeModel(std::string_view value) {
|
|
std::string out;
|
|
|
|
for (unsigned char ch : value) {
|
|
if (std::isalnum(ch)) {
|
|
out.push_back(static_cast<char>(std::tolower(ch)));
|
|
}
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
std::string DefaultSettingsPath() {
|
|
const char* pref = psxe_cfg_get_pref_path();
|
|
|
|
if (pref && pref[0]) {
|
|
return std::string(pref) + "settings.toml";
|
|
}
|
|
|
|
return "settings.toml";
|
|
}
|
|
|
|
std::filesystem::path DefaultBrowseDirectory() {
|
|
const char* pref = psxe_cfg_get_pref_path();
|
|
|
|
if (pref && pref[0]) {
|
|
return std::filesystem::path(pref);
|
|
}
|
|
|
|
return std::filesystem::current_path();
|
|
}
|
|
|
|
std::filesystem::path DefaultDiagnosticsLogPath() {
|
|
std::filesystem::path path = DefaultBrowseDirectory() / "logs";
|
|
#if defined(UWP_TARGET)
|
|
path /= "armsx-uwp.log";
|
|
#else
|
|
path /= "armsx.log";
|
|
#endif
|
|
return path;
|
|
}
|
|
|
|
std::string AppendBrowseRootHint(std::string summary) {
|
|
#if defined(_WIN32)
|
|
if (!summary.empty() && summary.back() != ' ') {
|
|
summary.push_back(' ');
|
|
}
|
|
summary += "Use Filesystem Roots or Parent Directory to switch drives.";
|
|
#endif
|
|
return summary;
|
|
}
|
|
|
|
bool PathsMatch(const std::filesystem::path& left, const std::filesystem::path& right) {
|
|
#if defined(_WIN32)
|
|
return ToLower(left.lexically_normal().generic_string()) == ToLower(right.lexically_normal().generic_string());
|
|
#else
|
|
return left.lexically_normal() == right.lexically_normal();
|
|
#endif
|
|
}
|
|
|
|
bool PathListContains(const std::vector<std::filesystem::path>& paths, const std::filesystem::path& candidate) {
|
|
return std::any_of(paths.begin(), paths.end(), [&](const std::filesystem::path& path) {
|
|
return PathsMatch(path, candidate);
|
|
});
|
|
}
|
|
|
|
bool FileContainsCaseInsensitive(const std::filesystem::path& path, const std::string& needle_lower) {
|
|
std::ifstream input(path, std::ios::binary);
|
|
if (!input.good()) {
|
|
return false;
|
|
}
|
|
|
|
std::string content((std::istreambuf_iterator<char>(input)), std::istreambuf_iterator<char>());
|
|
return ToLower(content).find(needle_lower) != std::string::npos;
|
|
}
|
|
|
|
bool CueDirectoryReferencesImage(const std::filesystem::path& image_path) {
|
|
if (!image_path.has_parent_path()) {
|
|
return false;
|
|
}
|
|
|
|
const std::string needle = ToLower(image_path.filename().string());
|
|
std::error_code ec;
|
|
|
|
for (const auto& item : std::filesystem::directory_iterator(image_path.parent_path(), ec)) {
|
|
if (ec) {
|
|
break;
|
|
}
|
|
if (!item.is_regular_file()) {
|
|
continue;
|
|
}
|
|
if (ToLower(item.path().extension().string()) != ".cue") {
|
|
continue;
|
|
}
|
|
if (FileContainsCaseInsensitive(item.path(), needle)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
bool IsLikelyBiosImagePath(const std::filesystem::path& path, const FrontendSettings& settings) {
|
|
const std::string ext = ToLower(path.extension().string());
|
|
if (ext != ".bin" && ext != ".rom") {
|
|
return false;
|
|
}
|
|
|
|
if (!settings.bios_override.empty() && PathsMatch(path, std::filesystem::path(settings.bios_override))) {
|
|
return true;
|
|
}
|
|
|
|
const std::string stem = NormalizeModel(path.stem().string());
|
|
if (stem == "bios" || stem == "biosbin") {
|
|
return true;
|
|
}
|
|
if (stem.starts_with("scph")) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
std::string NormalizePathString(std::string value) {
|
|
#if defined(_WIN32)
|
|
std::replace(value.begin(), value.end(), '\\', '/');
|
|
if (value.size() == 2 && std::isalpha(static_cast<unsigned char>(value[0])) && value[1] == ':') {
|
|
value.push_back('/');
|
|
}
|
|
#endif
|
|
return value;
|
|
}
|
|
|
|
bool StartsWithCaseInsensitive(std::string_view value, std::string_view prefix) {
|
|
if (value.size() < prefix.size()) {
|
|
return false;
|
|
}
|
|
|
|
for (size_t index = 0; index < prefix.size(); index++) {
|
|
const unsigned char lhs = static_cast<unsigned char>(value[index]);
|
|
const unsigned char rhs = static_cast<unsigned char>(prefix[index]);
|
|
if (std::tolower(lhs) != std::tolower(rhs)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
int HexValue(unsigned char ch) {
|
|
if (ch >= '0' && ch <= '9') {
|
|
return ch - '0';
|
|
}
|
|
if (ch >= 'a' && ch <= 'f') {
|
|
return 10 + (ch - 'a');
|
|
}
|
|
if (ch >= 'A' && ch <= 'F') {
|
|
return 10 + (ch - 'A');
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
std::string PercentDecode(std::string_view value) {
|
|
std::string decoded;
|
|
decoded.reserve(value.size());
|
|
|
|
for (size_t index = 0; index < value.size(); index++) {
|
|
const unsigned char ch = static_cast<unsigned char>(value[index]);
|
|
if ((ch == '%') && ((index + 2) < value.size())) {
|
|
const int hi = HexValue(static_cast<unsigned char>(value[index + 1]));
|
|
const int lo = HexValue(static_cast<unsigned char>(value[index + 2]));
|
|
if (hi >= 0 && lo >= 0) {
|
|
decoded.push_back(static_cast<char>((hi << 4) | lo));
|
|
index += 2;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
decoded.push_back(ch == '+' ? ' ' : static_cast<char>(ch));
|
|
}
|
|
|
|
return decoded;
|
|
}
|
|
|
|
std::optional<std::string> NormalizedPickerSelection(const std::string& path) {
|
|
const std::string trimmed = Trim(path);
|
|
if (trimmed.empty()) {
|
|
return std::nullopt;
|
|
}
|
|
|
|
return NormalizePathString(trimmed);
|
|
}
|
|
|
|
std::string WithHiddenId(std::string_view label, std::string_view id) {
|
|
std::string out(label);
|
|
out += "##";
|
|
out += id;
|
|
return out;
|
|
}
|
|
|
|
bool ReadSdlFileBytes(std::string_view path, std::vector<unsigned char>& bytes) {
|
|
SDL_RWops* handle = SDL_RWFromFile(std::string(path).c_str(), "rb");
|
|
if (!handle) {
|
|
return false;
|
|
}
|
|
|
|
bytes.clear();
|
|
std::array<unsigned char, 16 * 1024> 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<std::ptrdiff_t>(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<unsigned char> 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<const char*>(bytes.data()), static_cast<std::streamsize>(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<const char, VitaBaseDeleter> base_path(vita_base);
|
|
#else
|
|
std::unique_ptr<char, decltype(&SDL_free)> 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<const char*, 8> japan_models = {
|
|
"scph1000",
|
|
"scph3000",
|
|
"scph3500",
|
|
"scph5000",
|
|
"scph5500",
|
|
"scph7000",
|
|
"scph7003",
|
|
"scph100",
|
|
};
|
|
|
|
if (std::find(japan_models.begin(), japan_models.end(), model) != japan_models.end()) {
|
|
return CDR_REGION_JAPAN;
|
|
}
|
|
|
|
return CDR_REGION_AMERICA;
|
|
}
|
|
|
|
const std::string model = NormalizeModel(settings.model);
|
|
static const std::array<const char*, 8> europe_models = {
|
|
"scph1002",
|
|
"scph5502",
|
|
"scph5552",
|
|
"scph7002",
|
|
"scph7502",
|
|
"scph9002",
|
|
"scph102a",
|
|
"scph102b",
|
|
};
|
|
static const std::array<const char*, 8> japan_models = {
|
|
"scph1000",
|
|
"scph3000",
|
|
"scph3500",
|
|
"scph5000",
|
|
"scph5500",
|
|
"scph7000",
|
|
"scph7003",
|
|
"scph100",
|
|
};
|
|
|
|
if (std::find(europe_models.begin(), europe_models.end(), model) != europe_models.end()) {
|
|
return CDR_REGION_EUROPE;
|
|
}
|
|
|
|
if (std::find(japan_models.begin(), japan_models.end(), model) != japan_models.end()) {
|
|
return CDR_REGION_JAPAN;
|
|
}
|
|
|
|
return CDR_REGION_AMERICA;
|
|
}
|
|
|
|
LaunchRequest LaunchForPath(const std::filesystem::path& path) {
|
|
LaunchRequest request;
|
|
if (path.empty()) {
|
|
return request;
|
|
}
|
|
|
|
request.path = std::filesystem::path(NormalizePathString(path.string()));
|
|
request.label = StemToTitle(request.path);
|
|
|
|
if (IsExePath(request.path)) {
|
|
request.kind = LaunchKind::Exe;
|
|
} else if (IsDiscPath(request.path)) {
|
|
request.kind = LaunchKind::Disc;
|
|
}
|
|
|
|
return request;
|
|
}
|
|
|
|
std::optional<LaunchKind> ParseUriLaunchKind(std::string_view value) {
|
|
const std::string lower = ToLower(Trim(value));
|
|
|
|
if (lower == "bios") {
|
|
return LaunchKind::Bios;
|
|
}
|
|
if (lower == "disc" || lower == "cdrom") {
|
|
return LaunchKind::Disc;
|
|
}
|
|
if (lower == "exe") {
|
|
return LaunchKind::Exe;
|
|
}
|
|
|
|
return std::nullopt;
|
|
}
|
|
|
|
std::optional<std::string> QueryValue(std::string_view query, std::string_view key) {
|
|
size_t begin = 0;
|
|
|
|
while (begin <= query.size()) {
|
|
const size_t end = query.find('&', begin);
|
|
const std::string_view pair = query.substr(begin, end == std::string_view::npos ? query.size() - begin : end - begin);
|
|
const size_t sep = pair.find('=');
|
|
const std::string_view pair_key = pair.substr(0, sep);
|
|
|
|
if (ToLower(PercentDecode(pair_key)) == ToLower(key)) {
|
|
if (sep == std::string_view::npos) {
|
|
return std::string();
|
|
}
|
|
return PercentDecode(pair.substr(sep + 1));
|
|
}
|
|
|
|
if (end == std::string_view::npos) {
|
|
break;
|
|
}
|
|
begin = end + 1;
|
|
}
|
|
|
|
return std::nullopt;
|
|
}
|
|
|
|
std::string DecodeLaunchUriPathPayload(std::string_view payload) {
|
|
std::string decoded = PercentDecode(payload);
|
|
|
|
if (decoded.rfind("///", 0) == 0) {
|
|
decoded.erase(0, 2);
|
|
} else if (decoded.rfind("//", 0) == 0) {
|
|
decoded.erase(0, 2);
|
|
}
|
|
|
|
#if defined(_WIN32)
|
|
if (decoded.size() >= 3 &&
|
|
decoded[0] == '/' &&
|
|
std::isalpha(static_cast<unsigned char>(decoded[1])) &&
|
|
decoded[2] == ':') {
|
|
decoded.erase(decoded.begin());
|
|
}
|
|
#endif
|
|
|
|
return NormalizePathString(decoded);
|
|
}
|
|
|
|
LaunchRequest LaunchForUri(std::string_view uri) {
|
|
LaunchRequest request;
|
|
|
|
std::string_view remainder = uri;
|
|
if (StartsWithCaseInsensitive(uri, "armsx:")) {
|
|
remainder = uri.substr(6);
|
|
} else if (StartsWithCaseInsensitive(uri, "web+armsx:")) {
|
|
remainder = uri.substr(10);
|
|
} else {
|
|
return request;
|
|
}
|
|
const size_t fragment_pos = remainder.find('#');
|
|
if (fragment_pos != std::string_view::npos) {
|
|
remainder = remainder.substr(0, fragment_pos);
|
|
}
|
|
|
|
std::string_view query;
|
|
const size_t query_pos = remainder.find('?');
|
|
if (query_pos != std::string_view::npos) {
|
|
query = remainder.substr(query_pos + 1);
|
|
remainder = remainder.substr(0, query_pos);
|
|
}
|
|
|
|
std::optional<LaunchKind> kind = std::nullopt;
|
|
if (const std::optional<std::string> kind_value = QueryValue(query, "kind")) {
|
|
kind = ParseUriLaunchKind(*kind_value);
|
|
}
|
|
|
|
if (kind == LaunchKind::Bios) {
|
|
request.kind = LaunchKind::Bios;
|
|
request.label = "PlayStation BIOS";
|
|
return request;
|
|
}
|
|
|
|
std::string launch_path;
|
|
if (const std::optional<std::string> query_path = QueryValue(query, "path")) {
|
|
launch_path = NormalizePathString(*query_path);
|
|
} else {
|
|
launch_path = DecodeLaunchUriPathPayload(remainder);
|
|
}
|
|
|
|
const std::string launch_path_lower = ToLower(Trim(launch_path));
|
|
if (launch_path_lower == "bios" || launch_path_lower == "/bios") {
|
|
request.kind = LaunchKind::Bios;
|
|
request.label = "PlayStation BIOS";
|
|
return request;
|
|
}
|
|
|
|
request = LaunchForPath(std::filesystem::path(launch_path));
|
|
if (request.kind == LaunchKind::None) {
|
|
return request;
|
|
}
|
|
|
|
if (kind == LaunchKind::Disc || kind == LaunchKind::Exe) {
|
|
request.kind = *kind;
|
|
}
|
|
|
|
return request;
|
|
}
|
|
|
|
LaunchRequest LaunchForArgument(std::string_view argument, std::optional<LaunchKind> forced_kind = std::nullopt) {
|
|
LaunchRequest request;
|
|
const std::string trimmed = Trim(argument);
|
|
if (trimmed.empty()) {
|
|
return request;
|
|
}
|
|
|
|
if (StartsWithCaseInsensitive(trimmed, "armsx:")) {
|
|
request = LaunchForUri(trimmed);
|
|
} else {
|
|
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<fsui::SettingsChoiceOption> BuildChoices(const std::vector<std::string>& titles, int selected_index) {
|
|
std::vector<fsui::SettingsChoiceOption> 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<int>(index) == selected_index);
|
|
options.push_back(std::move(option));
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
std::vector<fsui::SettingsChoiceOption> BuildAspectChoices(int current) {
|
|
return BuildChoices({"Classic", "Square", "Wide 16:9"}, current);
|
|
}
|
|
|
|
std::vector<fsui::SettingsChoiceOption> BuildUpscaleChoices(int current_height) {
|
|
const std::vector<int> heights = {480, 720, 1080, 1440, 2160};
|
|
const std::vector<std::string> 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<int>(index);
|
|
break;
|
|
}
|
|
}
|
|
|
|
return BuildChoices(labels, selected);
|
|
}
|
|
|
|
std::vector<fsui::SettingsChoiceOption> BuildScaleChoices(int current_scale) {
|
|
const std::vector<int> scales = {1, 2, 3, 4, 5, 6};
|
|
std::vector<std::string> 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<int>(index);
|
|
}
|
|
}
|
|
|
|
return BuildChoices(labels, selected);
|
|
}
|
|
|
|
std::vector<fsui::SettingsChoiceOption> BuildRegionChoices(const std::string& current_region) {
|
|
const std::vector<std::string> values = {"auto", "ntsc", "pal"};
|
|
const std::vector<std::string> 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<int>(index);
|
|
break;
|
|
}
|
|
}
|
|
|
|
return BuildChoices(labels, selected);
|
|
}
|
|
|
|
std::vector<std::string> ModelChoices() {
|
|
return {
|
|
"scph1000", "scph1001", "scph1002", "scph3000", "scph3500", "scph5000", "scph5500",
|
|
"scph5501", "scph5502", "scph5552", "scph7000", "scph7001", "scph7002", "scph7003",
|
|
"scph7501", "scph7502", "scph9002", "scph100", "scph101", "scph102a", "scph102b", "scph102c",
|
|
};
|
|
}
|
|
|
|
std::vector<fsui::SettingsChoiceOption> BuildModelChoices(const std::string& current_model) {
|
|
const std::vector<std::string> 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<int>(index);
|
|
break;
|
|
}
|
|
}
|
|
|
|
return BuildChoices(models, selected);
|
|
}
|
|
|
|
std::vector<fsui::SettingsChoiceOption> BuildLogLevelChoices(int current_level) {
|
|
const std::vector<std::string> labels = {"Trace", "Debug", "Info", "Warn", "Error", "Fatal"};
|
|
const int clamped = std::clamp(current_level, static_cast<int>(LOG_TRACE), static_cast<int>(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<system_clock::duration>(
|
|
value - std::filesystem::file_time_type::clock::now() + system_clock::now()
|
|
);
|
|
return system_clock::to_time_t(adjusted);
|
|
}
|
|
|
|
void CopyTomlString(const toml_datum_t& datum, std::string& out) {
|
|
if (!datum.ok || !datum.u.s) {
|
|
return;
|
|
}
|
|
|
|
out = datum.u.s;
|
|
free(datum.u.s);
|
|
}
|
|
|
|
void CopyTomlPathString(const toml_datum_t& datum, std::string& out) {
|
|
if (!datum.ok || !datum.u.s) {
|
|
return;
|
|
}
|
|
|
|
out = NormalizePathString(datum.u.s);
|
|
free(datum.u.s);
|
|
}
|
|
|
|
void CopyTomlArrayStrings(const toml_array_t* array, std::vector<std::filesystem::path>& out) {
|
|
out.clear();
|
|
|
|
if (!array) {
|
|
return;
|
|
}
|
|
|
|
const int count = toml_array_nelem(array);
|
|
out.reserve(static_cast<size_t>(count));
|
|
|
|
for (int index = 0; index < count; index++) {
|
|
toml_datum_t value = toml_string_at(array, index);
|
|
|
|
if (!value.ok || !value.u.s) {
|
|
continue;
|
|
}
|
|
|
|
out.emplace_back(NormalizePathString(value.u.s));
|
|
free(value.u.s);
|
|
}
|
|
}
|
|
|
|
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<int>(value.u.i);
|
|
}
|
|
}
|
|
|
|
if (!cli.log_level) {
|
|
toml_datum_t value = toml_int_in(runtime, "log_level");
|
|
if (value.ok) {
|
|
settings.log_level = static_cast<int>(value.u.i);
|
|
}
|
|
}
|
|
|
|
if (!cli.quiet) {
|
|
bool resolved_logging_enabled = false;
|
|
toml_datum_t logging_enabled = toml_bool_in(runtime, "logging_enabled");
|
|
if (logging_enabled.ok) {
|
|
settings.logging_enabled = logging_enabled.u.b != 0;
|
|
settings.quiet = !settings.logging_enabled;
|
|
resolved_logging_enabled = true;
|
|
}
|
|
|
|
toml_datum_t value = toml_bool_in(runtime, "quiet");
|
|
if (!resolved_logging_enabled && value.ok) {
|
|
settings.quiet = value.u.b != 0;
|
|
settings.logging_enabled = !settings.quiet;
|
|
}
|
|
}
|
|
}
|
|
|
|
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<int>(default_view.u.i);
|
|
}
|
|
if (sort.ok) {
|
|
settings.ui_state.game_sort = static_cast<int>(sort.u.i);
|
|
}
|
|
if (reverse.ok) {
|
|
settings.ui_state.game_sort_reverse = reverse.u.b != 0;
|
|
}
|
|
}
|
|
|
|
toml_free(root);
|
|
}
|
|
|
|
FrontendSettings BuildSettings(const psxe_config_t* cfg, const CliFlags& cli) {
|
|
FrontendSettings settings;
|
|
|
|
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<int>(LOG_TRACE), static_cast<int>(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);
|
|
|
|
try {
|
|
if (target.has_parent_path()) {
|
|
std::filesystem::create_directories(target.parent_path());
|
|
}
|
|
} catch (...) {
|
|
return false;
|
|
}
|
|
|
|
std::ofstream out(target);
|
|
|
|
if (!out.is_open()) {
|
|
return false;
|
|
}
|
|
|
|
auto write_path_array = [&](const std::vector<std::filesystem::path>& paths) {
|
|
out << "[";
|
|
for (size_t index = 0; index < paths.size(); index++) {
|
|
if (index) {
|
|
out << ", ";
|
|
}
|
|
out << "\"" << EscapeTomlString(paths[index].string()) << "\"";
|
|
}
|
|
out << "]";
|
|
};
|
|
|
|
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";
|
|
|
|
return out.good();
|
|
}
|
|
|
|
void MixPsxAudio(psx_t* psx, uint8_t* buffer, int size) {
|
|
if (!psx || !buffer || size <= 0) {
|
|
return;
|
|
}
|
|
|
|
psx_cdrom_t* cdrom = psx->cdrom;
|
|
psx_spu_t* spu = psx->spu;
|
|
|
|
std::memset(buffer, 0, static_cast<size_t>(size));
|
|
|
|
psx_cdrom_get_audio_samples(cdrom, buffer, size);
|
|
psx_spu_update_cdda_buffer(spu, cdrom->cdda_buf);
|
|
|
|
for (int sample = 0; sample < (size >> 2); sample++) {
|
|
const uint32_t value = psx_spu_get_sample(spu);
|
|
const int16_t left = static_cast<int16_t>(value & 0xffff);
|
|
const int16_t right = static_cast<int16_t>(value >> 16);
|
|
|
|
*reinterpret_cast<int16_t*>(&buffer[(sample << 2) + 0]) += left;
|
|
*reinterpret_cast<int16_t*>(&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<char, decltype(&free)> bios_guard(bios_path, &free);
|
|
|
|
if (!bios_path || !bios_path[0]) {
|
|
error = "No BIOS could be resolved. Set a BIOS override file or a BIOS folder that contains the selected model.";
|
|
return false;
|
|
}
|
|
|
|
psx_ = psx_create();
|
|
if (!psx_) {
|
|
error = "Failed to allocate a PSX session.";
|
|
return false;
|
|
}
|
|
|
|
const char* expansion = settings.exp_path.empty() ? nullptr : settings.exp_path.c_str();
|
|
const int init_result = psx_init(psx_, bios_path, expansion);
|
|
|
|
if (init_result != 0) {
|
|
error = "Failed to initialize the emulator core. Check the BIOS and expansion ROM paths.";
|
|
destroy();
|
|
return false;
|
|
}
|
|
|
|
psx_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<fsui::OverlayTextLine> settingsOverlayLines(const FrontendSettings& settings) const {
|
|
std::vector<fsui::OverlayTextLine> 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<fsui::OverlayTextLine> performanceOverlayLines(const FrontendSettings& settings) const {
|
|
(void)settings;
|
|
std::vector<fsui::OverlayTextLine> 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<int>(psx_get_display_width(psx_));
|
|
const int next_height = debug_view_ ? PSX_GPU_FB_HEIGHT : static_cast<int>(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<float>(texture_width_) / static_cast<float>(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<float>(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<ImTextureID>(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<size_t>(surface->pitch);
|
|
const uint8_t* src = static_cast<const uint8_t*>(source);
|
|
uint8_t* dst = static_cast<uint8_t*>(surface->pixels);
|
|
|
|
for (int row = 0; row < height; row++) {
|
|
std::memcpy(dst + (static_cast<size_t>(row) * static_cast<size_t>(surface->pitch)), src + (static_cast<size_t>(row) * static_cast<size_t>(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<double>(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<ArmsxSession*>(gpu->udata[0])) {
|
|
session->vblank_counter_++;
|
|
}
|
|
|
|
psxe_gpu_vblank_timer_event_cb(gpu);
|
|
}
|
|
}
|
|
|
|
static void AudioUpdate(void* userdata, uint8_t* buffer, int size) {
|
|
if (!buffer || size <= 0) {
|
|
return;
|
|
}
|
|
|
|
std::memset(buffer, 0, static_cast<size_t>(size));
|
|
|
|
auto* session = static_cast<ArmsxSession*>(userdata);
|
|
if (!session) {
|
|
return;
|
|
}
|
|
|
|
session->consumeQueuedAudio(buffer, static_cast<size_t>(size));
|
|
}
|
|
|
|
void updateAudioPlaybackState() {
|
|
if (!audio_dev_) {
|
|
return;
|
|
}
|
|
|
|
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<double>(kAudioMixRate) / frameRate();
|
|
const int sample_count = static_cast<int>(audio_sample_accumulator_);
|
|
if (sample_count <= 0) {
|
|
return;
|
|
}
|
|
|
|
audio_sample_accumulator_ -= static_cast<double>(sample_count);
|
|
|
|
const size_t byte_count = static_cast<size_t>(sample_count) * sizeof(int16_t) * 2;
|
|
if (byte_count == 0) {
|
|
return;
|
|
}
|
|
|
|
std::vector<uint8_t> frame_audio(byte_count);
|
|
MixPsxAudio(psx_, frame_audio.data(), static_cast<int>(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<std::ptrdiff_t>(audio_queue_read_offset_));
|
|
audio_queue_read_offset_ = 0;
|
|
}
|
|
|
|
static constexpr int kAudioMixRate = 44100;
|
|
static constexpr size_t kMaxQueuedAudioBytes = static_cast<size_t>(kAudioMixRate * sizeof(int16_t) * 2 / 2);
|
|
static constexpr size_t kAudioQueueCompactThreshold = 4096;
|
|
static constexpr std::uint32_t kMaxFrameSteps = PSX_CPU_CPS / 8u;
|
|
|
|
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<uint8_t> 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<fsui::InputOverlayDeviceState> 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<SDL_GameControllerButton>(event.cbutton.button);
|
|
|
|
if (button == SDL_CONTROLLER_BUTTON_START || button == SDL_CONTROLLER_BUTTON_BACK ||
|
|
button == SDL_CONTROLLER_BUTTON_GUIDE || button == SDL_CONTROLLER_BUTTON_MISC1) {
|
|
handlePauseChordButton(button, pressed, fsui_active);
|
|
return;
|
|
}
|
|
|
|
if (fsui_active || fsui_owns_input_) {
|
|
return;
|
|
}
|
|
|
|
const uint32_t mask = buttonForController(button);
|
|
if (mask) {
|
|
if (pressed) {
|
|
press(mask);
|
|
} else {
|
|
release(mask);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (event.type == SDL_CONTROLLERAXISMOTION) {
|
|
if (event.caxis.which != controller_id || fsui_active || fsui_owns_input_ || !pad_) {
|
|
return;
|
|
}
|
|
|
|
const uint16_t mapped = static_cast<uint16_t>((static_cast<int>(event.caxis.value) + INT16_MAX + 1) / 0x100);
|
|
switch (event.caxis.axis) {
|
|
case SDL_CONTROLLER_AXIS_RIGHTX:
|
|
psx_pad_analog_change(pad_, 0, PSXI_AX_SDA_RIGHT_HORZ, mapped);
|
|
break;
|
|
case SDL_CONTROLLER_AXIS_RIGHTY:
|
|
psx_pad_analog_change(pad_, 0, PSXI_AX_SDA_RIGHT_VERT, mapped);
|
|
break;
|
|
case SDL_CONTROLLER_AXIS_LEFTX:
|
|
psx_pad_analog_change(pad_, 0, PSXI_AX_SDA_LEFT_HORZ, mapped);
|
|
break;
|
|
case SDL_CONTROLLER_AXIS_LEFTY:
|
|
psx_pad_analog_change(pad_, 0, PSXI_AX_SDA_LEFT_VERT, mapped);
|
|
break;
|
|
#ifdef CONTROLLER_GENERIC
|
|
case SDL_CONTROLLER_AXIS_TRIGGERLEFT:
|
|
handleTrigger(PSXI_SW_SDA_L2, trigger_left_down_, event.caxis.value);
|
|
break;
|
|
case SDL_CONTROLLER_AXIS_TRIGGERRIGHT:
|
|
handleTrigger(PSXI_SW_SDA_R2, trigger_right_down_, event.caxis.value);
|
|
break;
|
|
#endif
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private:
|
|
void handleControllerLifecycle(const SDL_Event& event) {
|
|
if (event.type == SDL_CONTROLLERDEVICEADDED) {
|
|
if (!controller_ && SDL_IsGameController(event.cdevice.which)) {
|
|
controller_ = SDL_GameControllerOpen(event.cdevice.which);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (event.type == SDL_CONTROLLERDEVICEREMOVED && controller_) {
|
|
SDL_Joystick* joystick = SDL_GameControllerGetJoystick(controller_);
|
|
if (joystick && SDL_JoystickInstanceID(joystick) == event.cdevice.which) {
|
|
SDL_GameControllerClose(controller_);
|
|
controller_ = nullptr;
|
|
start_button_ = {};
|
|
select_button_ = {};
|
|
trigger_left_down_ = false;
|
|
trigger_right_down_ = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
void handlePauseChordButton(SDL_GameControllerButton button, bool pressed, bool fsui_active) {
|
|
PendingChordButton* state = nullptr;
|
|
PendingChordButton* other = nullptr;
|
|
|
|
if (button == SDL_CONTROLLER_BUTTON_START) {
|
|
state = &start_button_;
|
|
other = &select_button_;
|
|
} else {
|
|
state = &select_button_;
|
|
other = &start_button_;
|
|
}
|
|
|
|
if (pressed) {
|
|
state->physical_down = true;
|
|
if (!fsui_active && !fsui_owns_input_ && !pause_latched_) {
|
|
state->pending = true;
|
|
state->pending_since = SDL_GetTicks();
|
|
if (other->physical_down && other->pending) {
|
|
pause_latched_ = true;
|
|
pause_requested_ = true;
|
|
state->pending = false;
|
|
other->pending = false;
|
|
clearAll();
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
state->physical_down = false;
|
|
|
|
if (pause_latched_) {
|
|
if (!start_button_.physical_down && !select_button_.physical_down) {
|
|
pause_latched_ = false;
|
|
start_button_ = {};
|
|
select_button_ = {};
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (state->pending) {
|
|
const uint32_t mask = (state == &start_button_) ? PSXI_SW_SDA_START : PSXI_SW_SDA_SELECT;
|
|
press(mask);
|
|
release(mask);
|
|
state->pending = false;
|
|
return;
|
|
}
|
|
|
|
if (state->forwarded) {
|
|
const uint32_t mask = (state == &start_button_) ? PSXI_SW_SDA_START : PSXI_SW_SDA_SELECT;
|
|
release(mask);
|
|
state->forwarded = false;
|
|
}
|
|
}
|
|
|
|
void flushChordButtons(Uint32 now) {
|
|
if (pause_latched_ || fsui_owns_input_) {
|
|
return;
|
|
}
|
|
|
|
flushOneChordButton(start_button_, select_button_, PSXI_SW_SDA_START, now);
|
|
flushOneChordButton(select_button_, start_button_, PSXI_SW_SDA_SELECT, now);
|
|
}
|
|
|
|
void flushOneChordButton(PendingChordButton& state, PendingChordButton& other, uint32_t mask, Uint32 now) {
|
|
if (!state.pending || other.physical_down || !pad_) {
|
|
return;
|
|
}
|
|
|
|
if ((now - state.pending_since) < kPauseChordGraceMs) {
|
|
return;
|
|
}
|
|
|
|
press(mask);
|
|
state.pending = false;
|
|
state.forwarded = true;
|
|
}
|
|
|
|
void press(uint32_t mask) {
|
|
if (!pad_ || !mask) {
|
|
return;
|
|
}
|
|
|
|
if ((active_digital_mask_ & mask) == 0) {
|
|
psx_pad_button_press(pad_, 0, mask);
|
|
active_digital_mask_ |= mask;
|
|
}
|
|
}
|
|
|
|
void release(uint32_t mask) {
|
|
if (!pad_ || !mask) {
|
|
return;
|
|
}
|
|
|
|
if ((active_digital_mask_ & mask) != 0) {
|
|
psx_pad_button_release(pad_, 0, mask);
|
|
active_digital_mask_ &= ~mask;
|
|
}
|
|
}
|
|
|
|
void clearAll() {
|
|
if (!pad_) {
|
|
active_digital_mask_ = 0;
|
|
return;
|
|
}
|
|
|
|
static const std::array<uint32_t, 17> masks = {
|
|
PSXI_SW_SDA_SELECT, PSXI_SW_SDA_L3, PSXI_SW_SDA_R3, PSXI_SW_SDA_START,
|
|
PSXI_SW_SDA_PAD_UP, PSXI_SW_SDA_PAD_RIGHT, PSXI_SW_SDA_PAD_DOWN, PSXI_SW_SDA_PAD_LEFT,
|
|
PSXI_SW_SDA_L2, PSXI_SW_SDA_R2, PSXI_SW_SDA_L1, PSXI_SW_SDA_R1,
|
|
PSXI_SW_SDA_TRIANGLE, PSXI_SW_SDA_CIRCLE, PSXI_SW_SDA_CROSS, PSXI_SW_SDA_SQUARE,
|
|
PSXI_SW_SDA_ANALOG,
|
|
};
|
|
|
|
for (uint32_t mask : masks) {
|
|
if ((active_digital_mask_ & mask) != 0) {
|
|
psx_pad_button_release(pad_, 0, mask);
|
|
}
|
|
}
|
|
|
|
active_digital_mask_ = 0;
|
|
psx_pad_analog_change(pad_, 0, PSXI_AX_SDA_RIGHT_HORZ, 0x80);
|
|
psx_pad_analog_change(pad_, 0, PSXI_AX_SDA_RIGHT_VERT, 0x80);
|
|
psx_pad_analog_change(pad_, 0, PSXI_AX_SDA_LEFT_HORZ, 0x80);
|
|
psx_pad_analog_change(pad_, 0, PSXI_AX_SDA_LEFT_VERT, 0x80);
|
|
}
|
|
|
|
#ifdef CONTROLLER_GENERIC
|
|
void handleTrigger(uint32_t mask, bool& down_flag, Sint16 value) {
|
|
const bool pressed = value > 8000;
|
|
if (pressed == down_flag) {
|
|
return;
|
|
}
|
|
|
|
down_flag = pressed;
|
|
if (pressed) {
|
|
press(mask);
|
|
} else {
|
|
release(mask);
|
|
}
|
|
}
|
|
#endif
|
|
|
|
static uint32_t buttonForKey(SDL_Keycode key) {
|
|
switch (key) {
|
|
case SDLK_x: return PSXI_SW_SDA_CROSS;
|
|
case SDLK_a: return PSXI_SW_SDA_SQUARE;
|
|
case SDLK_w: return PSXI_SW_SDA_TRIANGLE;
|
|
case SDLK_d: return PSXI_SW_SDA_CIRCLE;
|
|
case SDLK_RETURN: return PSXI_SW_SDA_START;
|
|
case SDLK_s: return PSXI_SW_SDA_SELECT;
|
|
case SDLK_UP: return PSXI_SW_SDA_PAD_UP;
|
|
case SDLK_DOWN: return PSXI_SW_SDA_PAD_DOWN;
|
|
case SDLK_LEFT: return PSXI_SW_SDA_PAD_LEFT;
|
|
case SDLK_RIGHT: return PSXI_SW_SDA_PAD_RIGHT;
|
|
case SDLK_q: return PSXI_SW_SDA_L1;
|
|
case SDLK_e: return PSXI_SW_SDA_R1;
|
|
case SDLK_1: return PSXI_SW_SDA_L2;
|
|
case SDLK_3: return PSXI_SW_SDA_R2;
|
|
case SDLK_z: return PSXI_SW_SDA_L3;
|
|
case SDLK_c: return PSXI_SW_SDA_R3;
|
|
case SDLK_2: return PSXI_SW_SDA_ANALOG;
|
|
default: return 0;
|
|
}
|
|
}
|
|
|
|
static uint32_t buttonForController(SDL_GameControllerButton button) {
|
|
switch (button) {
|
|
case SDL_CONTROLLER_BUTTON_A: return PSXI_SW_SDA_CROSS;
|
|
case SDL_CONTROLLER_BUTTON_X: return PSXI_SW_SDA_SQUARE;
|
|
case SDL_CONTROLLER_BUTTON_Y: return PSXI_SW_SDA_TRIANGLE;
|
|
case SDL_CONTROLLER_BUTTON_B: return PSXI_SW_SDA_CIRCLE;
|
|
case SDL_CONTROLLER_BUTTON_DPAD_UP: return PSXI_SW_SDA_PAD_UP;
|
|
case SDL_CONTROLLER_BUTTON_DPAD_DOWN: return PSXI_SW_SDA_PAD_DOWN;
|
|
case SDL_CONTROLLER_BUTTON_DPAD_LEFT: return PSXI_SW_SDA_PAD_LEFT;
|
|
case SDL_CONTROLLER_BUTTON_DPAD_RIGHT: return PSXI_SW_SDA_PAD_RIGHT;
|
|
case SDL_CONTROLLER_BUTTON_LEFTSHOULDER: return PSXI_SW_SDA_L1;
|
|
case SDL_CONTROLLER_BUTTON_RIGHTSHOULDER: return PSXI_SW_SDA_R1;
|
|
case SDL_CONTROLLER_BUTTON_LEFTSTICK: return PSXI_SW_SDA_L3;
|
|
case SDL_CONTROLLER_BUTTON_RIGHTSTICK: return PSXI_SW_SDA_R3;
|
|
default: return 0;
|
|
}
|
|
}
|
|
|
|
psx_pad_t* pad_ = nullptr;
|
|
SDL_GameController* controller_ = nullptr;
|
|
uint32_t active_digital_mask_ = 0;
|
|
PendingChordButton start_button_{};
|
|
PendingChordButton select_button_{};
|
|
bool pause_requested_ = false;
|
|
bool pause_latched_ = false;
|
|
bool fsui_owns_input_ = false;
|
|
bool trigger_left_down_ = false;
|
|
bool trigger_right_down_ = false;
|
|
};
|
|
|
|
class ArmsxApp {
|
|
public:
|
|
ArmsxApp(int argc, const char* argv[], void* external_window, void* external_renderer)
|
|
: argc_(argc), argv_(argv), external_window_(static_cast<SDL_Window*>(external_window)),
|
|
external_renderer_(static_cast<SDL_Renderer*>(external_renderer)), cli_(ScanCliFlags(argc, argv)) {}
|
|
|
|
int run() {
|
|
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<const char**>(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<LaunchKind> forced_kind = std::nullopt) {
|
|
const std::optional<std::string> selection = NormalizedPickerSelection(path);
|
|
if (!selection.has_value()) {
|
|
return;
|
|
}
|
|
|
|
const LaunchRequest request = LaunchForArgument(*selection, forced_kind);
|
|
if (request.kind != LaunchKind::None) {
|
|
queueLaunchRequest(request, true);
|
|
}
|
|
}
|
|
|
|
void queueDiscSwapSelection(const std::string& path) {
|
|
const std::optional<std::string> selection = NormalizedPickerSelection(path);
|
|
if (!selection.has_value()) {
|
|
return;
|
|
}
|
|
|
|
deferred_change_disc_ = std::filesystem::path(*selection);
|
|
}
|
|
|
|
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<uint64_t>(
|
|
1,
|
|
static_cast<uint64_t>(std::llround(static_cast<double>(frequency) / safe_rate))
|
|
);
|
|
|
|
if (frame_period_ticks_ != ticks) {
|
|
frame_period_ticks_ = ticks;
|
|
next_frame_deadline_ = 0;
|
|
psxe_diag_breadcrumbf(
|
|
"Frame pacing target updated fps=%.2f period_ms=%.3f",
|
|
safe_rate,
|
|
CounterTicksToMilliseconds(frame_period_ticks_)
|
|
);
|
|
}
|
|
#else
|
|
(void)frame_rate;
|
|
#endif
|
|
}
|
|
|
|
void waitForFrameDeadline(double frame_rate) {
|
|
#if !defined(__EMSCRIPTEN__)
|
|
updateFramePeriod(frame_rate);
|
|
|
|
const uint64_t frequency = SDL_GetPerformanceFrequency();
|
|
const uint64_t slack_ticks = std::max<uint64_t>(1, frequency / 2000u);
|
|
uint64_t now = SDL_GetPerformanceCounter();
|
|
|
|
if (next_frame_deadline_ == 0) {
|
|
next_frame_deadline_ = now;
|
|
return;
|
|
}
|
|
|
|
if (frame_period_ticks_ != 0 && now > (next_frame_deadline_ + (frame_period_ticks_ * 2u))) {
|
|
psxe_diag_breadcrumbf(
|
|
"Frame pacing resync lateness_ms=%.3f",
|
|
CounterTicksToMilliseconds(now - next_frame_deadline_)
|
|
);
|
|
next_frame_deadline_ = now;
|
|
return;
|
|
}
|
|
|
|
while (now + slack_ticks < next_frame_deadline_) {
|
|
const double wait_ms = CounterTicksToMilliseconds(next_frame_deadline_ - now);
|
|
if (wait_ms > 1.5) {
|
|
SDL_Delay(static_cast<Uint32>(wait_ms - 1.0));
|
|
} else {
|
|
std::this_thread::yield();
|
|
}
|
|
now = SDL_GetPerformanceCounter();
|
|
}
|
|
|
|
while (now < next_frame_deadline_) {
|
|
std::this_thread::yield();
|
|
now = SDL_GetPerformanceCounter();
|
|
}
|
|
#else
|
|
(void)frame_rate;
|
|
#endif
|
|
}
|
|
|
|
void advanceFrameDeadline() {
|
|
#if !defined(__EMSCRIPTEN__)
|
|
if (frame_period_ticks_ == 0) {
|
|
return;
|
|
}
|
|
|
|
if (next_frame_deadline_ == 0) {
|
|
next_frame_deadline_ = SDL_GetPerformanceCounter();
|
|
}
|
|
|
|
next_frame_deadline_ += frame_period_ticks_;
|
|
#endif
|
|
}
|
|
|
|
void resetFramePacing(const char* reason) {
|
|
#if !defined(__EMSCRIPTEN__)
|
|
next_frame_deadline_ = 0;
|
|
frame_period_ticks_ = 0;
|
|
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<LaunchKind> forced_kind = std::nullopt) {
|
|
pending_cli_argument_ = std::string(value);
|
|
pending_cli_launch_ = LaunchForArgument(value, forced_kind);
|
|
};
|
|
|
|
if (arg == "--cdrom") {
|
|
if ((index + 1) < argc_) {
|
|
set_boot_argument(argv_[index + 1] ? argv_[index + 1] : "", LaunchKind::Disc);
|
|
index++;
|
|
}
|
|
} else if (arg == "-x" || arg == "--exe") {
|
|
if ((index + 1) < argc_) {
|
|
set_boot_argument(argv_[index + 1] ? argv_[index + 1] : "", LaunchKind::Exe);
|
|
index++;
|
|
}
|
|
} else if (arg.starts_with("--cdrom=")) {
|
|
set_boot_argument(arg.substr(8), LaunchKind::Disc);
|
|
} else if (arg.starts_with("--exe=")) {
|
|
set_boot_argument(arg.substr(6), LaunchKind::Exe);
|
|
} else if (!arg.empty() && arg[0] != '-') {
|
|
set_boot_argument(arg);
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
bool initializeWindowAndRenderer() {
|
|
if (external_window_) {
|
|
window_ = external_window_;
|
|
} else {
|
|
int window_width = 1280;
|
|
int window_height = 720;
|
|
#if defined(__EMSCRIPTEN__)
|
|
double css_width = 0.0;
|
|
double css_height = 0.0;
|
|
if (emscripten_get_element_css_size("#canvas", &css_width, &css_height) == EMSCRIPTEN_RESULT_SUCCESS &&
|
|
css_width > 0.0 && css_height > 0.0) {
|
|
window_width = std::max(1, static_cast<int>(std::lround(css_width)));
|
|
window_height = std::max(1, static_cast<int>(std::lround(css_height)));
|
|
}
|
|
#endif
|
|
Uint32 flags = 0;
|
|
if (SupportsManagedWindowSizing()) {
|
|
flags |= SDL_WINDOW_RESIZABLE;
|
|
}
|
|
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<int>(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<std::filesystem::path> seen_game_paths;
|
|
std::vector<std::pair<std::filesystem::path, bool>> 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<fsui::MenuItemDescriptor> buildLandingItems() {
|
|
std::vector<fsui::MenuItemDescriptor> 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<fsui::MenuItemDescriptor> buildStartItems() {
|
|
std::vector<fsui::MenuItemDescriptor> 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<fsui::MenuItemDescriptor> buildExitItems() {
|
|
std::vector<fsui::MenuItemDescriptor> 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<fsui::MenuItemDescriptor> buildPauseItems() {
|
|
std::vector<fsui::MenuItemDescriptor> 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<fsui::MenuItemDescriptor> buildGameLaunchOptions(const fsui::GameEntry&) {
|
|
return {};
|
|
}
|
|
|
|
std::vector<fsui::SettingsPageDescriptor> buildSettingsPages(fsui::SettingsScope scope) {
|
|
std::vector<fsui::SettingsPageDescriptor> 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<fsui::SettingsPageDescriptor> buildGlobalSettingsPages() {
|
|
std::vector<fsui::SettingsPageDescriptor> 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<fsui::SettingsRowDescriptor> 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<int>(models.size())) {
|
|
settings_.model = models[static_cast<size_t>(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<std::string> 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<std::string> 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<fsui::SettingsRowDescriptor> 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<const char*, 3> values = {"auto", "ntsc", "pal"};
|
|
if (index >= 0 && index < static_cast<int>(values.size())) {
|
|
settings_.region = values[static_cast<size_t>(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<std::string> 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<std::string> 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<fsui::SettingsRowDescriptor> 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<std::string> 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<std::string> 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<std::filesystem::path>& 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<std::ptrdiff_t>(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<fsui::SettingsRowDescriptor> 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<int>(LOG_TRACE), static_cast<int>(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<fsui::SettingsPageDescriptor> buildPerGameSettingsPages() {
|
|
std::vector<fsui::SettingsPageDescriptor> 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<fsui::SettingsRowDescriptor> 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<fsui::SettingsRowDescriptor> 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<fsui::SettingsRowDescriptor> buildGraphicsRows(bool include_scale) {
|
|
std::vector<fsui::SettingsRowDescriptor> 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<int, 6> values = {1, 2, 3, 4, 5, 6};
|
|
if (index >= 0 && index < static_cast<int>(values.size())) {
|
|
settings_.scale = values[static_cast<size_t>(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<int, 5> heights = {480, 720, 1080, 1440, 2160};
|
|
if (index >= 0 && index < static_cast<int>(heights.size())) {
|
|
settings_.upscale_height = heights[static_cast<size_t>(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<int>((16.0f / 9.0f) * static_cast<float>(height));
|
|
} else if (settings_.display_aspect == 1) {
|
|
width = 320 * std::max(1, settings_.scale);
|
|
height = width;
|
|
} else {
|
|
int base_width = 320;
|
|
if (session_.psx()) {
|
|
const int display_width = static_cast<int>(psx_get_dmode_width(session_.psx()));
|
|
if (display_width == 256 || display_width == 320) {
|
|
base_width = display_width;
|
|
} else if (display_width == 368) {
|
|
base_width = 384;
|
|
}
|
|
}
|
|
|
|
width = base_width * std::max(1, settings_.scale);
|
|
height = 240 * std::max(1, settings_.scale);
|
|
}
|
|
}
|
|
SDL_SetWindowSize(window_, width, height);
|
|
}
|
|
|
|
std::filesystem::path initialBrowseDirectory() const {
|
|
if (session_.valid()) {
|
|
const 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<fsui::GameEntry> game_list_{};
|
|
std::optional<std::string> pending_error_dialog_{};
|
|
std::optional<LaunchRequest> deferred_launch_{};
|
|
std::optional<std::filesystem::path> deferred_change_disc_{};
|
|
std::optional<bool> deferred_vsync_{};
|
|
std::optional<std::string> pending_settings_page_restore_{};
|
|
std::optional<LaunchRequest> pending_cli_launch_{};
|
|
std::string pending_cli_argument_;
|
|
bool close_ui_after_launch_ = false;
|
|
bool deferred_exit_to_library_ = false;
|
|
bool deferred_reset_ = false;
|
|
bool deferred_screenshot_ = false;
|
|
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<DWORD>(std::size(frames)), frames, nullptr);
|
|
psxe_diag_logf("crash", "Native stack trace (%u frames):", static_cast<unsigned int>(frame_count));
|
|
|
|
#if !defined(UWP_TARGET)
|
|
HANDLE process = GetCurrentProcess();
|
|
SymInitialize(process, nullptr, TRUE);
|
|
|
|
char symbol_buffer[sizeof(SYMBOL_INFO) + MAX_SYM_NAME] = {};
|
|
auto* symbol = reinterpret_cast<SYMBOL_INFO*>(symbol_buffer);
|
|
symbol->SizeOfStruct = sizeof(SYMBOL_INFO);
|
|
symbol->MaxNameLen = MAX_SYM_NAME;
|
|
#endif
|
|
|
|
for (USHORT index = 0; index < frame_count; index++) {
|
|
const DWORD64 address = static_cast<DWORD64>(reinterpret_cast<uintptr_t>(frames[index]));
|
|
HMODULE module = nullptr;
|
|
char module_path[MAX_PATH] = {};
|
|
DWORD64 module_offset = 0;
|
|
|
|
if (GetModuleHandleExA(
|
|
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
|
reinterpret_cast<LPCSTR>(frames[index]),
|
|
&module) != 0) {
|
|
GetModuleFileNameA(module, module_path, static_cast<DWORD>(std::size(module_path)));
|
|
module_offset = address - static_cast<DWORD64>(reinterpret_cast<uintptr_t>(module));
|
|
}
|
|
|
|
#if !defined(UWP_TARGET)
|
|
DWORD64 displacement = 0;
|
|
if (SymFromAddr(process, address, &displacement, symbol) != 0) {
|
|
psxe_diag_logf(
|
|
"crash",
|
|
" #%u %p %s!%s+0x%llx module+0x%llx",
|
|
static_cast<unsigned int>(index),
|
|
frames[index],
|
|
module_path[0] ? ModuleNameFromPath(module_path).c_str() : "(unknown)",
|
|
symbol->Name,
|
|
static_cast<unsigned long long>(displacement),
|
|
static_cast<unsigned long long>(module_offset)
|
|
);
|
|
continue;
|
|
}
|
|
#endif
|
|
|
|
psxe_diag_logf(
|
|
"crash",
|
|
" #%u %p %s+0x%llx",
|
|
static_cast<unsigned int>(index),
|
|
frames[index],
|
|
module_path[0] ? ModuleNameFromPath(module_path).c_str() : "(unknown)",
|
|
static_cast<unsigned long long>(module_offset)
|
|
);
|
|
}
|
|
|
|
#if !defined(UWP_TARGET)
|
|
SymCleanup(process);
|
|
#endif
|
|
#elif defined(PSXE_HAS_EXECINFO)
|
|
void* frames[64] = {};
|
|
const int frame_count = backtrace(frames, static_cast<int>(std::size(frames)));
|
|
psxe_diag_logf("crash", "Native stack trace (%d frames):", frame_count);
|
|
|
|
for (int index = 0; index < frame_count; index++) {
|
|
Dl_info info{};
|
|
if (dladdr(frames[index], &info) != 0 && info.dli_fname) {
|
|
const uintptr_t symbol_offset =
|
|
info.dli_saddr
|
|
? (reinterpret_cast<uintptr_t>(frames[index]) - reinterpret_cast<uintptr_t>(info.dli_saddr))
|
|
: 0u;
|
|
psxe_diag_logf(
|
|
"crash",
|
|
" #%d %p %s %s+0x%zx",
|
|
index,
|
|
frames[index],
|
|
ModuleNameFromPath(info.dli_fname).c_str(),
|
|
info.dli_sname ? info.dli_sname : "(unknown)",
|
|
symbol_offset
|
|
);
|
|
} else {
|
|
psxe_diag_logf("crash", " #%d %p", index, frames[index]);
|
|
}
|
|
}
|
|
#else
|
|
psxe_diag_logf("crash", "Native stack trace unavailable on this platform build.");
|
|
#endif
|
|
}
|
|
|
|
[[noreturn]] void ReportNativeCrash(const char* reason) {
|
|
if (g_crash_reporting.exchange(true)) {
|
|
std::_Exit(1);
|
|
}
|
|
|
|
psxe_diag_logf("crash", "Native crash captured: %s", reason ? reason : "(unknown)");
|
|
|
|
if (g_active_app) {
|
|
g_active_app->writeCrashContext(reason);
|
|
} else {
|
|
psxe_diag_logf("crash", "No active app context available.");
|
|
psxe_diag_dump_breadcrumbs();
|
|
}
|
|
|
|
WriteNativeStackTraceImpl();
|
|
psxe_diag_shutdown();
|
|
std::_Exit(1);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
extern "C" void psxe_diag_write_native_stacktrace(void) {
|
|
WriteNativeStackTraceImpl();
|
|
}
|
|
|
|
extern "C" int psxe_run(int argc, const char* argv[], void* external_window, void* external_renderer) {
|
|
ArmsxApp app(argc, argv, external_window, external_renderer);
|
|
return app.run();
|
|
}
|
|
|
|
extern "C" PSXE_API void psxe_enqueue_launch_argument(const char* argument) {
|
|
if (!argument || !*argument) {
|
|
return;
|
|
}
|
|
|
|
EnqueuePendingLaunchArgument(argument);
|
|
}
|
|
|
|
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
|