Files

1066 lines
32 KiB
C++
Raw Permalink Normal View History

// SPDX-FileCopyrightText: 2002-2026 PCSX2 Dev Team
// SPDX-License-Identifier: GPL-3.0+
// pcsx2-sdl — SDL3 frontend for kmsdrm-only handhelds.
//
// Boots a game from a CLI ISO path, or wires PCSX2's existing FullscreenUI
// (ImGui) for in-game settings, game-picker, and configuration. With no ISO
// supplied (and the "UI"/"StartBigPictureMode" flag set, which is on by
// default for this frontend), comes up directly into the FullscreenUI
// game-picker.
//
// Display surface is normally acquired via Vulkan VK_KHR_display, so no
// Wayland/X11 compositor is needed — the Vulkan renderer enumerates monitors
// itself and this frontend only reports the requested resolution back through
// WindowInfo.
//
// When the frontend is instead launched inside a Wayland session (a compositor
// owns the display, so VK_KHR_display can't take it — desktop, phone, or a
// gamescope-style embedded compositor), we detect WAYLAND_DISPLAY at
// window-build time, spin up an SDL3 SDL_WINDOW_VULKAN window, and hand its
// wl_display / wl_surface to the Vulkan backend as a WindowInfo::Type::Wayland
// surface. This path is compiled only when WAYLAND_API is enabled.
//
// Audio + input come from SDL3 via the existing SDLAudioStream / SDLInputSource
// modules in the core (already linked, already non-Qt).
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <csignal>
#include <cstdlib>
#include <cstring>
#include <deque>
#include <functional>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <thread>
#include <utility>
#include <vector>
#include <SDL3/SDL.h>
#include "fmt/format.h"
#include "common/Assertions.h"
#include "common/Console.h"
#include "common/CrashHandler.h"
#include "common/Error.h"
#include "common/FileSystem.h"
#include "common/Path.h"
#include "common/ProgressCallback.h"
#include "common/StringUtil.h"
#include "common/Threading.h"
#include "pcsx2/PrecompiledHeader.h"
#include "pcsx2/Achievements.h"
#include "pcsx2/CDVD/CDVDcommon.h"
#include "pcsx2/GS.h"
#include "pcsx2/GameList.h"
#include "pcsx2/Host.h"
#include "pcsx2/INISettingsInterface.h"
#include "pcsx2/ImGui/FullscreenUI.h"
#include "pcsx2/ImGui/ImGuiFullscreen.h"
#include "pcsx2/ImGui/ImGuiManager.h"
#include "pcsx2/Input/InputManager.h"
#include "pcsx2/MTGS.h"
#include "pcsx2/PerformanceMetrics.h"
#include "pcsx2/SIO/Pad/Pad.h"
#include "pcsx2/VMManager.h"
#include "svnrev.h"
namespace Pcsx2SDL
{
static bool InitializeConfig();
static bool ParseCommandLineArgs(int argc, char* argv[], VMBootParameters& params);
static void InstallSignalHandler();
static std::optional<WindowInfo> BuildWindowInfo();
static void CPUThreadMain(VMBootParameters initial_params, bool start_in_fsui, std::atomic<int>* ret);
static void DrainCPUThreadQueue();
static void StopGameListRefreshThread();
} // namespace Pcsx2SDL
// Settings persistence (INI on disk).
static std::unique_ptr<INISettingsInterface> s_base_settings;
static std::unique_ptr<INISettingsInterface> s_secrets_settings;
// Shutdown signal from SIGTERM/SIGINT or VM exit.
static std::atomic<bool> s_shutdown_requested{false};
// Display mode requested via --fullscreen-mode (0 = let the renderer pick the
// display's preferred mode).
static u32 s_requested_width = 0;
static u32 s_requested_height = 0;
// Wayland fallback window. Created lazily by BuildWindowInfo() the first time a
// render window is requested, but only when running under a Wayland compositor
// (WAYLAND_DISPLAY set) — on a bare kmsdrm handheld this stays null and the
// VK_KHR_display (VulkanDirect) path is used instead. Owned by this frontend;
// destroyed in main() after the CPU thread joins.
#if defined(WAYLAND_API)
static SDL_Window* s_wayland_window = nullptr;
static bool s_wayland_probed = false;
#endif
// Pending CPU-thread callbacks queued by Host::RunOnCPUThread (FullscreenUI
// uses this to schedule work back from the GS thread, e.g. "user picked an
// ISO from the game list, please VMManager::Initialize it on the CPU thread").
// Drained by Host::PumpMessagesOnCPUThread which the VM polls every vsync;
// also drained on a 16ms tick by the idle loop while no VM is running.
static std::mutex s_cpu_queue_lock;
static std::deque<std::function<void()>> s_cpu_queue;
static std::condition_variable s_cpu_queue_cv;
// Set once the CPU thread is up; used to detect "RunOnCPUThread(block=true)"
// being called from the CPU thread itself (which would self-deadlock).
static std::atomic<std::thread::id> s_cpu_thread_id{};
// Background game-list scanner. FullscreenUI's GameList page calls
// Host::RefreshGameListAsync, which spawns a single worker thread to
// GameList::Refresh. The thread is joined (blocking) at shutdown and at
// restart, to avoid two scans racing.
static std::thread s_gamelist_thread;
static std::atomic<bool> s_gamelist_running{false};
//////////////////////////////////////////////////////////////////////////
// Settings + lifecycle
//////////////////////////////////////////////////////////////////////////
bool Pcsx2SDL::InitializeConfig()
{
EmuFolders::SetAppRoot();
if (!EmuFolders::SetResourcesDirectory() || !EmuFolders::SetDataDirectory(nullptr))
return false;
CrashHandler::SetWriteDirectory(EmuFolders::DataRoot);
const char* hw_check_error = nullptr;
if (!VMManager::PerformEarlyHardwareChecks(&hw_check_error))
{
Console.ErrorFmt("Early hardware check failed: {}", hw_check_error ? hw_check_error : "unknown");
return false;
}
// Load Roboto for OSD / FullscreenUI (font is bundled in resources).
{
const std::string roboto_path =
EmuFolders::GetOverridableResourcePath("fonts" FS_OSPATH_SEPARATOR_STR "Roboto-Regular.ttf");
const auto roboto_data = FileSystem::MapBinaryFileForRead(roboto_path.c_str());
if (roboto_data.empty())
{
Console.ErrorFmt("Failed to load font file '{}'.", roboto_path);
return false;
}
std::vector<ImGuiManager::FontInfo> fonts;
ImGuiManager::FontInfo fi{};
fi.data = roboto_data;
fi.exclude_ranges = {};
fi.face_name = nullptr;
fi.is_emoji_font = false;
fonts.push_back(fi);
ImGuiManager::SetFonts(std::move(fonts));
}
// Open / create the persistent INI at $XDG_CONFIG_HOME/PCSX2/PCSX2.ini.
const std::string ini_path = Path::Combine(EmuFolders::Settings, "PCSX2.ini");
const bool ini_exists = FileSystem::FileExists(ini_path.c_str());
Console.WriteLnFmt("Loading config from {}.", ini_path);
s_base_settings = std::make_unique<INISettingsInterface>(ini_path);
Host::Internal::SetBaseSettingsLayer(s_base_settings.get());
if (!ini_exists || !s_base_settings->Load() || !VMManager::Internal::CheckSettingsVersion())
{
Console.WriteLnFmt("Initialising fresh config at {}.", ini_path);
VMManager::SetDefaultSettings(*s_base_settings, true, true, true, true, true);
}
// Secrets layer (achievements credentials etc). Created on first run;
// failure to create is non-fatal — just log and continue.
const std::string secrets_path = Path::Combine(EmuFolders::Settings, "secrets.ini");
s_secrets_settings = std::make_unique<INISettingsInterface>(secrets_path);
Host::Internal::SetSecretsSettingsLayer(s_secrets_settings.get());
if (FileSystem::FileExists(secrets_path.c_str()))
s_secrets_settings->Load();
// Apply handheld-frontend defaults. These override anything the INI
// might have for fields that don't make sense without a desktop:
// - Vulkan renderer (only one with VK_KHR_display direct-display path).
// - Fullscreen always (no window manager to support windowed mode).
// - SDL audio + SDL gamepad input (no Qt-coupled keyboard input layer).
{
auto lock = Host::GetSettingsLock();
s_base_settings->SetBoolValue("InputSources", "SDL", true);
// Don't disable user-set values they may have customised — only fill
// in missing defaults for first-run.
if (!s_base_settings->ContainsValue("EmuCore/GS", "Renderer"))
s_base_settings->SetIntValue("EmuCore/GS", "Renderer",
static_cast<int>(GSRendererType::VK));
if (!s_base_settings->ContainsValue("SPU2/Output", "OutputModule"))
s_base_settings->SetStringValue("SPU2/Output", "OutputModule", "sdl");
if (!s_base_settings->ContainsValue("EmuCore/GS", "FullscreenMode"))
s_base_settings->SetBoolValue("EmuCore/GS", "FullscreenMode", true);
}
// Persist any first-run defaults so the user can hand-edit
// the INI between sessions.
Error save_error;
if (!s_base_settings->Save(&save_error))
Console.ErrorFmt("Failed to save config: {}", save_error.GetDescription());
VMManager::Internal::LoadStartupSettings();
return true;
}
bool Pcsx2SDL::ParseCommandLineArgs(int argc, char* argv[], VMBootParameters& params)
{
for (int i = 1; i < argc; i++)
{
#define ARG(s) (!std::strcmp(argv[i], s))
#define ARG_PARAM(s) (!std::strcmp(argv[i], s) && (i + 1) < argc)
if (ARG("--help") || ARG("-h"))
{
std::fprintf(stderr, "PCSX2 SDL frontend %s\n", GIT_REV);
std::fprintf(stderr, "Usage: %s [options] <iso-path>\n\n", argv[0]);
std::fprintf(stderr, " --fullscreen-mode WxH Request a specific display mode\n");
std::fprintf(stderr, " (default: monitor's preferred mode)\n");
std::fprintf(stderr, " --bios-only Boot the BIOS without loading a disc\n");
std::fprintf(stderr, " --state-from-file PATH Resume from a save state file\n");
std::fprintf(stderr, " --no-fast-boot Skip fast boot (run full BIOS animation)\n");
std::fprintf(stderr, " -h, --help Show this help and exit\n");
std::fprintf(stderr, " --version Show version and exit\n");
return false;
}
if (ARG("--version"))
{
std::fprintf(stderr, "PCSX2 SDL frontend %s\n", GIT_REV);
return false;
}
if (ARG_PARAM("--fullscreen-mode"))
{
const char* mode = argv[++i];
const char* x_pos = std::strchr(mode, 'x');
if (!x_pos)
{
std::fprintf(stderr, "Invalid --fullscreen-mode '%s', expected WxH (e.g. 1280x720).\n", mode);
return false;
}
s_requested_width = StringUtil::FromChars<u32>(std::string_view(mode, x_pos - mode)).value_or(0);
s_requested_height = StringUtil::FromChars<u32>(x_pos + 1).value_or(0);
if (s_requested_width == 0 || s_requested_height == 0)
{
std::fprintf(stderr, "Invalid --fullscreen-mode '%s'.\n", mode);
return false;
}
continue;
}
if (ARG("--bios-only"))
{
params.source_type = CDVD_SourceType::NoDisc;
continue;
}
if (ARG_PARAM("--state-from-file"))
{
params.save_state = argv[++i];
continue;
}
if (ARG("--no-fast-boot"))
{
params.fast_boot = false;
continue;
}
if (argv[i][0] == '-')
{
// Note for anyone arriving from the Qt frontend: this one takes none of its
// flags - no -fullscreen, -bigpicture, -nogui, -batch or -fastboot. Fullscreen
// is always on, and with no ISO it starts in the Big Picture game-picker.
std::fprintf(stderr, "Unknown argument: '%s'\nRun with --help for the accepted options.\n", argv[i]);
return false;
}
// Positional: ISO path.
if (!params.filename.empty())
{
std::fprintf(stderr, "Multiple ISO paths supplied; expected exactly one.\n");
return false;
}
params.filename = argv[i];
#undef ARG
#undef ARG_PARAM
}
// Empty positional + no --bios-only is allowed: the frontend boots into
// FullscreenUI's game-picker if "UI/StartBigPictureMode" is set
// (the default for this frontend; see Host::SetDefaultUISettings).
// The decision happens in main() after settings are loaded.
if (!params.fast_boot.has_value())
params.fast_boot = true;
if (!params.fullscreen.has_value())
params.fullscreen = true;
return true;
}
static void HandleSignal(int)
{
s_shutdown_requested.store(true, std::memory_order_release);
if (VMManager::HasValidVM())
VMManager::SetState(VMState::Stopping);
}
void Pcsx2SDL::InstallSignalHandler()
{
std::signal(SIGTERM, &HandleSignal);
std::signal(SIGINT, &HandleSignal);
std::signal(SIGHUP, &HandleSignal);
}
#if defined(WAYLAND_API)
// True when a Wayland compositor is present. On a bare kmsdrm handheld there is
// no WAYLAND_DISPLAY and VK_KHR_display (VulkanDirect) is the correct path, so
// we must not create an SDL window there (doing so would also fight the ICD for
// the DRM master).
static bool WaylandSessionPresent()
{
const char* wl = std::getenv("WAYLAND_DISPLAY");
return wl && *wl;
}
// Bring up an SDL3 Vulkan window on the Wayland compositor and populate `wi`
// with its wl_display / wl_surface. Returns false (and leaves s_wayland_window
// null) if anything fails, so the caller falls back to VulkanDirect.
static bool BuildWaylandWindowInfo(WindowInfo& wi)
{
if (!s_wayland_probed)
{
s_wayland_probed = true;
// Prefer the Wayland video driver explicitly; if SDL comes up on
// something else (e.g. Xwayland via x11), the handles below won't match
// Type::Wayland, so we bail and let VulkanDirect take over.
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "wayland");
if (!SDL_InitSubSystem(SDL_INIT_VIDEO))
{
Console.WarningFmt("Wayland fallback: SDL_INIT_VIDEO failed ({}); using VK_KHR_display.",
SDL_GetError());
return false;
}
const char* driver = SDL_GetCurrentVideoDriver();
if (!driver || std::strcmp(driver, "wayland") != 0)
{
Console.WarningFmt("Wayland fallback: SDL video driver is '{}', not wayland; using VK_KHR_display.",
driver ? driver : "<none>");
SDL_QuitSubSystem(SDL_INIT_VIDEO);
return false;
}
// Fullscreen borderless — the compositor sizes us to the output. A
// requested mode only seeds the initial (windowed) size; fullscreen
// overrides it.
const int win_w = s_requested_width != 0 ? static_cast<int>(s_requested_width) : 1280;
const int win_h = s_requested_height != 0 ? static_cast<int>(s_requested_height) : 720;
s_wayland_window = SDL_CreateWindow("ARMSX2", win_w, win_h,
SDL_WINDOW_VULKAN | SDL_WINDOW_FULLSCREEN);
if (!s_wayland_window)
{
Console.WarningFmt("Wayland fallback: SDL_CreateWindow failed ({}); using VK_KHR_display.",
SDL_GetError());
SDL_QuitSubSystem(SDL_INIT_VIDEO);
return false;
}
}
if (!s_wayland_window)
return false;
const SDL_PropertiesID props = SDL_GetWindowProperties(s_wayland_window);
void* wl_display = SDL_GetPointerProperty(props, SDL_PROP_WINDOW_WAYLAND_DISPLAY_POINTER, nullptr);
void* wl_surface = SDL_GetPointerProperty(props, SDL_PROP_WINDOW_WAYLAND_SURFACE_POINTER, nullptr);
if (!wl_display || !wl_surface)
{
Console.Warning("Wayland fallback: SDL window exposed no wl_display/wl_surface; using VK_KHR_display.");
return false;
}
int px_w = 0, px_h = 0;
SDL_GetWindowSizeInPixels(s_wayland_window, &px_w, &px_h);
wi.type = WindowInfo::Type::Wayland;
wi.display_connection = wl_display;
wi.window_handle = wl_surface;
wi.surface_handle = nullptr;
wi.surface_width = px_w > 0 ? static_cast<u32>(px_w) : s_requested_width;
wi.surface_height = px_h > 0 ? static_cast<u32>(px_h) : s_requested_height;
wi.surface_scale = SDL_GetWindowPixelDensity(s_wayland_window);
if (wi.surface_scale <= 0.0f)
wi.surface_scale = 1.0f;
return true;
}
#endif
std::optional<WindowInfo> Pcsx2SDL::BuildWindowInfo()
{
#if defined(WAYLAND_API)
if (WaylandSessionPresent())
{
WindowInfo wl_wi;
if (BuildWaylandWindowInfo(wl_wi))
return wl_wi;
// Fell through: no usable Wayland surface — try VK_KHR_display below.
}
#endif
WindowInfo wi;
wi.type = WindowInfo::Type::VulkanDirect;
wi.surface_width = s_requested_width;
wi.surface_height = s_requested_height;
wi.surface_scale = 1.0f;
wi.display_connection = nullptr;
wi.window_handle = nullptr;
wi.surface_handle = nullptr;
return wi;
}
//////////////////////////////////////////////////////////////////////////
// Host:: callbacks
//////////////////////////////////////////////////////////////////////////
void Host::CommitBaseSettingChanges()
{
if (!s_base_settings)
return;
Error err;
if (!s_base_settings->Save(&err))
Console.ErrorFmt("Failed to save settings: {}", err.GetDescription());
}
void Host::LoadSettings(SettingsInterface& si, std::unique_lock<std::mutex>& lock)
{
// No host-specific settings layer to merge in — everything lives in the
// base INI.
}
void Host::CheckForSettingsChanges(const Pcsx2Config& old_config)
{
}
bool Host::RequestResetSettings(bool folders, bool core, bool controllers, bool hotkeys, bool ui)
{
// FullscreenUI will trigger this; no UI is wired to drive it yet.
return false;
}
void Host::SetDefaultUISettings(SettingsInterface& si)
{
// Handheld defaults — start straight into FullscreenUI when no game is
// loaded, hide pointer (no mouse), confirm via cross/A button.
si.SetBoolValue("UI", "StartBigPictureMode", true);
}
bool Host::LocaleCircleConfirm()
{
return false;
}
std::unique_ptr<ProgressCallback> Host::CreateHostProgressCallback()
{
return ProgressCallback::CreateNullProgressCallback();
}
void Host::ReportInfoAsync(const std::string_view title, const std::string_view message)
{
if (!title.empty() && !message.empty())
INFO_LOG("{}: {}", title, message);
else if (!message.empty())
INFO_LOG("{}", message);
}
void Host::ReportErrorAsync(const std::string_view title, const std::string_view message)
{
if (!title.empty() && !message.empty())
ERROR_LOG("{}: {}", title, message);
else if (!message.empty())
ERROR_LOG("{}", message);
}
void Host::OpenURL(const std::string_view url)
{
}
bool Host::CopyTextToClipboard(const std::string_view text)
{
return false;
}
std::string Host::GetTextFromClipboard()
{
return std::string();
}
void Host::BeginTextInput()
{
}
void Host::EndTextInput()
{
}
std::optional<WindowInfo> Host::GetTopLevelWindowInfo()
{
return Pcsx2SDL::BuildWindowInfo();
}
void Host::OnInputDeviceConnected(const std::string_view identifier, const std::string_view device_name)
{
INFO_LOG("Input device connected: {} ({})", identifier, device_name);
}
void Host::OnInputDeviceDisconnected(const InputBindingKey key, const std::string_view identifier)
{
INFO_LOG("Input device disconnected: {}", identifier);
}
void Host::SetMouseMode(bool relative_mode, bool hide_cursor)
{
}
void Host::SetMouseLock(bool state)
{
}
std::optional<WindowInfo> Host::AcquireRenderWindow(bool recreate_window)
{
return Pcsx2SDL::BuildWindowInfo();
}
void Host::ReleaseRenderWindow()
{
}
void Host::BeginPresentFrame()
{
}
void Host::RequestResizeHostDisplay(s32 width, s32 height)
{
// VK_KHR_display provides a fixed mode for the lifetime of the surface;
// resize requests from the core are advisory only.
}
void Host::OnVMStarting()
{
}
void Host::OnVMStarted()
{
}
void Host::OnVMDestroyed()
{
}
void Host::OnVMPaused()
{
}
void Host::OnVMResumed()
{
}
void Host::OnGameChanged(const std::string& title, const std::string& elf_override, const std::string& disc_path,
const std::string& disc_serial, u32 disc_crc, u32 current_crc)
{
if (!title.empty())
INFO_LOG("Game changed: {} (serial {}, CRC {:08X})", title, disc_serial, current_crc);
}
void Host::OnPerformanceMetricsUpdated()
{
}
void Host::OnSaveStateLoading(const std::string_view filename)
{
}
void Host::OnSaveStateLoaded(const std::string_view filename, bool was_successful)
{
}
void Host::OnSaveStateSaved(const std::string_view filename)
{
}
void Pcsx2SDL::DrainCPUThreadQueue()
{
for (;;)
{
std::function<void()> fn;
{
std::lock_guard<std::mutex> lock(s_cpu_queue_lock);
if (s_cpu_queue.empty())
return;
fn = std::move(s_cpu_queue.front());
s_cpu_queue.pop_front();
}
fn();
}
}
void Host::PumpMessagesOnCPUThread()
{
// Honour SIGTERM / SIGINT picked up by the signal handler.
if (s_shutdown_requested.load(std::memory_order_acquire) && VMManager::HasValidVM())
VMManager::SetState(VMState::Stopping);
Pcsx2SDL::DrainCPUThreadQueue();
}
void Host::RunOnCPUThread(std::function<void()> function, bool block)
{
if (block)
{
// Inline if already on the CPU thread to avoid self-deadlock.
if (s_cpu_thread_id.load(std::memory_order_acquire) == std::this_thread::get_id())
{
function();
return;
}
std::mutex done_lock;
std::condition_variable done_cv;
bool done = false;
auto wrapped = [&function, &done_lock, &done_cv, &done]() {
function();
std::lock_guard<std::mutex> lk(done_lock);
done = true;
done_cv.notify_all();
};
{
std::lock_guard<std::mutex> lock(s_cpu_queue_lock);
s_cpu_queue.emplace_back(std::move(wrapped));
}
s_cpu_queue_cv.notify_all();
std::unique_lock<std::mutex> lk(done_lock);
done_cv.wait(lk, [&done]() { return done; });
return;
}
{
std::lock_guard<std::mutex> lock(s_cpu_queue_lock);
s_cpu_queue.emplace_back(std::move(function));
}
s_cpu_queue_cv.notify_all();
}
void Pcsx2SDL::StopGameListRefreshThread()
{
if (!s_gamelist_thread.joinable())
return;
s_gamelist_thread.join();
}
void Host::RefreshGameListAsync(bool invalidate_cache)
{
// Only one scan at a time — FullscreenUI's "rescan" button can fire
// multiple times in quick succession; coalesce by joining the previous
// scan first.
Pcsx2SDL::StopGameListRefreshThread();
s_gamelist_running.store(true, std::memory_order_release);
s_gamelist_thread = std::thread([invalidate_cache]() {
Threading::SetNameOfCurrentThread("GameList Refresh");
GameList::Refresh(invalidate_cache, false, nullptr);
s_gamelist_running.store(false, std::memory_order_release);
});
}
void Host::CancelGameListRefresh()
{
Pcsx2SDL::StopGameListRefreshThread();
}
bool Host::IsFullscreen()
{
return true;
}
void Host::SetFullscreen(bool enabled)
{
// No-op: VK_KHR_display is always fullscreen; there's no compositor to
// host a windowed mode.
}
void Host::OnCaptureStarted(const std::string& filename)
{
}
void Host::OnCaptureStopped()
{
}
void Host::RequestExitApplication(bool allow_confirm)
{
s_shutdown_requested.store(true, std::memory_order_release);
if (VMManager::HasValidVM())
VMManager::SetState(VMState::Stopping);
}
void Host::RequestExitBigPicture()
{
// FullscreenUI exit — shut down the application.
Host::RequestExitApplication(false);
}
void Host::RequestVMShutdown(bool allow_confirm, bool allow_save_state, bool default_save_state)
{
VMManager::SetState(VMState::Stopping);
}
void Host::OnAchievementsLoginSuccess(const char* username, u32 points, u32 sc_points, u32 unread_messages)
{
}
void Host::OnAchievementsLoginRequested(Achievements::LoginRequestReason reason)
{
}
void Host::OnAchievementsHardcoreModeChanged(bool enabled)
{
}
bool Host::HasNativeAchievementNotifications() { return false; }
void Host::OnAchievementNotification(const char*, float, const char*, const char*, const char*) {}
void Host::OnAchievementsRefreshed()
{
}
void Host::OnCoverDownloaderOpenRequested()
{
}
void Host::OnCreateMemoryCardOpenRequested()
{
}
bool Host::InBatchMode()
{
return false;
}
bool Host::InNoGUIMode()
{
return false;
}
bool Host::ShouldPreferHostFileSelector()
{
return false;
}
void Host::OpenHostFileSelectorAsync(std::string_view title, bool select_directory, FileSelectorCallback callback,
FileSelectorFilters filters, std::string_view initial_directory)
{
// No native file picker on a kmsdrm-only handheld. FullscreenUI's own
// game list / file browser handles this path.
callback(std::string());
}
int Host::LocaleSensitiveCompare(std::string_view lhs, std::string_view rhs)
{
const int res = std::strncmp(lhs.data(), rhs.data(), std::min(lhs.size(), rhs.size()));
if (res != 0)
return res;
return lhs.size() > rhs.size() ? 1 : (lhs.size() < rhs.size() ? -1 : 0);
}
s32 Host::Internal::GetTranslatedStringImpl(
const std::string_view context, const std::string_view msg, char* tbuf, size_t tbuf_space)
{
if (msg.size() > tbuf_space)
return -1;
if (msg.empty())
return 0;
std::memcpy(tbuf, msg.data(), msg.size());
return static_cast<s32>(msg.size());
}
std::string Host::TranslatePluralToString(const char* context, const char* msg, const char* disambiguation, int count)
{
TinyString count_str = TinyString::from_format("{}", count);
std::string ret(msg);
for (;;)
{
std::string::size_type pos = ret.find("%n");
if (pos == std::string::npos)
break;
ret.replace(pos, 2, count_str.view());
}
return ret;
}
std::optional<u32> InputManager::ConvertHostKeyboardStringToCode(const std::string_view str)
{
return std::nullopt;
}
std::optional<std::string> InputManager::ConvertHostKeyboardCodeToString(u32 code)
{
return std::nullopt;
}
const char* InputManager::ConvertHostKeyboardCodeToIcon(u32 code)
{
return nullptr;
}
BEGIN_HOTKEY_LIST(g_host_hotkeys)
END_HOTKEY_LIST()
//////////////////////////////////////////////////////////////////////////
// CPU thread + main
//////////////////////////////////////////////////////////////////////////
void Pcsx2SDL::CPUThreadMain(VMBootParameters initial_params, bool start_in_fsui, std::atomic<int>* ret)
{
ret->store(EXIT_FAILURE);
s_cpu_thread_id.store(std::this_thread::get_id(), std::memory_order_release);
if (!VMManager::Internal::CPUThreadInitialize())
{
Console.Error("CPU thread init failed.");
VMManager::Internal::CPUThreadShutdown();
return;
}
VMManager::ApplySettings();
// SDL doesn't enumerate already-connected gamepads at init time — the
// initial SDL_EVENT_GAMEPAD_ADDED events sit in SDL's queue until
// something calls SDL_PollEvent. Drain them now and rebind, so any
// SDL2-style A/B/X/Y face-button bindings get migrated to SDL3 positional
// names (FaceSouth/East/West/North) before use. Qt sidesteps this via its
// background-poll QTimer; the SDL frontend must drain explicitly here.
InputManager::ReloadDevices();
VMManager::ReloadInputBindings(true);
// Bring up the GS thread + display surface before booting anything when
// starting straight into FullscreenUI. With a VM-on-startup path the
// VMManager::Initialize call below will open MTGS itself; with the
// bootless FSUI path it must be opened manually so the game-picker is
// visible before the user selects a game.
if (start_in_fsui)
{
ImGuiManager::InitializeFullscreenUI();
if (!MTGS::WaitForOpen())
{
Console.Error("Failed to open MTGS for FullscreenUI startup.");
VMManager::Internal::CPUThreadShutdown();
return;
}
MTGS::SetRunIdle(true);
}
// The "initial" boot is the ISO/state passed on the CLI. When launching
// straight into FullscreenUI instead, this stays empty and the user picks
// a game from the game-list page (which queues a VMManager::Initialize
// call back via Host::RunOnCPUThread).
std::optional<VMBootParameters> pending_boot;
if (!start_in_fsui)
pending_boot = std::move(initial_params);
bool clean_shutdown = true;
// Main CPU thread state-machine loop. Exits on shutdown_requested OR on
// VM shutdown when no FullscreenUI session is active to fall back to.
while (!s_shutdown_requested.load(std::memory_order_acquire))
{
// Drain RunOnCPUThread callbacks (also done inside Execute via
// PumpMessagesOnCPUThread, but must be drained in the no-VM idle
// state too).
Pcsx2SDL::DrainCPUThreadQueue();
const VMState state = VMManager::GetState();
switch (state)
{
case VMState::Initializing:
// Transient — just spin until VMManager moves on.
continue;
case VMState::Running:
VMManager::Execute();
continue;
case VMState::Resetting:
VMManager::Reset();
continue;
case VMState::Stopping:
VMManager::Shutdown(false);
// After a clean shutdown, fall through to Shutdown / Paused.
continue;
case VMState::Paused:
case VMState::Shutdown:
{
// If a CLI-supplied boot is pending, kick it now.
if (pending_boot.has_value())
{
VMBootParameters bp = std::move(pending_boot.value());
pending_boot.reset();
const VMBootResult br = VMManager::Initialize(bp);
if (br != VMBootResult::StartupSuccess)
{
Console.ErrorFmt("VMManager::Initialize failed (result {}).",
static_cast<int>(br));
clean_shutdown = false;
s_shutdown_requested.store(true, std::memory_order_release);
break;
}
VMManager::SetState(VMState::Running);
continue;
}
// Nothing pending. If no FSUI session is running, exit cleanly
// — the user's game finished and there is no UI to show next,
// so the frontend's job is done.
if (!start_in_fsui)
{
s_shutdown_requested.store(true, std::memory_order_release);
break;
}
// FSUI idle: pump input so the gamepad can drive the menus.
// Qt does this from a background QTimer; here it has to run
// inline. Do it *outside* s_cpu_queue_lock — FSUI menu
// callbacks can call Host::RunOnCPUThread, which takes the
// same lock.
VMManager::IdlePollUpdate();
// Then wait for either a queued RunOnCPUThread (which might
// Initialize a new VM) or a state change. Bounded timeout so
// the shutdown flag is rechecked promptly.
std::unique_lock<std::mutex> lock(s_cpu_queue_lock);
s_cpu_queue_cv.wait_for(lock, std::chrono::milliseconds(16),
[]() { return !s_cpu_queue.empty(); });
continue;
}
default:
continue;
}
}
// Tear down in reverse order of setup. MTGS may already have been closed
// by VMManager::Shutdown if VMManager opened it; if opened for FSUI
// directly it will still be open here.
if (VMManager::HasValidVM())
VMManager::Shutdown(false);
if (MTGS::IsOpen())
{
MTGS::SetRunIdle(false);
MTGS::WaitForClose();
}
Pcsx2SDL::StopGameListRefreshThread();
VMManager::Internal::CPUThreadShutdown();
s_cpu_thread_id.store(std::thread::id{}, std::memory_order_release);
ret->store(clean_shutdown ? EXIT_SUCCESS : EXIT_FAILURE);
}
int main(int argc, char* argv[])
{
// Short-circuit --help/--version before any heavyweight init so they
// work even on a system where the resources dir hasn't been laid down.
for (int i = 1; i < argc; i++)
{
if (!std::strcmp(argv[i], "--help") || !std::strcmp(argv[i], "-h"))
{
std::fprintf(stderr, "PCSX2 SDL frontend %s\n", GIT_REV);
std::fprintf(stderr, "Usage: %s [options] <iso-path>\n\n", argv[0]);
std::fprintf(stderr, " --fullscreen-mode WxH Request a specific display mode\n");
std::fprintf(stderr, " (default: monitor's preferred mode)\n");
std::fprintf(stderr, " --bios-only Boot the BIOS without loading a disc\n");
std::fprintf(stderr, " --state-from-file PATH Resume from a save state file\n");
std::fprintf(stderr, " --no-fast-boot Skip fast boot (run full BIOS animation)\n");
std::fprintf(stderr, " -h, --help Show this help and exit\n");
std::fprintf(stderr, " --version Show version and exit\n");
return EXIT_SUCCESS;
}
if (!std::strcmp(argv[i], "--version"))
{
std::fprintf(stderr, "PCSX2 SDL frontend %s\n", GIT_REV);
return EXIT_SUCCESS;
}
}
CrashHandler::Install();
Log::SetConsoleOutputLevel(LOGLEVEL_INFO);
if (!Pcsx2SDL::InitializeConfig())
{
Console.Error("Failed to initialize config.");
return EXIT_FAILURE;
}
VMBootParameters params;
if (!Pcsx2SDL::ParseCommandLineArgs(argc, argv, params))
return EXIT_FAILURE;
const bool have_boot_target = !params.filename.empty() || params.source_type.has_value();
bool start_in_fsui = false;
if (!have_boot_target)
{
// No ISO supplied and no --bios-only — only valid if the
// StartBigPictureMode flag is set, in which case the frontend starts
// in the FullscreenUI game-picker.
if (Host::GetBaseBoolSettingValue("UI", "StartBigPictureMode", true))
{
start_in_fsui = true;
}
else
{
std::fprintf(stderr, "No ISO path supplied. Use --bios-only to boot the BIOS, "
"or set UI/StartBigPictureMode=true in PCSX2.ini for the game-picker.\n");
return EXIT_FAILURE;
}
}
Pcsx2SDL::InstallSignalHandler();
SysMemory::ReserveMemory();
std::atomic<int> thread_ret{EXIT_FAILURE};
std::thread cpu_thread([&]() {
Pcsx2SDL::CPUThreadMain(std::move(params), start_in_fsui, &thread_ret);
});
// VK_KHR_display has no host event loop; SDL3 input pumping happens
// inside InputManager on the CPU thread; signals are async. The main
// thread just waits for shutdown.
cpu_thread.join();
#if defined(WAYLAND_API)
// Tear down the Wayland fallback window, if one was created. Done after the
// CPU/GS threads are gone so nothing is still presenting to the surface.
if (s_wayland_window)
{
SDL_DestroyWindow(s_wayland_window);
s_wayland_window = nullptr;
SDL_QuitSubSystem(SDL_INIT_VIDEO);
}
#endif
return thread_ret.load();
}