/* PCSX2 - PS2 Emulator for PCs * Copyright (C) 2002-2022 PCSX2 Dev Team * * PCSX2 is free software: you can redistribute it and/or modify it under the terms * of the GNU Lesser General Public License as published by the Free Software Found- * ation, either version 3 of the License, or (at your option) any later version. * * PCSX2 is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR * PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with PCSX2. * If not, see . */ #include "PrecompiledHeader.h" #include #include #include #include "common/Assertions.h" #include "common/Console.h" #include "common/Exceptions.h" #include "common/SettingsWrapper.h" #include "common/StringUtil.h" #include "pcsx2/CDVD/CDVD.h" #include "pcsx2/Frontend/InputManager.h" #include "pcsx2/Frontend/ImGuiManager.h" #include "pcsx2/GS.h" #include "pcsx2/GS/GS.h" #include "pcsx2/GSDumpReplayer.h" #include "pcsx2/HostDisplay.h" #include "pcsx2/PAD/Host/PAD.h" #include "pcsx2/PerformanceMetrics.h" #include "pcsx2/VMManager.h" #include "DisplayWidget.h" #include "EmuThread.h" #include "MainWindow.h" #include "QtHost.h" #include "QtUtils.h" EmuThread* g_emu_thread = nullptr; WindowInfo g_gs_window_info; static std::unique_ptr s_host_display; EmuThread::EmuThread(QThread* ui_thread) : QThread() , m_ui_thread(ui_thread) { } EmuThread::~EmuThread() = default; bool EmuThread::isOnEmuThread() const { return QThread::currentThread() == this; } void EmuThread::start() { pxAssertRel(!g_emu_thread, "Emu thread does not exist"); g_emu_thread = new EmuThread(QThread::currentThread()); g_emu_thread->QThread::start(); g_emu_thread->m_started_semaphore.acquire(); g_emu_thread->moveToThread(g_emu_thread); g_main_window->connectVMThreadSignals(g_emu_thread); } void EmuThread::stop() { pxAssertRel(g_emu_thread, "Emu thread exists"); pxAssertRel(!g_emu_thread->isOnEmuThread(), "Not called on the emu thread"); QMetaObject::invokeMethod(g_emu_thread, &EmuThread::stopInThread, Qt::QueuedConnection); while (g_emu_thread->isRunning()) QApplication::processEvents(QEventLoop::ExcludeUserInputEvents, 1); } void EmuThread::stopInThread() { if (VMManager::HasValidVM()) destroyVM(); m_event_loop->quit(); m_shutdown_flag.store(true); } void EmuThread::startVM(std::shared_ptr boot_params) { if (!isOnEmuThread()) { QMetaObject::invokeMethod(this, "startVM", Qt::QueuedConnection, Q_ARG(std::shared_ptr, boot_params)); return; } pxAssertRel(!VMManager::HasValidVM(), "VM is shut down"); loadOurSettings(); emit onVMStarting(); // create the display, this may take a while... m_is_fullscreen = boot_params->fullscreen.value_or(QtHost::GetBaseBoolSettingValue("UI", "StartFullscreen", false)); m_is_rendering_to_main = QtHost::GetBaseBoolSettingValue("UI", "RenderToMainWindow", true); m_is_surfaceless = false; m_save_state_on_shutdown = false; if (!VMManager::Initialize(*boot_params)) return; VMManager::SetState(VMState::Running); m_event_loop->quit(); } void EmuThread::resetVM() { if (!isOnEmuThread()) { QMetaObject::invokeMethod(this, &EmuThread::resetVM, Qt::QueuedConnection); return; } VMManager::Reset(); } void EmuThread::setVMPaused(bool paused) { if (!isOnEmuThread()) { QMetaObject::invokeMethod(this, "setVMPaused", Qt::QueuedConnection, Q_ARG(bool, paused)); return; } // if we were surfaceless (view->game list, system->unpause), get our display widget back if (!paused && m_is_surfaceless) setSurfaceless(false); VMManager::SetPaused(paused); } void EmuThread::shutdownVM(bool save_state /* = true */) { if (!isOnEmuThread()) { QMetaObject::invokeMethod(this, "shutdownVM", Qt::QueuedConnection, Q_ARG(bool, save_state)); return; } const VMState state = VMManager::GetState(); if (state == VMState::Paused) m_event_loop->quit(); else if (state != VMState::Running) return; m_save_state_on_shutdown = save_state; VMManager::SetState(VMState::Stopping); } void EmuThread::loadState(const QString& filename) { if (!isOnEmuThread()) { QMetaObject::invokeMethod(this, "loadState", Qt::QueuedConnection, Q_ARG(const QString&, filename)); return; } if (!VMManager::HasValidVM()) return; VMManager::LoadState(filename.toUtf8().constData()); } void EmuThread::loadStateFromSlot(qint32 slot) { if (!isOnEmuThread()) { QMetaObject::invokeMethod(this, "loadStateFromSlot", Qt::QueuedConnection, Q_ARG(qint32, slot)); return; } if (!VMManager::HasValidVM()) return; VMManager::LoadStateFromSlot(slot); } void EmuThread::saveState(const QString& filename) { if (!isOnEmuThread()) { QMetaObject::invokeMethod(this, "saveState", Qt::QueuedConnection, Q_ARG(const QString&, filename)); return; } if (!VMManager::HasValidVM()) return; if (!VMManager::SaveState(filename.toUtf8().constData())) { // this one is usually the result of a user-chosen path, so we can display a message box safely here Console.Error("Failed to save state"); } } void EmuThread::saveStateToSlot(qint32 slot) { if (!isOnEmuThread()) { QMetaObject::invokeMethod(this, "saveStateToSlot", Qt::QueuedConnection, Q_ARG(qint32, slot)); return; } if (!VMManager::HasValidVM()) return; VMManager::SaveStateToSlot(slot); } void EmuThread::run() { PerformanceMetrics::SetCPUThread(Threading::ThreadHandle::GetForCallingThread()); m_event_loop = new QEventLoop(); m_started_semaphore.release(); if (!VMManager::Internal::InitializeMemory()) pxFailRel("Failed to allocate memory map"); // we need input sources ready for binding reloadInputSources(); createBackgroundControllerPollTimer(); startBackgroundControllerPollTimer(); while (!m_shutdown_flag.load()) { if (!VMManager::HasValidVM()) { m_event_loop->exec(); continue; } executeVM(); } stopBackgroundControllerPollTimer(); destroyBackgroundControllerPollTimer(); InputManager::CloseSources(); VMManager::WaitForSaveStateFlush(); VMManager::Internal::ReleaseMemory(); PerformanceMetrics::SetCPUThread(Threading::ThreadHandle()); moveToThread(m_ui_thread); deleteLater(); } void EmuThread::destroyVM() { m_last_speed = 0.0f; m_last_game_fps = 0.0f; m_last_video_fps = 0.0f; m_last_internal_width = 0; m_last_internal_height = 0; VMManager::Shutdown(m_save_state_on_shutdown); } void EmuThread::executeVM() { for (;;) { switch (VMManager::GetState()) { case VMState::Initializing: pxFailRel("Shouldn't be in the starting state state"); continue; case VMState::Paused: m_event_loop->exec(); continue; case VMState::Running: m_event_loop->processEvents(QEventLoop::AllEvents); VMManager::Execute(); continue; case VMState::Stopping: destroyVM(); m_event_loop->processEvents(QEventLoop::AllEvents); return; default: continue; } } } void EmuThread::createBackgroundControllerPollTimer() { pxAssert(!m_background_controller_polling_timer); m_background_controller_polling_timer = new QTimer(this); m_background_controller_polling_timer->setSingleShot(false); m_background_controller_polling_timer->setTimerType(Qt::CoarseTimer); connect(m_background_controller_polling_timer, &QTimer::timeout, this, &EmuThread::doBackgroundControllerPoll); } void EmuThread::destroyBackgroundControllerPollTimer() { delete m_background_controller_polling_timer; m_background_controller_polling_timer = nullptr; } void EmuThread::startBackgroundControllerPollTimer() { if (m_background_controller_polling_timer->isActive()) return; m_background_controller_polling_timer->start(BACKGROUND_CONTROLLER_POLLING_INTERVAL); } void EmuThread::stopBackgroundControllerPollTimer() { if (!m_background_controller_polling_timer->isActive()) return; m_background_controller_polling_timer->stop(); } void EmuThread::doBackgroundControllerPoll() { InputManager::PollSources(); } void EmuThread::toggleFullscreen() { if (!isOnEmuThread()) { QMetaObject::invokeMethod(this, &EmuThread::toggleFullscreen, Qt::QueuedConnection); return; } setFullscreen(!m_is_fullscreen); } void EmuThread::setFullscreen(bool fullscreen) { if (!isOnEmuThread()) { QMetaObject::invokeMethod(this, "setFullscreen", Qt::QueuedConnection, Q_ARG(bool, fullscreen)); return; } if (!VMManager::HasValidVM() || m_is_fullscreen == fullscreen) return; // This will call back to us on the MTGS thread. m_is_fullscreen = fullscreen; GetMTGS().UpdateDisplayWindow(); GetMTGS().WaitGS(); } void EmuThread::setSurfaceless(bool surfaceless) { if (!isOnEmuThread()) { QMetaObject::invokeMethod(this, "setSurfaceless", Qt::QueuedConnection, Q_ARG(bool, surfaceless)); return; } if (!VMManager::HasValidVM() || m_is_surfaceless == surfaceless) return; // This will call back to us on the MTGS thread. m_is_surfaceless = surfaceless; GetMTGS().UpdateDisplayWindow(); GetMTGS().WaitGS(); } void EmuThread::applySettings() { if (!isOnEmuThread()) { QMetaObject::invokeMethod(this, &EmuThread::applySettings, Qt::QueuedConnection); return; } checkForSettingChanges(); VMManager::ApplySettings(); } void EmuThread::reloadGameSettings() { if (!isOnEmuThread()) { QMetaObject::invokeMethod(this, &EmuThread::reloadGameSettings, Qt::QueuedConnection); return; } // this will skip applying settings when they're not active if (VMManager::ReloadGameSettings()) { // none of these settings below are per-game.. for now. but in case they are in the future. checkForSettingChanges(); } } void EmuThread::loadOurSettings() { m_verbose_status = QtHost::GetBaseBoolSettingValue("UI", "VerboseStatusBar", false); } void EmuThread::checkForSettingChanges() { if (VMManager::HasValidVM()) { const bool render_to_main = QtHost::GetBaseBoolSettingValue("UI", "RenderToMainWindow", true); if (!m_is_fullscreen && m_is_rendering_to_main != render_to_main) { m_is_rendering_to_main = render_to_main; GetMTGS().UpdateDisplayWindow(); GetMTGS().WaitGS(); } } const bool last_verbose_status = m_verbose_status; loadOurSettings(); if (m_verbose_status != last_verbose_status) updatePerformanceMetrics(true); } void EmuThread::toggleSoftwareRendering() { if (!isOnEmuThread()) { QMetaObject::invokeMethod(this, &EmuThread::toggleSoftwareRendering, Qt::QueuedConnection); return; } if (!VMManager::HasValidVM()) return; GetMTGS().ToggleSoftwareRendering(); } void EmuThread::switchRenderer(GSRendererType renderer) { if (!isOnEmuThread()) { QMetaObject::invokeMethod(this, "switchRenderer", Qt::QueuedConnection, Q_ARG(GSRendererType, renderer)); return; } if (!VMManager::HasValidVM()) return; GetMTGS().SwitchRenderer(renderer); } void EmuThread::changeDisc(const QString& path) { if (!isOnEmuThread()) { QMetaObject::invokeMethod(this, "changeDisc", Qt::QueuedConnection, Q_ARG(const QString&, path)); return; } if (!VMManager::HasValidVM()) return; VMManager::ChangeDisc(path.toStdString()); } void EmuThread::reloadPatches() { if (!isOnEmuThread()) { QMetaObject::invokeMethod(this, &EmuThread::reloadPatches, Qt::QueuedConnection); return; } if (!VMManager::HasValidVM()) return; VMManager::ReloadPatches(true); } void EmuThread::reloadInputSources() { if (!isOnEmuThread()) { QMetaObject::invokeMethod(this, &EmuThread::reloadInputSources, Qt::QueuedConnection); return; } auto lock = Host::GetSettingsLock(); SettingsInterface* si = Host::GetSettingsInterface(); InputManager::ReloadSources(*si); // skip loading bindings if we're not running, since it'll get done on startup anyway if (VMManager::HasValidVM()) InputManager::ReloadBindings(*si); } void EmuThread::reloadInputBindings() { if (!isOnEmuThread()) { QMetaObject::invokeMethod(this, &EmuThread::reloadInputBindings, Qt::QueuedConnection); return; } // skip loading bindings if we're not running, since it'll get done on startup anyway if (!VMManager::HasValidVM()) return; auto lock = Host::GetSettingsLock(); SettingsInterface* si = Host::GetSettingsInterface(); InputManager::ReloadBindings(*si); } void EmuThread::requestDisplaySize(float scale) { if (!isOnEmuThread()) { QMetaObject::invokeMethod(this, "requestDisplaySize", Qt::QueuedConnection, Q_ARG(float, scale)); return; } if (!VMManager::HasValidVM()) return; VMManager::RequestDisplaySize(scale); } void EmuThread::enumerateInputDevices() { if (!isOnEmuThread()) { QMetaObject::invokeMethod(this, &EmuThread::enumerateInputDevices, Qt::QueuedConnection); return; } const std::vector> devs(InputManager::EnumerateDevices()); QList> qdevs; qdevs.reserve(devs.size()); for (const std::pair& dev : devs) qdevs.emplace_back(QString::fromStdString(dev.first), QString::fromStdString(dev.second)); onInputDevicesEnumerated(qdevs); } void EmuThread::enumerateVibrationMotors() { if (!isOnEmuThread()) { QMetaObject::invokeMethod(this, &EmuThread::enumerateVibrationMotors, Qt::QueuedConnection); return; } const std::vector motors(InputManager::EnumerateMotors()); QList qmotors; qmotors.reserve(motors.size()); for (InputBindingKey key : motors) qmotors.push_back(key); onVibrationMotorsEnumerated(qmotors); } void EmuThread::connectDisplaySignals(DisplayWidget* widget) { widget->disconnect(this); connect(widget, &DisplayWidget::windowFocusEvent, this, &EmuThread::onDisplayWindowFocused); connect(widget, &DisplayWidget::windowResizedEvent, this, &EmuThread::onDisplayWindowResized); // connect(widget, &DisplayWidget::windowRestoredEvent, this, &EmuThread::redrawDisplayWindow); connect(widget, &DisplayWidget::windowKeyEvent, this, &EmuThread::onDisplayWindowKeyEvent); connect(widget, &DisplayWidget::windowMouseMoveEvent, this, &EmuThread::onDisplayWindowMouseMoveEvent); connect(widget, &DisplayWidget::windowMouseButtonEvent, this, &EmuThread::onDisplayWindowMouseButtonEvent); connect(widget, &DisplayWidget::windowMouseWheelEvent, this, &EmuThread::onDisplayWindowMouseWheelEvent); } void EmuThread::onDisplayWindowMouseMoveEvent(int x, int y) {} void EmuThread::onDisplayWindowMouseButtonEvent(int button, bool pressed) { InputManager::InvokeEvents(InputManager::MakeHostMouseButtonKey(button), pressed ? 1.0f : 0.0f); } void EmuThread::onDisplayWindowMouseWheelEvent(const QPoint& delta_angle) {} void EmuThread::onDisplayWindowKeyEvent(int key, bool pressed) { InputManager::InvokeEvents(InputManager::MakeHostKeyboardKey(key), pressed ? 1.0f : 0.0f); } void EmuThread::onDisplayWindowResized(int width, int height, float scale) { if (!VMManager::HasValidVM()) return; GetMTGS().ResizeDisplayWindow(width, height, scale); } void EmuThread::onDisplayWindowFocused() {} void EmuThread::runOnCPUThread(const std::function& func) { func(); } void EmuThread::updateDisplay() { pxAssertRel(!isOnEmuThread(), "Not on emu thread"); // finished with the display for now HostDisplay* display = Host::GetHostDisplay(); display->DoneRenderContextCurrent(); // but we should get it back after this call onUpdateDisplayRequested(m_is_fullscreen, !m_is_fullscreen && m_is_rendering_to_main, m_is_surfaceless); if (!display->MakeRenderContextCurrent()) { pxFailRel("Failed to recreate context after updating"); return; } } HostDisplay* EmuThread::acquireHostDisplay(HostDisplay::RenderAPI api) { s_host_display = HostDisplay::CreateDisplayForAPI(api); if (!s_host_display) return nullptr; DisplayWidget* widget = emit onCreateDisplayRequested(m_is_fullscreen, m_is_rendering_to_main); if (!widget) { s_host_display.reset(); return nullptr; } connectDisplaySignals(widget); if (!s_host_display->MakeRenderContextCurrent()) { Console.Error("Failed to make render context current"); releaseHostDisplay(); return nullptr; } if (!s_host_display->InitializeRenderDevice(StringUtil::wxStringToUTF8String(EmuFolders::Cache.ToString()), false) || !ImGuiManager::Initialize()) { Console.Error("Failed to initialize device/imgui"); releaseHostDisplay(); return nullptr; } g_gs_window_info = s_host_display->GetWindowInfo(); Console.WriteLn(Color_StrongGreen, "%s Graphics Driver Info:", HostDisplay::RenderAPIToString(s_host_display->GetRenderAPI())); Console.Indent().WriteLn(s_host_display->GetDriverInfo()); return s_host_display.get(); } void EmuThread::releaseHostDisplay() { ImGuiManager::Shutdown(); if (s_host_display) { s_host_display->DestroyRenderSurface(); s_host_display->DestroyRenderDevice(); } g_gs_window_info = WindowInfo(); emit onDestroyDisplayRequested(); s_host_display.reset(); } HostDisplay* Host::GetHostDisplay() { return s_host_display.get(); } HostDisplay* Host::AcquireHostDisplay(HostDisplay::RenderAPI api) { return g_emu_thread->acquireHostDisplay(api); } void Host::ReleaseHostDisplay() { g_emu_thread->releaseHostDisplay(); } bool Host::BeginPresentFrame(bool frame_skip) { return s_host_display->BeginPresent(frame_skip); } void Host::EndPresentFrame() { if (GSDumpReplayer::IsReplayingDump()) GSDumpReplayer::RenderUI(); ImGuiManager::RenderOSD(); s_host_display->EndPresent(); ImGuiManager::NewFrame(); } void Host::ResizeHostDisplay(u32 new_window_width, u32 new_window_height, float new_window_scale) { s_host_display->ResizeRenderWindow(new_window_width, new_window_height, new_window_scale); ImGuiManager::WindowResized(); } void Host::RequestResizeHostDisplay(s32 width, s32 height) { g_emu_thread->onResizeDisplayRequested(width, height); } void Host::UpdateHostDisplay() { g_emu_thread->updateDisplay(); ImGuiManager::WindowResized(); } void Host::OnVMStarting() { g_emu_thread->stopBackgroundControllerPollTimer(); emit g_emu_thread->onVMStarting(); } void Host::OnVMStarted() { emit g_emu_thread->onVMStarted(); } void Host::OnVMDestroyed() { emit g_emu_thread->onVMStopped(); g_emu_thread->startBackgroundControllerPollTimer(); } void Host::OnVMPaused() { g_emu_thread->startBackgroundControllerPollTimer(); emit g_emu_thread->onVMPaused(); } void Host::OnVMResumed() { // exit the event loop when we eventually return g_emu_thread->getEventLoop()->quit(); g_emu_thread->stopBackgroundControllerPollTimer(); emit g_emu_thread->onVMResumed(); } void Host::OnGameChanged(const std::string& disc_path, const std::string& game_serial, const std::string& game_name, u32 game_crc) { emit g_emu_thread->onGameChanged(QString::fromStdString(disc_path), QString::fromStdString(game_serial), QString::fromStdString(game_name), game_crc); } void EmuThread::updatePerformanceMetrics(bool force) { QString fps_stat, gs_stat; bool changed = force; if (m_verbose_status && VMManager::HasValidVM()) { std::string gs_stat_str; GSgetTitleStats(gs_stat_str); changed = true; if (THREAD_VU1) { gs_stat = QStringLiteral("%1 | EE: %2% | VU: %3% | GS: %4%") .arg(gs_stat_str.c_str()) .arg(PerformanceMetrics::GetCPUThreadUsage(), 0, 'f', 0) .arg(PerformanceMetrics::GetVUThreadUsage(), 0, 'f', 0) .arg(PerformanceMetrics::GetGSThreadUsage(), 0, 'f', 0); } else { gs_stat = QStringLiteral("%1 | EE: %2% | GS: %3%") .arg(gs_stat_str.c_str()) .arg(PerformanceMetrics::GetCPUThreadUsage(), 0, 'f', 0) .arg(PerformanceMetrics::GetGSThreadUsage(), 0, 'f', 0); } } const float speed = std::round(PerformanceMetrics::GetSpeed()); const float gfps = std::round(PerformanceMetrics::GetInternalFPS()); const float vfps = std::round(PerformanceMetrics::GetFPS()); int iwidth, iheight; GSgetInternalResolution(&iwidth, &iheight); if (iwidth != m_last_internal_width || iheight != m_last_internal_height || speed != m_last_speed || gfps != m_last_game_fps || vfps != m_last_video_fps || changed) { m_last_internal_width = iwidth; m_last_internal_height = iheight; m_last_speed = speed; m_last_game_fps = gfps; m_last_video_fps = vfps; changed = true; if (iwidth == 0 && iheight == 0) { // if we don't have width/height yet, we're not going to have fps either. // and we'll probably be <100% due to compiling. so just leave it blank for now. } else if (PerformanceMetrics::IsInternalFPSValid()) { fps_stat = QStringLiteral("%1x%2 | G: %3 | V: %4 | %5%") .arg(iwidth) .arg(iheight) .arg(gfps, 0, 'f', 0) .arg(vfps, 0, 'f', 0) .arg(speed, 0, 'f', 0); } else { fps_stat = QStringLiteral("%1x%2 | V: %3 | %4%") .arg(iwidth) .arg(iheight) .arg(vfps, 0, 'f', 0) .arg(speed, 0, 'f', 0); } } if (changed) emit onPerformanceMetricsUpdated(fps_stat, gs_stat); } void Host::OnPerformanceMetricsUpdated() { g_emu_thread->updatePerformanceMetrics(false); } void Host::OnSaveStateLoading(const std::string_view& filename) { emit g_emu_thread->onSaveStateLoading(QtUtils::StringViewToQString(filename)); } void Host::OnSaveStateLoaded(const std::string_view& filename, bool was_successful) { emit g_emu_thread->onSaveStateLoaded(QtUtils::StringViewToQString(filename), was_successful); } void Host::OnSaveStateSaved(const std::string_view& filename) { emit g_emu_thread->onSaveStateSaved(QtUtils::StringViewToQString(filename)); } void Host::PumpMessagesOnCPUThread() { g_emu_thread->getEventLoop()->processEvents(QEventLoop::AllEvents); } void Host::RunOnCPUThread(std::function function, bool block /* = false */) { if (g_emu_thread->isOnEmuThread()) { // probably shouldn't ever happen, but just in case.. function(); return; } QMetaObject::invokeMethod(g_emu_thread, "runOnCPUThread", block ? Qt::BlockingQueuedConnection : Qt::QueuedConnection, Q_ARG(const std::function&, std::move(function))); } alignas(16) static SysMtgsThread s_mtgs_thread; SysMtgsThread& GetMTGS() { return s_mtgs_thread; } // ------------------------------------------------------------------------ // Hotkeys // ------------------------------------------------------------------------ BEGIN_HOTKEY_LIST(g_host_hotkeys) DEFINE_HOTKEY("Screenshot", "General", "Save Screenshot", [](bool pressed) { if (!pressed) { Host::AddOSDMessage("Saved Screenshot.", 10.0f); GSmakeSnapshot(EmuFolders::Snapshots.ToString().char_str()); } }) DEFINE_HOTKEY("ShutdownVM", "System", "Shut Down Virtual Machine", [](bool pressed) { if (!pressed) { // run it on the host thread, that way we get the confirm prompt (if enabled) QMetaObject::invokeMethod(g_main_window, "requestShutdown", Qt::QueuedConnection, Q_ARG(bool, true), Q_ARG(bool, true), Q_ARG(bool, true)); } }) DEFINE_HOTKEY("TogglePause", "System", "Toggle Pause", [](bool pressed) { if (!pressed) g_emu_thread->setVMPaused(VMManager::GetState() != VMState::Paused); }) DEFINE_HOTKEY("ToggleFullscreen", "General", "Toggle Fullscreen", [](bool pressed) { if (!pressed) g_emu_thread->toggleFullscreen(); }) END_HOTKEY_LIST()