You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
duckstation/src/core/fullscreenui.cpp

2544 lines
90 KiB
C++

// SPDX-FileCopyrightText: 2019-2025 Connor McLaughlin <stenzek@gmail.com>
// SPDX-License-Identifier: CC-BY-NC-ND-4.0
#include "fullscreenui.h"
#include "achievements_private.h"
#include "controller.h"
#include "core.h"
#include "fullscreenui_private.h"
#include "fullscreenui_widgets.h"
#include "game_list.h"
#include "host.h"
#include "sound_effect_manager.h"
#include "system.h"
#include "video_thread.h"
#include "scmversion/scmversion.h"
#include "util/cd_image.h"
#include "util/gpu_device.h"
#include "util/imgui_manager.h"
#include "util/shadergen.h"
#include "common/assert.h"
#include "common/error.h"
#include "common/file_system.h"
#include "common/log.h"
#include "common/path.h"
#include "common/timer.h"
#include "IconsEmoji.h"
#include "IconsPromptFont.h"
LOG_CHANNEL(FullscreenUI);
#ifndef __ANDROID__
namespace FullscreenUI {
enum class PauseSubMenu : u8
{
None,
Exit,
Achievements,
};
//////////////////////////////////////////////////////////////////////////
// Main
//////////////////////////////////////////////////////////////////////////
static void DrawLandingTemplate(ImVec2* menu_pos, ImVec2* menu_size);
static void DrawLandingWindow();
static void DrawStartGameWindow();
static void DrawExitWindow();
static void DrawPauseMenu();
static void DrawAboutWindow();
static void FixStateIfPaused();
static void UnpauseForMenuClose();
//////////////////////////////////////////////////////////////////////////
// Backgrounds
//////////////////////////////////////////////////////////////////////////
static bool HasBackground();
static bool LoadBackgroundShader(const std::string& path, Error* error);
static bool LoadBackgroundImage(const std::string& path, Error* error);
static void DrawBackground();
static void DrawShaderBackgroundCallback(const ImDrawList* parent_list, const ImDrawCmd* cmd, u32 base_vertex,
u32 base_index);
//////////////////////////////////////////////////////////////////////////
// Resources
//////////////////////////////////////////////////////////////////////////
static GPUTexture* GetUserThemeableTexture(
const std::string_view png_name, const std::string_view svg_name, bool* is_colorable = nullptr,
const ImVec2& svg_size = LayoutScale(LAYOUT_HORIZONTAL_MENU_ITEM_IMAGE_SIZE, LAYOUT_HORIZONTAL_MENU_ITEM_IMAGE_SIZE));
static bool UserThemeableHorizontalButton(const std::string_view png_name, const std::string_view svg_name,
std::string_view title, std::string_view description);
static void UpdateCurrentTimeString();
//////////////////////////////////////////////////////////////////////////
// Landing
//////////////////////////////////////////////////////////////////////////
static void DoStartFile();
static void DoStartBIOS();
static void DoStartDisc();
static void DoToggleFastForward();
static void ConfirmWithSafetyCheck(std::string action, bool check_achievements, std::function<void(bool)> callback);
static void RequestShutdown(bool save_state);
static void RequestRestart();
static void BeginChangeDiscOnCoreThread(bool return_to_game);
static void StartChangeDiscFromFile(bool return_to_game);
static void DoRequestExit();
static void DoDesktopMode();
static void DoToggleFullscreen();
static void DoToggleAnalogMode();
//////////////////////////////////////////////////////////////////////////
// Save State List
//////////////////////////////////////////////////////////////////////////
struct SaveStateListEntry
{
std::string title;
std::string summary;
std::string game_path;
std::string state_path;
std::unique_ptr<GPUTexture> preview_texture;
time_t timestamp;
s32 slot;
bool global;
};
static void InitializePlaceholderSaveStateListEntry(SaveStateListEntry* li, s32 slot, bool global);
static bool InitializeSaveStateListEntryFromSerial(SaveStateListEntry* li, const std::string& serial, s32 slot,
bool global);
static bool InitializeSaveStateListEntryFromPath(SaveStateListEntry* li, std::string path, s32 slot, bool global,
Error* error, bool* exists);
static void ClearSaveStateEntryList();
static u32 PopulateSaveStateListEntries(const std::string& serial, std::optional<ExtendedSaveStateInfo> undo_save_state,
bool is_loading);
static void DrawSaveStateSelector();
static void DrawResumeStateSelector();
//////////////////////////////////////////////////////////////////////////
// Constants
//////////////////////////////////////////////////////////////////////////
static constexpr std::string_view RESUME_STATE_SELECTOR_DIALOG_NAME = "##resume_state_selector";
static constexpr std::string_view ABOUT_DIALOG_NAME = "##about_duckstation";
const char* SFX_NAV_ACTIVATE = "sounds/nav_activate.wav";
const char* SFX_NAV_BACK = "sounds/nav_back.wav";
const char* SFX_NAV_MOVE = "sounds/nav_move.wav";
//////////////////////////////////////////////////////////////////////////
// State
//////////////////////////////////////////////////////////////////////////
namespace {
struct Locals
{
// Main
MainWindowType current_main_window = MainWindowType::None;
PauseSubMenu current_pause_submenu = PauseSubMenu::None;
MainWindowType previous_main_window = MainWindowType::None;
MainWindowType toggled_main_window = MainWindowType::None;
bool initialized = false;
bool background_loaded = false;
bool was_paused_on_quick_menu_open = false;
bool has_pending_window_switch = false;
// Background
std::unique_ptr<GPUTexture> app_background_texture;
std::unique_ptr<GPUPipeline> app_background_shader;
Timer::Value app_background_load_time = 0;
// Pause Menu
std::time_t current_time = 0;
std::string current_time_string;
// Save State List
std::vector<SaveStateListEntry> save_state_selector_slots;
bool save_state_selector_loading = true;
};
} // namespace
ALIGN_TO_CACHE_LINE static Locals s_locals;
} // namespace FullscreenUI
//////////////////////////////////////////////////////////////////////////
// Main
//////////////////////////////////////////////////////////////////////////
void FullscreenUI::Initialize()
{
// some achievement callbacks fire early while e.g. there is a load state popup blocking system init
if (s_locals.initialized || !ImGuiManager::IsInitialized())
return;
s_locals.initialized = true;
// in case we open the pause menu while the game is running
if (s_locals.current_main_window == MainWindowType::None && !VideoThread::HasGPUBackend() &&
!VideoThread::IsGPUBackendRequested())
{
ReturnToMainWindow();
ForceKeyNavEnabled();
}
else
{
UpdateRunIdleState();
}
SoundEffectManager::EnsureInitialized();
INFO_LOG("Fullscreen UI initialized.");
}
bool FullscreenUI::IsInitialized()
{
return s_locals.initialized;
}
bool FullscreenUI::HasActiveWindow()
{
return s_locals.initialized && (s_locals.current_main_window != MainWindowType::None || AreAnyDialogsOpen());
}
bool FullscreenUI::HasActiveOrPendingWindow()
{
return s_locals.initialized && (s_locals.current_main_window != MainWindowType::None ||
s_locals.has_pending_window_switch || AreAnyDialogsOpen());
}
bool FullscreenUI::AreAnyDialogsOpen()
{
return (IsInputBindingDialogOpen() || AreAnyWidgetsDialogOpen());
}
bool FullscreenUI::AreAnyDialogsInteractable()
{
return (IsInputBindingDialogInteractable() || AreAnyWidgetsDialogInteractable());
}
void FullscreenUI::CheckForConfigChanges(const GPUSettings& old_settings)
{
// NOTE: Called on GPU thread.
}
void FullscreenUI::UpdateRunIdleState()
{
VideoThread::SetRunIdleReason(VideoThread::RunIdleReason::FullscreenUIActive,
(HasActiveWindow() || GetTransitionState() != TransitionState::Inactive));
}
void FullscreenUI::OnSystemStarting()
{
// NOTE: Called on CPU thread.
if (!IsInitialized())
return;
VideoThread::RunOnThread([]() {
if (!IsInitialized())
return;
BeginTransition(LONG_TRANSITION_TIME, []() {
ClearSaveStateEntryList();
s_locals.current_main_window = MainWindowType::None;
s_locals.toggled_main_window = MainWindowType::None;
QueueResetFocus(FocusResetType::ViewChanged);
UpdateRunIdleState();
});
});
}
void FullscreenUI::OnSystemPaused()
{
// NOTE: Called on CPU thread.
if (!IsInitialized())
return;
VideoThread::RunOnThread([]() {
if (!IsInitialized())
return;
UpdateRunIdleState();
});
}
void FullscreenUI::OnSystemResumed()
{
// NOTE: Called on CPU thread.
if (!IsInitialized())
return;
VideoThread::RunOnThread([]() {
if (!IsInitialized())
return;
// get rid of pause menu if we unpaused another way
if (s_locals.current_main_window != MainWindowType::None)
ClosePauseMenuImmediately();
UpdateRunIdleState();
});
}
void FullscreenUI::OnSystemDestroyed()
{
// NOTE: Called on CPU thread.
if (!IsInitialized())
return;
VideoThread::RunOnThread([]() {
if (!IsInitialized())
return;
s_locals.was_paused_on_quick_menu_open = false;
s_locals.current_pause_submenu = PauseSubMenu::None;
s_locals.toggled_main_window = MainWindowType::None;
ReturnToMainWindow(TransitionEffect::Fade, LONG_TRANSITION_TIME);
});
}
void FullscreenUI::PauseAndOpenMenuFromCoreThread(void (*callback)())
{
DebugAssert(Host::IsOnCoreThread());
if (!System::IsValid())
return;
// Do the pause early, that way the core thread doesn't push extra frames while the transition is happening.
// But, we also don't want extra frames between the pause and the open, because otherwise we'll see the pause icon.
const bool was_paused = System::IsPaused();
VideoThread::RunOnThread([callback, was_paused]() {
if (s_locals.current_main_window != MainWindowType::None)
return;
Initialize();
if (!SetPendingMainWindowSwitch())
return;
// Explicit open actions supersede a menu which was hidden by PauseAndToggleMenuFromCoreThread().
if (s_locals.toggled_main_window != MainWindowType::None)
{
s_locals.toggled_main_window = MainWindowType::None;
s_locals.previous_main_window = MainWindowType::None;
s_locals.current_pause_submenu = PauseSubMenu::None;
}
s_locals.was_paused_on_quick_menu_open = was_paused;
callback();
});
// See comment above. We want the video thread to start transitioning before the pause goes through.
if (!was_paused)
System::PauseSystem(true);
}
void FullscreenUI::OpenPauseMenu()
{
PauseAndOpenMenuFromCoreThread([]() {
BeginTransition(TransitionEffect::ZoomIn, SHORT_TRANSITION_TIME, []() {
ForceKeyNavEnabled();
EnqueueSoundEffect(SFX_NAV_ACTIVATE);
UpdateAchievementsPauseScreenInfo();
s_locals.current_pause_submenu = PauseSubMenu::None;
SwitchToMainWindow(MainWindowType::PauseMenu);
});
});
}
void FullscreenUI::PauseAndToggleMenuFromCoreThread(void (*open_callback)(), void (*restored_callback)(),
float transition_time)
{
DebugAssert(Host::IsOnCoreThread());
if (!System::IsValid())
return;
const bool was_paused = System::IsPaused();
VideoThread::RunOnThread([open_callback, restored_callback, transition_time, was_paused]() {
Initialize();
// Dialogs cannot be hidden safely, since doing so would leave their callbacks pending while the game is running.
// A pending switch means another action already owns the current transition.
if (AreAnyDialogsOpen() || !SetPendingMainWindowSwitch())
return;
// Hide the current window without disturbing the state it owns.
if (s_locals.current_main_window != MainWindowType::None)
{
s_locals.toggled_main_window = s_locals.current_main_window;
BeginTransition(TransitionEffect::ZoomOut, SHORT_TRANSITION_TIME, []() {
s_locals.current_main_window = MainWindowType::None;
s_locals.has_pending_window_switch = false;
UpdateRunIdleState();
FixStateIfPaused();
UnpauseForMenuClose();
});
return;
}
// Restore the hidden window, or invoke the requested open action when there is no saved window. Start the
// transition before requesting the pause so an extra frame does not display the pause icon.
s_locals.was_paused_on_quick_menu_open = was_paused;
const bool restoring = (s_locals.toggled_main_window != MainWindowType::None);
BeginTransition(TransitionEffect::ZoomIn, transition_time, [open_callback, restored_callback, restoring]() {
ForceKeyNavEnabled();
EnqueueSoundEffect(SFX_NAV_ACTIVATE);
if (restoring)
{
s_locals.current_main_window = std::exchange(s_locals.toggled_main_window, MainWindowType::None);
s_locals.has_pending_window_switch = false;
UpdateRunIdleState();
FixStateIfPaused();
if (restored_callback)
restored_callback();
}
else
{
open_callback();
}
});
if (!was_paused)
Host::RunOnCoreThread([]() { System::PauseSystem(true); });
});
}
void FullscreenUI::TogglePauseMenu()
{
PauseAndToggleMenuFromCoreThread(
[]() {
UpdateAchievementsPauseScreenInfo();
s_locals.current_pause_submenu = PauseSubMenu::None;
SwitchToMainWindow(MainWindowType::PauseMenu);
},
&UpdateAchievementsPauseScreenInfo);
}
void FullscreenUI::OpenCheatsMenu()
{
PauseAndOpenMenuFromCoreThread([]() {
BeginTransition(TransitionEffect::ZoomIn, SHORT_TRANSITION_TIME, []() {
if (!SwitchToGameSettings(SettingsPage::Cheats))
{
// We'll end up here when we're in batch mode and using a runtime-populated game list entry. There _might_ be
// one frame when we display the main settings instead of the game settings page, but it'll be part of the
// transition and not noticeable. That's what you get for not using the game list.
SwitchToMainWindow(MainWindowType::Settings);
}
ForceKeyNavEnabled();
EnqueueSoundEffect(SFX_NAV_ACTIVATE);
});
});
}
void FullscreenUI::OpenDiscChangeMenu()
{
if (!System::IsValid())
return;
// bit of back and forth here, but needed because we're not coming from the pause menu
PauseAndOpenMenuFromCoreThread([]() { Host::RunOnCoreThread([]() { BeginChangeDiscOnCoreThread(true); }); });
}
void FullscreenUI::FixStateIfPaused()
{
if (!VideoThread::HasGPUBackend() || System::IsRunning())
return;
// When we're paused, we won't have trickled the key up event for escape yet. Do it now.
ImGui::UpdateInputEvents(false);
}
void FullscreenUI::ClosePauseMenu(TransitionEffect effect /*= TransitionEffect::Fade*/,
float transition_time /*= SHORT_TRANSITION_TIME*/)
{
if (!VideoThread::HasGPUBackend())
return;
s_locals.toggled_main_window = MainWindowType::None;
BeginTransition(effect, transition_time, []() {
s_locals.current_pause_submenu = PauseSubMenu::None;
SwitchToMainWindow(MainWindowType::None);
});
UnpauseForMenuClose();
}
void FullscreenUI::ClosePauseMenuImmediately()
{
if (!VideoThread::HasGPUBackend())
return;
CancelTransition();
UnpauseForMenuClose();
s_locals.toggled_main_window = MainWindowType::None;
s_locals.current_pause_submenu = PauseSubMenu::None;
s_locals.has_pending_window_switch = false;
SwitchToMainWindow(MainWindowType::None);
// Present frame with menu closed. We have to defer this for a frame so imgui loses keyboard focus.
if (VideoThread::IsSystemPaused())
Host::RunOnCoreThread(&VideoThread::PresentCurrentFrame);
}
FullscreenUI::MainWindowType FullscreenUI::GetCurrentMainWindow()
{
return s_locals.current_main_window;
}
bool FullscreenUI::CanCurrentMainWindowStack()
{
// windows that are actually stackable
return (s_locals.current_main_window < MainWindowType::SaveStateSelector);
}
bool FullscreenUI::SetPendingMainWindowSwitch()
{
// Needed because otherwise we could switch two windows within the same frame.
if (s_locals.has_pending_window_switch)
return false;
s_locals.has_pending_window_switch = true;
return true;
}
void FullscreenUI::SwitchToMainWindow(MainWindowType type)
{
if (!AreAnyDialogsInteractable())
{
ImGui::SetWindowFocus(nullptr);
QueueResetFocus(FocusResetType::ViewChanged);
}
if (s_locals.current_main_window == type)
return;
// avoid a race where if we shutdown with achievements up, we end up back at the pause menu
if (!VideoThread::HasGPUBackend() && (type == MainWindowType::PauseMenu || type >= MainWindowType::Achievements))
{
WARNING_LOG("Trying to switch to main window requiring system without one");
s_locals.current_main_window = MainWindowType::None;
type = MainWindowType::Landing;
}
// windows that are actually stacked
else if (type != MainWindowType::None && !CanCurrentMainWindowStack())
{
WARNING_LOG("Trying to stack incompatible window type {} on {}, ignoring", static_cast<u32>(type),
static_cast<u32>(s_locals.current_main_window));
s_locals.previous_main_window = MainWindowType::None;
}
else
{
s_locals.previous_main_window =
(type == MainWindowType::None) ? MainWindowType::None : s_locals.current_main_window;
}
s_locals.current_main_window = type;
s_locals.has_pending_window_switch = false;
UpdateRunIdleState();
FixStateIfPaused();
}
void FullscreenUI::ReturnToPreviousWindow(TransitionEffect effect)
{
if (s_locals.previous_main_window == MainWindowType::None)
{
// if we're coming from a pause menu, we need to release the pause
if (s_locals.current_main_window >= MainWindowType::Settings &&
s_locals.current_main_window <= MainWindowType::Leaderboards)
{
ClosePauseMenu(effect);
}
else
{
ReturnToMainWindow(effect);
}
}
else
{
BeginTransition(effect, DEFAULT_TRANSITION_TIME, [window = s_locals.previous_main_window]() {
SwitchToMainWindow(window);
// return stack is only one deep
s_locals.previous_main_window = window;
});
}
}
void FullscreenUI::ReturnToMainWindow(TransitionEffect effect)
{
const float transition_time = (effect == TransitionEffect::Fade) ?
(VideoThread::HasGPUBackend() ? SHORT_TRANSITION_TIME : DEFAULT_TRANSITION_TIME) :
DEFAULT_TRANSITION_TIME;
ReturnToMainWindow(effect, transition_time);
}
void FullscreenUI::ReturnToMainWindow(TransitionEffect effect, float transition_time)
{
BeginTransition(effect, transition_time, []() {
s_locals.previous_main_window = MainWindowType::None;
s_locals.toggled_main_window = MainWindowType::None;
s_locals.current_pause_submenu = PauseSubMenu::None;
s_locals.has_pending_window_switch = false;
if (VideoThread::HasGPUBackend())
{
SwitchToMainWindow(MainWindowType::None);
}
else
{
if (ShouldOpenToGameList())
SwitchToGameList();
else
SwitchToMainWindow(MainWindowType::Landing);
}
});
}
void FullscreenUI::UnpauseForMenuClose()
{
if (!VideoThread::IsSystemPaused())
return;
s_locals.has_pending_window_switch = false;
// Represent the frame if we're not running idle, so that the pause indicator is displayed.
if (!s_locals.was_paused_on_quick_menu_open)
Host::RunOnCoreThread([]() { System::PauseSystem(false); });
else
Host::RunOnCoreThread([]() { VideoThread::PresentCurrentFrame(); });
}
void FullscreenUI::Shutdown()
{
DestroyGPUResources();
SoundEffectManager::Shutdown();
s_locals.current_main_window = MainWindowType::None;
s_locals.current_pause_submenu = PauseSubMenu::None;
s_locals.previous_main_window = MainWindowType::None;
s_locals.toggled_main_window = MainWindowType::None;
s_locals.was_paused_on_quick_menu_open = false;
s_locals.has_pending_window_switch = false;
ClearAchievementsState();
ClearSettingsState();
ClearGameListState();
s_locals.current_time_string = {};
s_locals.current_time = 0;
s_locals.initialized = false;
UpdateRunIdleState();
}
void FullscreenUI::DestroyGPUResources()
{
// has textures...
ClearSaveStateEntryList();
s_locals.app_background_texture.reset();
s_locals.app_background_shader.reset();
s_locals.background_loaded = false;
}
void FullscreenUI::Render()
{
if (!s_locals.initialized)
return;
// draw background before any overlays
if (!VideoThread::HasGPUBackend() && s_locals.current_main_window != MainWindowType::None)
DrawBackground();
BeginLayout();
switch (s_locals.current_main_window)
{
case MainWindowType::Landing:
DrawLandingWindow();
break;
case MainWindowType::StartGame:
DrawStartGameWindow();
break;
case MainWindowType::Exit:
DrawExitWindow();
break;
case MainWindowType::GameList:
DrawGameListWindow();
break;
case MainWindowType::Settings:
DrawSettingsWindow();
break;
case MainWindowType::PauseMenu:
DrawPauseMenu();
break;
case MainWindowType::SaveStateSelector:
DrawSaveStateSelector();
break;
case MainWindowType::Achievements:
DrawAchievementsWindow();
break;
case MainWindowType::Leaderboards:
DrawLeaderboardsWindow();
break;
default:
break;
}
if (IsFixedPopupDialogOpen(ABOUT_DIALOG_NAME))
DrawAboutWindow();
else if (IsFixedPopupDialogOpen(RESUME_STATE_SELECTOR_DIALOG_NAME))
DrawResumeStateSelector();
EndLayout();
ResetCloseMenuIfNeeded();
UpdateTransitionState();
}
GPUTexture* FullscreenUI::GetUserThemeableTexture(const std::string_view png_name, const std::string_view svg_name,
bool* is_colorable, const ImVec2& svg_size)
{
GPUTexture* tex = FindCachedTexture(png_name);
if (tex)
{
if (is_colorable)
*is_colorable = false;
return tex;
}
const u32 svg_width = static_cast<u32>(svg_size.x);
const u32 svg_height = static_cast<u32>(svg_size.y);
tex = FindCachedTexture(svg_name, svg_width, svg_height);
if (tex)
{
if (is_colorable)
*is_colorable = true;
return tex;
}
// slow path, check filesystem for override
if (EmuFolders::Resources != EmuFolders::UserResources &&
FileSystem::FileExists(Path::Combine(EmuFolders::UserResources, png_name).c_str()))
{
// use the user's png
if (is_colorable)
*is_colorable = false;
return GetCachedTexture(png_name);
}
// otherwise use the system/user svg
if (is_colorable)
*is_colorable = true;
if (!svg_name.empty())
return GetCachedTexture(svg_name, svg_width, svg_height);
else
return GetCachedTexture(png_name);
}
bool FullscreenUI::UserThemeableHorizontalButton(const std::string_view png_name, const std::string_view svg_name,
std::string_view title, std::string_view description)
{
bool is_colorable;
GPUTexture* icon = GetUserThemeableTexture(
png_name, svg_name, &is_colorable,
LayoutScale(LAYOUT_HORIZONTAL_MENU_ITEM_IMAGE_SIZE, LAYOUT_HORIZONTAL_MENU_ITEM_IMAGE_SIZE));
return HorizontalMenuItem(icon, title, description,
is_colorable ? ImGui::GetColorU32(ImGuiCol_Text) : IM_COL32(255, 255, 255, 255));
}
void FullscreenUI::UpdateCurrentTimeString()
{
const std::time_t current_time = std::time(nullptr);
if (s_locals.current_time == current_time)
return;
s_locals.current_time = current_time;
s_locals.current_time_string = {};
s_locals.current_time_string = Host::FormatTime(current_time, false);
}
//////////////////////////////////////////////////////////////////////////
// Utility
//////////////////////////////////////////////////////////////////////////
void FullscreenUI::DoStartPath(std::string path, std::string state, std::optional<bool> fast_boot)
{
if (VideoThread::HasGPUBackend())
return;
// Stop running idle to prevent game list from being redrawn until we know if startup succeeded.
VideoThread::SetRunIdleReason(VideoThread::RunIdleReason::FullscreenUIActive, false);
SystemBootParameters params;
params.path = std::move(path);
params.save_state = std::move(state);
params.override_fast_boot = std::move(fast_boot);
Host::RunOnCoreThread([params = std::move(params)]() mutable {
if (System::IsValid())
return;
// This can "fail" if HC mode is enabled and the user cancels, or other startup cancel paths.
Error error;
if (const System::BootResult result = System::BootSystem(std::move(params), &error);
result != System::BootResult::Success)
{
VideoThread::RunOnThread([error_desc = error.TakeDescription(), result]() {
if (!IsInitialized())
return;
std::string title = TRANSLATE_STR("System", "Error");
std::string message = fmt::format("{}\n\n{}", TRANSLATE_SV("System", "Failed to boot system:"), error_desc);
if (result == System::BootResult::MissingBIOS)
{
fmt::format_to(std::back_inserter(message), "\n\n{}",
TRANSLATE_SV("System", "Do you want to install a BIOS file now?"));
OpenConfirmMessageDialog(ICON_EMOJI_NO_ENTRY_SIGN, std::move(title), std::move(message), [](bool result) {
if (result)
StartInstallBIOS();
});
}
else
{
OpenInfoMessageDialog(ICON_EMOJI_NO_ENTRY_SIGN, std::move(title), std::move(message));
}
ClearSaveStateEntryList();
UpdateRunIdleState();
});
}
});
}
void FullscreenUI::DoResume()
{
std::string path = System::GetMostRecentResumeSaveStatePath();
if (path.empty())
{
ShowToast(OSDMessageType::Info, {}, FSUI_STR("No resume save state found."));
return;
}
Error error;
bool exists;
SaveStateListEntry slentry;
if (!InitializeSaveStateListEntryFromPath(&slentry, std::move(path), -1, false, &error, &exists))
{
ShowToast(OSDMessageType::Error, FSUI_STR("Failed to load resume save state info."), error.TakeDescription());
return;
}
ClearSaveStateEntryList();
s_locals.save_state_selector_slots.push_back(std::move(slentry));
OpenFixedPopupDialog(RESUME_STATE_SELECTOR_DIALOG_NAME);
}
void FullscreenUI::DoStartFile()
{
OpenFileSelector(FSUI_ICONVSTR(ICON_EMOJI_OPTICAL_DISK, "Select Disc Image"), GetDiscImageFilters(), {},
[](const std::string& path) {
if (!path.empty())
DoStartPath(path);
});
}
void FullscreenUI::DoStartBIOS()
{
DoStartPath(std::string(), std::string(), std::nullopt);
}
void FullscreenUI::DoStartDisc()
{
std::vector<std::pair<std::string, std::string>> devices = CDImage::GetDeviceList();
if (devices.empty())
{
ShowToast(OSDMessageType::Info, {},
FSUI_STR("Could not find any CD/DVD-ROM devices. Please ensure you have a drive connected and sufficient "
"permissions to access it."));
return;
}
// if there's only one, select it automatically
if (devices.size() == 1)
{
DoStartPath(std::move(devices.front().first), std::string(), std::nullopt);
return;
}
ChoiceDialogOptions options;
std::vector<std::string> paths;
options.reserve(devices.size());
paths.reserve(paths.size());
for (auto& [path, name] : devices)
{
options.emplace_back(std::move(name), false);
paths.push_back(std::move(path));
}
OpenChoiceDialog(FSUI_ICONVSTR(ICON_FA_COMPACT_DISC, "Select Disc Drive"), false, std::move(options),
[paths = std::move(paths)](s32 index, const std::string&, bool) mutable {
if (index < 0)
return;
DoStartPath(std::move(paths[index]), std::string(), std::nullopt);
});
}
void FullscreenUI::ConfirmWithSafetyCheck(std::string action, bool check_achievements,
std::function<void(bool)> callback)
{
Host::RunOnCoreThread([action = std::move(action), callback = std::move(callback), check_achievements]() mutable {
const u32 pending_unlock_count = check_achievements ? Achievements::GetPendingUnlockCount() : 0;
const bool was_saving = System::IsSavingMemoryCards();
VideoThread::RunOnThread([action = std::move(action), callback = std::move(callback), pending_unlock_count,
was_saving]() mutable {
if (!was_saving && pending_unlock_count == 0)
{
callback(true);
return;
}
if (was_saving)
{
OpenConfirmMessageDialog(
ICON_EMOJI_WARNING, FSUI_ICONVSTR(ICON_PF_MEMORY_CARD, "Memory Card Busy"),
fmt::format(
FSUI_FSTR("WARNING: Your game is still saving to the memory card. Continuing to {0} may IRREVERSIBLY "
"DESTROY YOUR MEMORY CARD. We recommend resuming your game and waiting 5 seconds for it to "
"finish saving.\n\nDo you want to {0} anyway?"),
action),
std::move(callback),
fmt::format(
fmt::runtime(FSUI_ICONVSTR(ICON_FA_TRIANGLE_EXCLAMATION, "Yes, {} now and risk memory card corruption.")),
action),
FSUI_ICONSTR(ICON_FA_PLAY, "No, resume the game."));
}
else
{
OpenConfirmMessageDialog(
ICON_EMOJI_WARNING, FSUI_ICONVSTR(ICON_FA_TROPHY, "Achievement Unlocks Unconfirmed"),
fmt::format(FSUI_FSTR("{0} achievement unlocks have not been confirmed by the server. Continuing to {1} will "
"result in loss of these unlocks. Once network connectivity has been re-established, "
"these unlocks will be confirmed automatically.\n\nDo you want to {1} anyway?"),
pending_unlock_count, action),
std::move(callback));
}
});
});
}
void FullscreenUI::RequestShutdown(bool save_state)
{
ConfirmWithSafetyCheck(FSUI_STR("shut down"), true, [save_state](bool result) {
if (result)
{
BeginTransition(TransitionEffect::Fade, LONG_TRANSITION_TIME, [save_state]() {
Host::RunOnCoreThread([save_state]() { Host::RequestSystemShutdown(false, save_state, false); });
});
}
else
{
ClosePauseMenu();
}
});
}
void FullscreenUI::RequestRestart()
{
ConfirmWithSafetyCheck(FSUI_STR("restart"), false, [](bool result) {
if (result)
{
Host::RunOnCoreThread(System::ResetSystem);
ClosePauseMenu(TransitionEffect::Fade, LONG_TRANSITION_TIME);
}
else
{
ClosePauseMenu();
}
});
}
void FullscreenUI::DoToggleFastForward()
{
Host::RunOnCoreThread([]() {
if (!System::IsValid())
return;
System::SetFastForwardEnabled(!System::IsFastForwardEnabled());
});
}
void FullscreenUI::StartChangeDiscFromFile(bool return_to_game)
{
OpenFileSelector(
FSUI_ICONVSTR(ICON_FA_COMPACT_DISC, "Select Disc Image"), GetDiscImageFilters(),
std::string(Path::GetDirectory(VideoThread::GetGamePath())), [return_to_game](const std::string& path) {
if (path.empty())
{
if (return_to_game)
UnpauseForMenuClose();
return;
}
ConfirmWithSafetyCheck(FSUI_STR("change disc"), false, [path, return_to_game](bool result) {
if (result)
{
if (!GameList::IsScannableFilename(path))
{
OpenInfoMessageDialog(ICON_EMOJI_NO_ENTRY_SIGN, FSUI_STR("Error"),
fmt::format(FSUI_FSTR("{} is not a valid disc image."), Path::GetFileName(path)));
if (return_to_game)
UnpauseForMenuClose();
}
else
{
Host::RunOnCoreThread([path]() { System::InsertMedia(path.c_str()); });
ClosePauseMenuImmediately();
}
}
});
});
// This can come from the core thread without the menu, so need to to trigger run idle.
UpdateRunIdleState();
}
void FullscreenUI::BeginChangeDiscOnCoreThread(bool return_to_game)
{
ChoiceDialogOptions options;
if (System::HasMediaSubImages())
{
const u32 current_index = System::GetMediaSubImageIndex();
const u32 count = System::GetMediaSubImageCount();
options.reserve(count + 1);
options.emplace_back(FSUI_STR("From File..."), false);
for (u32 i = 0; i < count; i++)
options.emplace_back(System::GetMediaSubImageTitle(i), i == current_index);
VideoThread::RunOnThread([options = std::move(options), return_to_game]() mutable {
auto callback = [return_to_game](s32 index, const std::string& title, bool checked) {
if (index == 0)
{
StartChangeDiscFromFile(return_to_game);
}
else if (index > 0)
{
ConfirmWithSafetyCheck(FSUI_STR("change disc"), false, [index](bool result) {
if (result)
{
System::SwitchMediaSubImage(static_cast<u32>(index - 1));
ClosePauseMenuImmediately();
}
});
}
else
{
if (return_to_game)
UnpauseForMenuClose();
}
};
OpenChoiceDialog(FSUI_ICONVSTR(ICON_FA_COMPACT_DISC, "Select Disc Image"), false, std::move(options),
std::move(callback));
});
return;
}
if (const GameDatabase::Entry* entry = System::GetGameDatabaseEntry(); entry && entry->disc_set)
{
auto lock = GameList::GetLock();
GameList::EnsureLoaded(lock);
auto matches = GameList::GetEntriesInDiscSet(entry->disc_set, GameList::ShouldShowLocalizedTitles());
if (matches.size() > 1)
{
options.reserve(matches.size() + 1);
options.emplace_back(FSUI_STR("From File..."), false);
std::vector<std::string> paths;
paths.reserve(matches.size());
const std::string& current_path = System::GetGamePath();
for (auto& [title, glentry] : matches)
{
options.emplace_back(std::move(title), current_path == glentry->path);
paths.push_back(glentry->path);
}
VideoThread::RunOnThread([options = std::move(options), paths = std::move(paths), return_to_game]() mutable {
Initialize();
auto callback = [paths = std::move(paths), return_to_game](s32 index, const std::string& title,
bool checked) mutable {
if (index == 0)
{
StartChangeDiscFromFile(return_to_game);
}
else if (index > 0)
{
ConfirmWithSafetyCheck(FSUI_STR("change disc"), false, [paths = std::move(paths), index](bool result) {
if (result)
{
Host::RunOnCoreThread([path = std::move(paths[index - 1])]() { System::InsertMedia(path.c_str()); });
ClosePauseMenu();
}
});
}
else
{
if (return_to_game)
UnpauseForMenuClose();
}
};
OpenChoiceDialog(FSUI_ICONVSTR(ICON_FA_COMPACT_DISC, "Select Disc Image"), false, std::move(options),
std::move(callback));
});
return;
}
}
VideoThread::RunOnThread([return_to_game]() { StartChangeDiscFromFile(return_to_game); });
}
void FullscreenUI::DoToggleAnalogMode()
{
// hacky way to toggle analog mode
Host::RunOnCoreThread([]() {
for (u32 i = 0; i < NUM_CONTROLLER_AND_CARD_PORTS; i++)
{
Controller* const ctrl = System::GetController(i);
if (!ctrl)
continue;
for (const Controller::ControllerBindingInfo& bi : Controller::GetControllerInfo(ctrl->GetType()).bindings)
{
if (std::strcmp(bi.name, "Analog") == 0)
{
ctrl->SetBindState(bi.bind_index, 1.0f);
ctrl->SetBindState(bi.bind_index, 0.0f);
break;
}
}
}
});
}
void FullscreenUI::DoRequestExit()
{
Host::RunOnCoreThread([]() { Host::RequestExitApplication(true); });
}
void FullscreenUI::DoDesktopMode()
{
Host::RunOnCoreThread([]() { Host::RequestExitBigPicture(); });
}
void FullscreenUI::DoToggleFullscreen()
{
Host::RunOnCoreThread([]() { VideoThread::SetFullscreen(!VideoThread::IsFullscreen()); });
}
//////////////////////////////////////////////////////////////////////////
// Landing Window
//////////////////////////////////////////////////////////////////////////
bool FullscreenUI::HasBackground()
{
return static_cast<bool>(s_locals.app_background_texture || s_locals.app_background_shader);
}
void FullscreenUI::UpdateBackground()
{
if (!IsInitialized())
return;
g_gpu_device->RecycleTexture(std::move(s_locals.app_background_texture));
s_locals.app_background_shader.reset();
s_locals.background_loaded = true;
const TinyString background_name =
Core::GetBaseTinyStringSettingValue("Main", "FullscreenUIBackground", DEFAULT_BACKGROUND_NAME);
if (background_name.empty() || background_name == "None")
return;
static constexpr std::pair<const char*, bool (*)(const std::string&, Error*)> loaders[] = {
{"glsl", &FullscreenUI::LoadBackgroundShader},
{"jpg", &FullscreenUI::LoadBackgroundImage},
{"png", &FullscreenUI::LoadBackgroundImage},
{"webp", &FullscreenUI::LoadBackgroundImage},
};
for (const auto& [extension, loader] : loaders)
{
static constexpr auto get_path = [](const std::string& dir, const TinyString& name, const char* extension) {
return fmt::format("{}" FS_OSPATH_SEPARATOR_STR "fullscreenui" FS_OSPATH_SEPARATOR_STR
"backgrounds" FS_OSPATH_SEPARATOR_STR "{}.{}",
dir, name, extension);
};
// try user directory first
std::string path = get_path(EmuFolders::UserResources, background_name, extension);
if (!FileSystem::FileExists(path.c_str()))
{
path = get_path(EmuFolders::Resources, background_name, extension);
if (!FileSystem::FileExists(path.c_str()))
continue;
}
Error error;
if (!loader(path, &error))
{
ERROR_LOG("Failed to load background '{}' with {} loader: {}", background_name, extension,
error.GetDescription());
return;
}
INFO_LOG("Loaded background '{}' with {} loader", background_name, extension);
return;
}
ERROR_LOG("No loader or file found for background '{}'", background_name);
}
FullscreenUI::ChoiceDialogOptions FullscreenUI::GetBackgroundOptions(const TinyString& current_value)
{
static constexpr const char* dir = FS_OSPATH_SEPARATOR_STR "fullscreenui" FS_OSPATH_SEPARATOR_STR "backgrounds";
ChoiceDialogOptions options;
options.emplace_back(FSUI_STR("None"), (current_value == NONE_BACKGROUND_NAME));
FileSystem::FindResultsArray results;
FileSystem::FindFiles(Path::Combine(EmuFolders::UserResources, dir).c_str(), "*",
FILESYSTEM_FIND_FILES | FILESYSTEM_FIND_RELATIVE_PATHS, &results);
FileSystem::FindFiles(Path::Combine(EmuFolders::Resources, dir).c_str(), "*",
FILESYSTEM_FIND_FILES | FILESYSTEM_FIND_RELATIVE_PATHS | FILESYSTEM_FIND_KEEP_ARRAY, &results);
for (const auto& it : results)
{
const std::string_view name = Path::GetFileTitle(it.FileName);
if (std::any_of(options.begin(), options.end(), [&name](const auto& it) { return it.first == name; }))
continue;
options.emplace_back(name, current_value == name);
}
return options;
}
bool FullscreenUI::LoadBackgroundShader(const std::string& path, Error* error)
{
std::optional<std::string> shader_body = FileSystem::ReadFileToString(path.c_str(), error);
if (!shader_body.has_value())
return false;
const std::string::size_type main_pos = shader_body->find("void main()");
if (main_pos == std::string::npos)
{
Error::SetStringView(error, "main() definition not found in shader.");
return false;
}
const ShaderGen shadergen(g_gpu_device->GetRenderAPI(), GPUShaderLanguage::GLSLVK, false, false);
std::stringstream shader;
shadergen.WriteHeader(shader);
shadergen.DeclareVertexEntryPoint(shader, {}, 0, 1, {}, true);
shader << R"( {
v_tex0 = vec2(float((v_id << 1) & 2u), float(v_id & 2u));
v_pos = vec4(v_tex0 * vec2(2.0f, -2.0f) + vec2(-1.0f, 1.0f), 0.0f, 1.0f);
#if API_VULKAN
v_pos.y = -v_pos.y;
#endif
})";
std::unique_ptr<GPUShader> vs =
g_gpu_device->CreateShader(GPUShaderStage::Vertex, GPUShaderLanguage::GLSLVK, shader.str(), error);
if (!vs)
return false;
shader.str(std::string());
shadergen.WriteHeader(shader, false, false, false);
shadergen.DeclareUniformBuffer(shader, {"vec2 u_display_size", "vec2 u_rcp_display_size", "float u_time"}, true);
if (main_pos > 0)
shader << std::string_view(shader_body.value()).substr(0, main_pos);
shadergen.DeclareFragmentEntryPoint(shader, 0, 1);
shader << std::string_view(shader_body.value()).substr(main_pos + 11);
std::unique_ptr<GPUShader> fs =
g_gpu_device->CreateShader(GPUShaderStage::Fragment, GPUShaderLanguage::GLSLVK, shader.str(), error);
if (!fs)
return false;
GPUPipeline::GraphicsConfig plconfig = {};
plconfig.primitive = GPUPipeline::Primitive::Triangles;
plconfig.rasterization = GPUPipeline::RasterizationState::GetNoCullState();
plconfig.depth = GPUPipeline::DepthState::GetNoTestsState();
plconfig.blend = GPUPipeline::BlendState::GetNoBlendingState();
plconfig.geometry_shader = nullptr;
plconfig.depth_format = GPUTextureFormat::Unknown;
plconfig.render_pass_flags = GPUPipeline::NoRenderPassFlags;
plconfig.layout = GPUPipeline::Layout::SingleTextureAndPushConstants;
plconfig.SetTargetFormats(g_gpu_device->HasMainSwapChain() ? g_gpu_device->GetMainSwapChain()->GetFormat() :
GPUTextureFormat::RGBA8);
plconfig.vertex_shader = vs.get();
plconfig.fragment_shader = fs.get();
s_locals.app_background_shader = g_gpu_device->CreatePipeline(plconfig, error);
if (!s_locals.app_background_shader)
return false;
return true;
}
void FullscreenUI::DrawShaderBackgroundCallback(const ImDrawList* parent_list, const ImDrawCmd* cmd, u32 base_vertex,
u32 base_index)
{
if (!g_gpu_device->HasMainSwapChain())
return;
struct alignas(16) Uniforms
{
float u_display_size[2];
float u_rcp_display_size[2];
float u_time;
};
const GSVector2 display_size = GSVector2(g_gpu_device->GetMainSwapChain()->GetSizeVec());
Uniforms uniforms;
GSVector2::store<true>(uniforms.u_display_size, display_size);
GSVector2::store<true>(uniforms.u_rcp_display_size, GSVector2::cxpr(1.0f) / display_size);
uniforms.u_time =
static_cast<float>(Timer::ConvertValueToSeconds(Timer::GetCurrentValue() - s_locals.app_background_load_time));
g_gpu_device->SetPipeline(s_locals.app_background_shader.get());
g_gpu_device->SetTextureSampler(0, nullptr, nullptr);
g_gpu_device->DrawWithPushConstants(3, 0, &uniforms, sizeof(uniforms));
}
bool FullscreenUI::LoadBackgroundImage(const std::string& path, Error* error)
{
Image image;
if (!image.LoadFromFile(path.c_str(), error))
return false;
s_locals.app_background_texture = g_gpu_device->FetchAndUploadTextureImage(image, GPUTexture::Flags::None, error);
if (!s_locals.app_background_texture)
return false;
return true;
}
void FullscreenUI::DrawBackground()
{
if (!s_locals.background_loaded)
UpdateBackground();
if (s_locals.app_background_shader)
{
ImDrawList* dl = ImGui::GetBackgroundDrawList();
dl->AddCallback(&FullscreenUI::DrawShaderBackgroundCallback, nullptr);
}
else if (s_locals.app_background_texture)
{
ImDrawList* dl = ImGui::GetBackgroundDrawList();
const ImVec2 size = ImGui::GetIO().DisplaySize;
const ImRect uv_rect = FitImage(size, ImVec2(static_cast<float>(s_locals.app_background_texture->GetWidth()),
static_cast<float>(s_locals.app_background_texture->GetHeight())));
dl->AddImage(s_locals.app_background_texture.get(), ImVec2(0.0f, 0.0f), size, uv_rect.Min, uv_rect.Max);
}
}
ImVec4 FullscreenUI::GetTransparentBackgroundColor(const ImVec4& no_background_color /* = UIStyle.BackgroundColor */)
{
// use transparent colour if background is visible for things like game list
if (!HasBackground())
return ModAlpha(no_background_color, GetBackgroundAlpha());
else
return ImVec4{};
}
bool FullscreenUI::ShouldOpenToGameList()
{
return Core::GetBaseBoolSettingValue("Main", "FullscreenUIOpenToGameList", false);
}
void FullscreenUI::DrawLandingTemplate(ImVec2* menu_pos, ImVec2* menu_size)
{
const ImGuiIO& io = ImGui::GetIO();
const ImVec2 heading_size = ImVec2(
io.DisplaySize.x, UIStyle.LargeFontSize + (LayoutScale(LAYOUT_MENU_BUTTON_Y_PADDING) * 2.0f) + LayoutScale(2.0f));
*menu_pos = ImVec2(0.0f, heading_size.y);
*menu_size = ImVec2(io.DisplaySize.x, io.DisplaySize.y - heading_size.y - LayoutScale(LAYOUT_FOOTER_HEIGHT));
if (BeginFullscreenWindow(ImVec2(0.0f, 0.0f), heading_size, "landing_heading",
ModAlpha(UIStyle.PrimaryColor, GetBackgroundAlpha())))
{
ImFont* const heading_font = UIStyle.Font;
const float heading_font_size = UIStyle.LargeFontSize;
const float heading_font_weight = UIStyle.BoldFontWeight;
ImDrawList* const dl = ImGui::GetWindowDrawList();
SmallString heading_str;
const u32 text_color = ImGui::GetColorU32(UIStyle.PrimaryTextColor);
// draw branding
{
const ImVec2 logo_pos = LayoutScale(LAYOUT_MENU_BUTTON_X_PADDING, LAYOUT_MENU_BUTTON_Y_PADDING);
const ImVec2 logo_size = ImVec2(UIStyle.LargeFontSize, UIStyle.LargeFontSize);
GPUTexture* const logo_texture = GetUserThemeableTexture("images/duck.png", {}, nullptr, logo_size);
dl->AddImage(logo_texture, logo_pos, logo_pos + logo_size);
const std::string_view heading_text = "DuckStation";
const ImVec2 text_size = heading_font->CalcTextSizeA(heading_font_size, heading_font_weight, FLT_MAX, 0.0f,
IMSTR_START_END(heading_text));
const ImVec2 text_pos = ImVec2(logo_pos.x + logo_size.x + LayoutScale(LAYOUT_MENU_BUTTON_X_PADDING), logo_pos.y);
RenderShadowedTextClipped(heading_font, heading_font_size, heading_font_weight, text_pos, text_pos + text_size,
text_color, heading_text, &text_size);
}
// draw time
ImVec2 time_pos;
{
UpdateCurrentTimeString();
const ImVec2 time_size = heading_font->CalcTextSizeA(heading_font_size, heading_font_weight, FLT_MAX, 0.0f,
IMSTR_START_END(s_locals.current_time_string));
time_pos = ImVec2(heading_size.x - LayoutScale(LAYOUT_MENU_BUTTON_X_PADDING) - time_size.x,
LayoutScale(LAYOUT_MENU_BUTTON_Y_PADDING));
RenderShadowedTextClipped(heading_font, heading_font_size, heading_font_weight, time_pos, time_pos + time_size,
text_color, s_locals.current_time_string, &time_size);
}
// draw achievements info
if (Achievements::IsActive())
{
const auto lock = Achievements::GetLock();
if (const std::string& username = Achievements::GetLoggedInUserName(); !username.empty())
{
const ImVec2 name_size =
heading_font->CalcTextSizeA(heading_font_size, heading_font_weight, FLT_MAX, 0.0f, IMSTR_START_END(username));
const ImVec2 name_pos =
ImVec2(time_pos.x - name_size.x - LayoutScale(LAYOUT_MENU_BUTTON_X_PADDING), time_pos.y);
RenderShadowedTextClipped(heading_font, heading_font_size, heading_font_weight, name_pos, name_pos + name_size,
text_color, username, &name_size);
if (const std::string& badge_path = Achievements::GetLoggedInUserIconURL(); !badge_path.empty())
{
const ImVec2 badge_size = ImVec2(UIStyle.LargeFontSize, UIStyle.LargeFontSize);
const ImVec2 badge_pos =
ImVec2(name_pos.x - badge_size.x - LayoutScale(LAYOUT_MENU_BUTTON_X_PADDING), time_pos.y);
dl->AddImage(reinterpret_cast<ImTextureID>(GetCachedTextureAsync(badge_path)), badge_pos,
badge_pos + badge_size);
}
}
}
}
EndFullscreenWindow();
}
void FullscreenUI::DrawLandingWindow()
{
ImVec2 menu_pos, menu_size;
DrawLandingTemplate(&menu_pos, &menu_size);
ImGui::PushStyleColor(ImGuiCol_Text, UIStyle.BackgroundTextColor);
if (BeginHorizontalMenu("landing_window", menu_pos, menu_size, GetTransparentBackgroundColor(), 4))
{
ResetFocusHere();
if (UserThemeableHorizontalButton("fullscreenui/game-list.png", "fullscreenui/game-list.svg",
FSUI_VSTR("Game List"),
FSUI_VSTR("Launch a game from images scanned from your game directories.")))
{
BeginTransition(TransitionEffect::ZoomIn, DEFAULT_TRANSITION_TIME, &SwitchToGameList);
}
ImGui::SetItemDefaultFocus();
if (UserThemeableHorizontalButton(
"fullscreenui/start-disc.png", "fullscreenui/start-disc.svg", FSUI_VSTR("Start Game"),
FSUI_VSTR("Launch a game from a file, disc, or starts the console without any disc inserted.")))
{
BeginTransition([]() { SwitchToMainWindow(MainWindowType::StartGame); });
}
if (UserThemeableHorizontalButton("fullscreenui/settings.png", "fullscreenui/settings.svg", FSUI_VSTR("Settings"),
FSUI_VSTR("Changes settings for the application.")))
{
BeginTransition(TransitionEffect::ZoomIn, DEFAULT_TRANSITION_TIME, []() { SwitchToSettings(); });
}
if (UserThemeableHorizontalButton("fullscreenui/exit.png", "fullscreenui/exit.svg", FSUI_VSTR("Exit"),
FSUI_VSTR("Return to desktop mode, or exit the application.")) ||
(!AreAnyDialogsOpen() && WantsToCloseMenu()))
{
BeginTransition([]() { SwitchToMainWindow(MainWindowType::Exit); });
}
}
EndHorizontalMenu();
ImGui::PopStyleColor();
if (!AreAnyDialogsOpen())
{
if (ImGui::IsKeyPressed(ImGuiKey_GamepadBack, false) || ImGui::IsKeyPressed(ImGuiKey_F1, false))
{
EnqueueSoundEffect(SFX_NAV_ACTIVATE);
OpenFixedPopupDialog(ABOUT_DIALOG_NAME);
}
else if (ImGui::IsKeyPressed(ImGuiKey_GamepadStart, false) || ImGui::IsKeyPressed(ImGuiKey_F3, false))
{
EnqueueSoundEffect(SFX_NAV_ACTIVATE);
DoResume();
}
else if (ImGui::IsKeyPressed(ImGuiKey_NavGamepadMenu, false) || ImGui::IsKeyPressed(ImGuiKey_F11, false))
{
DoToggleFullscreen();
}
}
if (IsGamepadInputSource())
{
SetFullscreenFooterText(std::array{std::make_pair(ICON_PF_SHARE_CAPTURE, FSUI_VSTR("About")),
std::make_pair(ICON_PF_BURGER_MENU, FSUI_VSTR("Resume Last Session")),
std::make_pair(ICON_PF_BUTTON_X, FSUI_VSTR("Toggle Fullscreen")),
std::make_pair(ICON_PF_XBOX_DPAD_LEFT_RIGHT, FSUI_VSTR("Navigate")),
std::make_pair(ICON_PF_BUTTON_A, FSUI_VSTR("Select")),
std::make_pair(ICON_PF_BUTTON_B, FSUI_VSTR("Exit"))});
}
else
{
SetFullscreenFooterText(std::array{
std::make_pair(ICON_PF_F1, FSUI_VSTR("About")), std::make_pair(ICON_PF_F3, FSUI_VSTR("Resume Last Session")),
std::make_pair(ICON_PF_F11, FSUI_VSTR("Toggle Fullscreen")),
std::make_pair(ICON_PF_ARROW_LEFT ICON_PF_ARROW_RIGHT, FSUI_VSTR("Navigate")),
std::make_pair(ICON_PF_ENTER, FSUI_VSTR("Select")), std::make_pair(ICON_PF_ESC, FSUI_VSTR("Exit"))});
}
}
void FullscreenUI::DrawStartGameWindow()
{
ImVec2 menu_pos, menu_size;
DrawLandingTemplate(&menu_pos, &menu_size);
ImGui::PushStyleColor(ImGuiCol_Text, UIStyle.BackgroundTextColor);
if (BeginHorizontalMenu("start_game_window", menu_pos, menu_size, GetTransparentBackgroundColor(), 4))
{
ResetFocusHere();
if (UserThemeableHorizontalButton("fullscreenui/start-file.png", "fullscreenui/start-file.svg",
FSUI_VSTR("Start File"),
FSUI_VSTR("Launch a game by selecting a file/disc image.")))
{
DoStartFile();
}
if (UserThemeableHorizontalButton("fullscreenui/start-disc.png", "fullscreenui/start-disc.svg",
FSUI_VSTR("Start Disc"),
FSUI_VSTR("Start a game from a disc in your PC's DVD drive.")))
{
DoStartDisc();
}
if (UserThemeableHorizontalButton("fullscreenui/start-bios.png", "fullscreenui/start-bios.svg",
FSUI_VSTR("Start BIOS"),
FSUI_VSTR("Start the console without any disc inserted.")))
{
DoStartBIOS();
}
if (UserThemeableHorizontalButton("fullscreenui/back-icon.png", "fullscreenui/back-icon.svg", FSUI_VSTR("Back"),
FSUI_VSTR("Return to the previous menu.")) ||
(!AreAnyDialogsOpen() && WantsToCloseMenu()))
{
BeginTransition([]() { SwitchToMainWindow(MainWindowType::Landing); });
}
}
EndHorizontalMenu();
ImGui::PopStyleColor();
if (!AreAnyDialogsOpen())
{
if (ImGui::IsKeyPressed(ImGuiKey_NavGamepadMenu, false) || ImGui::IsKeyPressed(ImGuiKey_F1, false))
{
EnqueueSoundEffect(SFX_NAV_ACTIVATE);
OpenSaveStateSelector(std::string(), std::string(), true);
}
}
if (IsGamepadInputSource())
{
SetFullscreenFooterText(std::array{std::make_pair(ICON_PF_XBOX_DPAD_LEFT_RIGHT, FSUI_VSTR("Navigate")),
std::make_pair(ICON_PF_BUTTON_Y, FSUI_VSTR("Load Global State")),
std::make_pair(ICON_PF_BUTTON_A, FSUI_VSTR("Select")),
std::make_pair(ICON_PF_BUTTON_B, FSUI_VSTR("Back"))});
}
else
{
SetFullscreenFooterText(std::array{std::make_pair(ICON_PF_ARROW_LEFT ICON_PF_ARROW_RIGHT, FSUI_VSTR("Navigate")),
std::make_pair(ICON_PF_F1, FSUI_VSTR("Load Global State")),
std::make_pair(ICON_PF_ENTER, FSUI_VSTR("Select")),
std::make_pair(ICON_PF_ESC, FSUI_VSTR("Back"))});
}
}
void FullscreenUI::DrawExitWindow()
{
ImVec2 menu_pos, menu_size;
DrawLandingTemplate(&menu_pos, &menu_size);
ImGui::PushStyleColor(ImGuiCol_Text, UIStyle.BackgroundTextColor);
if (BeginHorizontalMenu("exit_window", menu_pos, menu_size, GetTransparentBackgroundColor(), 3))
{
ResetFocusHere();
if (UserThemeableHorizontalButton("fullscreenui/back-icon.png", "fullscreenui/back-icon.svg", FSUI_VSTR("Back"),
FSUI_VSTR("Return to the previous menu.")) ||
WantsToCloseMenu())
{
BeginTransition([]() { SwitchToMainWindow(MainWindowType::Landing); });
}
if (UserThemeableHorizontalButton("fullscreenui/exit.png", "fullscreenui/exit.svg", FSUI_VSTR("Exit DuckStation"),
FSUI_VSTR("Completely exits the application, returning you to your desktop.")))
{
DoRequestExit();
}
if (UserThemeableHorizontalButton("fullscreenui/desktop-mode.png", "fullscreenui/desktop-mode.svg",
FSUI_VSTR("Desktop Mode"),
FSUI_VSTR("Exits Big Picture mode, returning to the desktop interface.")))
{
DoDesktopMode();
}
}
EndHorizontalMenu();
ImGui::PopStyleColor();
SetStandardSelectionFooterText(true);
}
float FullscreenUI::GetBackgroundAlpha()
{
if (VideoThread::HasGPUBackend())
{
// reduce background opacity when changing postfx settings
if (s_locals.current_main_window == MainWindowType::Settings &&
GetCurrentSettingsPage() == SettingsPage::PostProcessing)
{
return 0.50f;
}
else
{
return UIStyle.IsDarkTheme ? 0.90f : 0.92f;
}
}
else
{
return HasBackground() ? 0.5f : 1.0f;
}
}
void FullscreenUI::DrawPauseMenu()
{
static constexpr auto switch_submenu = [](PauseSubMenu submenu) {
s_locals.current_pause_submenu = submenu;
QueueResetFocus(FocusResetType::ViewChanged);
};
static constexpr float top_bar_height = 90.0f;
static constexpr float top_bar_padding = 10.0f;
SmallString buffer;
ImDrawList* const dl = ImGui::GetBackgroundDrawList();
const ImVec2 display_size(ImGui::GetIO().DisplaySize);
const ImU32 title_text_color = ImGui::GetColorU32(UIStyle.BackgroundTextColor);
const ImU32 text_color = ImGui::GetColorU32(DarkerColor(UIStyle.BackgroundTextColor, 0.85f));
const ImU32 last_text_color = ImGui::GetColorU32(DarkerColor(DarkerColor(UIStyle.BackgroundTextColor, 0.85f)));
// top bar
const float scaled_top_bar_height = LayoutScale(top_bar_height);
{
const float scaled_text_spacing = LayoutScale(4.0f);
const float scaled_top_bar_padding = LayoutScale(top_bar_padding);
const ImVec2 top_bar_min = ImVec2(0.0f, 0.0f);
const ImVec2 top_bar_max = ImVec2(display_size.x, scaled_top_bar_height);
if (UIStyle.BlurMenuBackground && BeginBlurBackground(dl, top_bar_min, top_bar_max))
{
dl->AddRectFilled(top_bar_min, top_bar_max, ImGui::GetColorU32(ModAlpha(UIStyle.BackgroundColor, 1.0f)), 0.0f);
EndBlurBackground(dl);
}
else
{
dl->AddRectFilled(top_bar_min, top_bar_max, ImGui::GetColorU32(ModAlpha(UIStyle.BackgroundColor, 0.95f)), 0.0f);
}
const std::string& game_title = VideoThread::GetGameTitle();
const std::string& game_serial = VideoThread::GetGameSerial();
const std::string& game_path = VideoThread::GetGamePath();
GPUTexture* const cover = GetCoverForCurrentGame(game_path);
const float image_padding = LayoutScale(5.0f); // compensate for font baseline
const float image_size = scaled_top_bar_height - scaled_top_bar_padding - scaled_top_bar_padding - image_padding;
const ImRect image_rect(
CenterImage(ImRect(scaled_top_bar_padding + image_padding, scaled_top_bar_padding + image_padding,
scaled_top_bar_padding + image_size, scaled_top_bar_padding + image_size),
ImVec2(static_cast<float>(cover->GetWidth()), static_cast<float>(cover->GetHeight()))));
dl->AddImage(cover, image_rect.Min, image_rect.Max);
if (!game_serial.empty())
buffer.format("{} - {}", game_serial, Path::GetFileName(game_path));
else
buffer.assign(Path::GetFileName(game_path));
ImVec2 text_pos = ImVec2(scaled_top_bar_padding + image_size + scaled_top_bar_padding, scaled_top_bar_padding);
RenderShadowedTextClipped(dl, UIStyle.Font, UIStyle.LargeFontSize, UIStyle.BoldFontWeight, text_pos, display_size,
title_text_color, game_title);
text_pos.y += UIStyle.LargeFontSize + scaled_text_spacing;
if (Achievements::IsActive())
{
const auto lock = Achievements::GetLock();
if (const std::string& rp = Achievements::GetRichPresenceString(); !rp.empty())
{
RenderShadowedTextClipped(dl, UIStyle.Font, UIStyle.MediumFontSize, UIStyle.BoldFontWeight, text_pos,
display_size, text_color, rp);
text_pos.y += UIStyle.MediumFontSize + scaled_text_spacing;
}
}
RenderShadowedTextClipped(dl, UIStyle.Font, UIStyle.MediumFontSize, UIStyle.NormalFontWeight, text_pos,
display_size, last_text_color, buffer);
// current time / play time
UpdateCurrentTimeString();
ImVec2 text_size =
UIStyle.Font->CalcTextSizeA(UIStyle.LargeFontSize, UIStyle.BoldFontWeight, std::numeric_limits<float>::max(),
-1.0f, IMSTR_START_END(s_locals.current_time_string));
text_pos = ImVec2(display_size.x - scaled_top_bar_padding - text_size.x, scaled_top_bar_padding);
RenderShadowedTextClipped(dl, UIStyle.Font, UIStyle.LargeFontSize, UIStyle.BoldFontWeight, text_pos, display_size,
title_text_color, s_locals.current_time_string);
text_pos.y += UIStyle.LargeFontSize + scaled_text_spacing;
if (!game_serial.empty())
{
const std::time_t cached_played_time = GameList::GetCachedPlayedTimeForSerial(game_serial);
const std::time_t session_time = static_cast<std::time_t>(System::GetSessionPlayedTime());
buffer.format(FSUI_FSTR("Session: {}"), Host::FormatTimespan(session_time, true));
text_size =
UIStyle.Font->CalcTextSizeA(UIStyle.MediumFontSize, UIStyle.NormalFontWeight, std::numeric_limits<float>::max(),
-1.0f, buffer.c_str(), buffer.end_ptr());
text_pos.x = display_size.x - scaled_top_bar_padding - text_size.x;
RenderShadowedTextClipped(dl, UIStyle.Font, UIStyle.MediumFontSize, UIStyle.NormalFontWeight, text_pos,
display_size, text_color, buffer);
text_pos.y += UIStyle.MediumFontSize + scaled_text_spacing;
buffer.format(FSUI_FSTR("All Time: {}"), Host::FormatTimespan(cached_played_time + session_time, true));
text_size = UIStyle.Font->CalcTextSizeA(UIStyle.MediumFontSize, UIStyle.NormalFontWeight,
std::numeric_limits<float>::max(), -1.0f, IMSTR_START_END(buffer));
text_pos.x = display_size.x - scaled_top_bar_padding - text_size.x;
RenderShadowedTextClipped(dl, UIStyle.Font, UIStyle.MediumFontSize, UIStyle.NormalFontWeight, text_pos,
display_size, text_color, buffer);
text_pos.y += UIStyle.MediumFontSize + scaled_text_spacing;
}
}
const ImVec2 window_size(LayoutScale(500.0f),
display_size.y - scaled_top_bar_height - LayoutScale(LAYOUT_FOOTER_HEIGHT));
const ImVec2 window_pos(0.0f, scaled_top_bar_height);
// background
dl->AddRectFilled(ImVec2(0.0f, scaled_top_bar_height),
ImVec2(display_size.x, display_size.y - LayoutScale(LAYOUT_FOOTER_HEIGHT)),
ImGui::GetColorU32(ModAlpha(UIStyle.BackgroundColor, 0.9f)));
DrawAchievementsPauseMenuOverlays(scaled_top_bar_height);
if (BeginFullscreenWindow(window_pos, window_size, "pause_menu", ImVec4(0.0f, 0.0f, 0.0f, 0.0f), 0.0f,
ImVec2(10.0f, 10.0f), ImGuiWindowFlags_NoBackground))
{
static constexpr u32 submenu_item_count[] = {
11, // None
4, // Exit
3, // Achievements
};
// reduce spacing to fit all the buttons
ResetFocusHere();
BeginMenuButtons(submenu_item_count[static_cast<u32>(s_locals.current_pause_submenu)], 1.0f,
LAYOUT_MENU_BUTTON_X_PADDING, LAYOUT_MENU_BUTTON_Y_PADDING, 0.0f, 4.0f);
switch (s_locals.current_pause_submenu)
{
case PauseSubMenu::None:
{
// NOTE: Menu close must come first, because otherwise VM destruction options will race.
const bool has_game = VideoThread::HasGPUBackend();
if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_PLAY, "Resume Game")) ||
(!AreAnyDialogsOpen() && WantsToCloseMenu()))
{
ClosePauseMenu(TransitionEffect::ZoomOut);
}
ImGui::SetItemDefaultFocus();
if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_FORWARD, "Toggle Fast Forward")))
{
ClosePauseMenu(TransitionEffect::ZoomOut);
DoToggleFastForward();
}
if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_PF_DOWNLOAD, "Load State"), has_game))
OpenSaveStateSelector(VideoThread::GetGameSerial(), VideoThread::GetGamePath(), true);
if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_PF_DISKETTE, "Save State"), has_game))
OpenSaveStateSelector(VideoThread::GetGameSerial(), VideoThread::GetGamePath(), false);
if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_PF_GAMEPAD_ALT, "Toggle Analog")))
{
ClosePauseMenu(TransitionEffect::ZoomOut);
DoToggleAnalogMode();
}
if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_WRENCH, "Game Properties"),
has_game && GameList::CanEditGameSettingsForPath(VideoThread::GetGamePath(),
VideoThread::GetGameSerial())))
{
BeginTransition(TransitionEffect::ZoomIn, DEFAULT_TRANSITION_TIME, []() { SwitchToGameSettings(); });
}
if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_TROPHY, "Achievements"),
Achievements::HasAchievementsOrLeaderboards()))
{
// skip second menu and go straight to cheevos if there's no lbs
if (!Achievements::HasLeaderboards())
BeginTransition(TransitionEffect::ZoomIn, DEFAULT_TRANSITION_TIME, &SwitchToAchievements);
else
BeginTransition([]() { switch_submenu(PauseSubMenu::Achievements); });
}
if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_CAMERA, "Save Screenshot")))
{
Host::RunOnCoreThread([]() { System::SaveScreenshot(); });
ClosePauseMenu(TransitionEffect::ZoomOut);
}
if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_COMPACT_DISC, "Change Disc")))
Host::RunOnCoreThread([]() { BeginChangeDiscOnCoreThread(false); });
if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_SLIDERS, "Settings")))
BeginTransition(TransitionEffect::ZoomIn, DEFAULT_TRANSITION_TIME, []() { SwitchToSettings(); });
if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_POWER_OFF, "Close Game")))
{
// skip submenu when we can't save anyway
if (!has_game)
RequestShutdown(false);
else
BeginTransition([]() { switch_submenu(PauseSubMenu::Exit); });
}
}
break;
case PauseSubMenu::Exit:
{
if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_PF_NAVIGATION_BACK, "Back To Pause Menu")) ||
WantsToCloseMenu())
{
BeginTransition([]() { switch_submenu(PauseSubMenu::None); });
}
else
{
ImGui::SetItemDefaultFocus();
}
if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_ARROWS_ROTATE, "Restart Game")))
RequestRestart();
if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_FLOPPY_DISK, "Close and Save State")))
RequestShutdown(true);
if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_POWER_OFF, "Close Without Saving")))
RequestShutdown(false);
}
break;
case PauseSubMenu::Achievements:
{
if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_PF_NAVIGATION_BACK, "Back To Pause Menu")) ||
WantsToCloseMenu())
{
BeginTransition([]() { switch_submenu(PauseSubMenu::None); });
}
else
{
ImGui::SetItemDefaultFocus();
}
if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_TROPHY, "Achievements")))
BeginTransition(TransitionEffect::ZoomIn, DEFAULT_TRANSITION_TIME, &SwitchToAchievements);
if (MenuButtonWithoutSummary(FSUI_ICONVSTR(ICON_FA_STOPWATCH, "Leaderboards")))
BeginTransition(TransitionEffect::ZoomIn, DEFAULT_TRANSITION_TIME, &SwitchToLeaderboards);
}
break;
}
EndMenuButtons();
}
EndFullscreenWindow();
SetFullscreenFooterBlur(false);
if (IsGamepadInputSource())
{
SetFullscreenFooterText(std::array{std::make_pair(ICON_PF_XBOX_DPAD_UP_DOWN, FSUI_VSTR("Change Selection")),
std::make_pair(ICON_PF_BUTTON_A, FSUI_VSTR("Select")),
std::make_pair(ICON_PF_BUTTON_B, FSUI_VSTR("Return To Game"))});
}
else
{
SetFullscreenFooterText(std::array{
std::make_pair(ICON_PF_ARROW_UP ICON_PF_ARROW_DOWN, FSUI_VSTR("Change Selection")),
std::make_pair(ICON_PF_ENTER, FSUI_VSTR("Select")), std::make_pair(ICON_PF_ESC, FSUI_VSTR("Return To Game"))});
}
}
void FullscreenUI::InitializePlaceholderSaveStateListEntry(SaveStateListEntry* li, s32 slot, bool global)
{
li->title = (global || slot > 0) ? fmt::format(global ? FSUI_FSTR("Global Slot {0}##global_slot_{0}") :
FSUI_FSTR("Game Slot {0}##game_slot_{0}"),
slot) :
FSUI_STR("Quick Save");
li->summary = FSUI_STR("No save present in this slot.");
li->timestamp = 0;
li->slot = slot;
li->preview_texture = {};
li->global = global;
}
bool FullscreenUI::InitializeSaveStateListEntryFromSerial(SaveStateListEntry* li, const std::string& serial, s32 slot,
bool global)
{
Error error;
bool exists;
std::string path = (global ? System::GetGlobalSaveStatePath(slot) : System::GetGameSaveStatePath(serial, slot));
if (!InitializeSaveStateListEntryFromPath(li, std::move(path), slot, global, &error, &exists))
{
InitializePlaceholderSaveStateListEntry(li, slot, global);
if (exists)
{
// Show error instead.
li->summary = error.TakeDescription();
return true;
}
return false;
}
return true;
}
bool FullscreenUI::InitializeSaveStateListEntryFromPath(SaveStateListEntry* li, std::string path, s32 slot, bool global,
Error* error, bool* exists)
{
li->state_path = std::move(path);
std::optional<ExtendedSaveStateInfo> ssi = System::GetExtendedSaveStateInfo(li->state_path.c_str(), error, exists);
if (!ssi.has_value())
return false;
if (global)
{
li->title = fmt::format(FSUI_FSTR("Global Slot {0} - {1}##global_slot_{0}"), slot, ssi->serial);
}
else
{
li->title = (slot > 0) ? fmt::format(FSUI_FSTR("Game Slot {0}##game_slot_{0}"), slot) : FSUI_STR("Game Quick Save");
}
li->summary = fmt::format(FSUI_FSTR("Saved {}"), Host::FormatRelativeDateTime(ssi->timestamp, false, false));
li->timestamp = ssi->timestamp;
li->slot = slot;
li->game_path = std::move(ssi->media_path);
li->global = global;
if (ssi->screenshot.IsValid())
li->preview_texture = g_gpu_device->FetchAndUploadTextureImage(ssi->screenshot);
return true;
}
void FullscreenUI::ClearSaveStateEntryList()
{
for (SaveStateListEntry& entry : s_locals.save_state_selector_slots)
QueueTextureRecycle(std::move(entry.preview_texture));
s_locals.save_state_selector_slots.clear();
}
u32 FullscreenUI::PopulateSaveStateListEntries(const std::string& serial,
std::optional<ExtendedSaveStateInfo> undo_save_state, bool is_loading)
{
ClearSaveStateEntryList();
if (undo_save_state.has_value())
{
SaveStateListEntry li;
li.title = FSUI_STR("Undo Load State");
li.summary =
fmt::format(FSUI_FSTR("Saved {}"), Host::FormatRelativeDateTime(undo_save_state->timestamp, false, false));
if (undo_save_state->screenshot.IsValid())
li.preview_texture = g_gpu_device->FetchAndUploadTextureImage(undo_save_state->screenshot);
s_locals.save_state_selector_slots.push_back(std::move(li));
}
if (!serial.empty())
{
for (s32 i = 1; i <= System::PER_GAME_SAVE_STATE_SLOTS; i++)
{
SaveStateListEntry li;
if (InitializeSaveStateListEntryFromSerial(&li, serial, i, false) || !is_loading)
s_locals.save_state_selector_slots.push_back(std::move(li));
}
}
if (System::AreGlobalSaveStatesEnabled())
{
for (s32 i = 1; i <= System::GLOBAL_SAVE_STATE_SLOTS; i++)
{
SaveStateListEntry li;
if (InitializeSaveStateListEntryFromSerial(&li, serial, i, true) || !is_loading)
s_locals.save_state_selector_slots.push_back(std::move(li));
}
}
return static_cast<u32>(s_locals.save_state_selector_slots.size());
}
void FullscreenUI::OpenSaveStateSelector(const std::string& serial, const std::string& path, bool is_loading)
{
if (VideoThread::HasGPUBackend())
{
// need to get the undo state, if any
Host::RunOnCoreThread([serial = serial, is_loading]() {
std::optional<ExtendedSaveStateInfo> undo_state;
if (is_loading)
undo_state = System::GetUndoSaveStateInfo();
VideoThread::RunOnThread([serial = std::move(serial), undo_state = std::move(undo_state), is_loading]() mutable {
if (PopulateSaveStateListEntries(serial, std::move(undo_state), is_loading) > 0)
{
s_locals.save_state_selector_loading = is_loading;
BeginTransition(TransitionEffect::ZoomIn, DEFAULT_TRANSITION_TIME,
[]() { SwitchToMainWindow(MainWindowType::SaveStateSelector); });
}
else
{
ShowToast(OSDMessageType::Info, {}, FSUI_STR("No save states found."));
}
});
});
}
else
{
if (PopulateSaveStateListEntries(serial, std::nullopt, is_loading) > 0)
{
s_locals.save_state_selector_loading = is_loading;
BeginTransition(TransitionEffect::ZoomIn, DEFAULT_TRANSITION_TIME,
[]() { SwitchToMainWindow(MainWindowType::SaveStateSelector); });
}
else
{
ShowToast(OSDMessageType::Info, {}, FSUI_STR("No save states found."));
}
}
}
void FullscreenUI::DrawSaveStateSelector()
{
static constexpr auto do_load_state = [](const SaveStateListEntry& entry) {
if (VideoThread::HasGPUBackend())
{
// Loading undo state?
if (entry.state_path.empty())
{
Host::RunOnCoreThread(&System::UndoLoadState);
}
else
{
Host::RunOnCoreThread(
[global = entry.global, slot = entry.slot]() { System::LoadStateFromSlot(global, slot); });
}
ClearSaveStateEntryList();
ClosePauseMenu(TransitionEffect::Fade, LONG_TRANSITION_TIME);
}
else
{
DoStartPath(entry.game_path, entry.state_path);
}
};
static constexpr auto do_save_state = [](const SaveStateListEntry& entry) {
Host::RunOnCoreThread([slot = entry.slot, global = entry.global]() { System::SaveStateToSlot(global, slot); });
ClearSaveStateEntryList();
ClosePauseMenu(TransitionEffect::Fade, LONG_TRANSITION_TIME);
};
ImGuiIO& io = ImGui::GetIO();
const ImVec2 heading_size = ImVec2(
io.DisplaySize.x, UIStyle.LargeFontSize + (LayoutScale(LAYOUT_MENU_BUTTON_Y_PADDING) * 2.0f) + LayoutScale(2.0f));
SaveStateListEntry* pressed_entry = nullptr;
bool closed = false;
// last state deleted?
if (s_locals.save_state_selector_slots.empty())
closed = true;
if (BeginFullscreenWindow(ImVec2(0.0f, 0.0f), heading_size, "##save_state_selector_title",
ModAlpha(UIStyle.PrimaryColor, GetBackgroundAlpha()), 0.0f, ImVec2(), 0, true))
{
BeginNavBar();
if (NavButton(ICON_PF_NAVIGATION_BACK, true, true))
closed = true;
NavTitle(s_locals.save_state_selector_loading ? FSUI_VSTR("Load State") : FSUI_VSTR("Save State"));
EndNavBar();
}
EndFullscreenWindow();
if (IsFocusResetFromWindowChange())
ImGui::SetNextWindowScroll(ImVec2(0.0f, 0.0f));
if (BeginFullscreenWindow(
ImVec2(0.0f, heading_size.y),
ImVec2(io.DisplaySize.x, io.DisplaySize.y - heading_size.y - LayoutScale(LAYOUT_FOOTER_HEIGHT)),
"##save_state_selector_list", ModAlpha(UIStyle.BackgroundColor, GetBackgroundAlpha()), 0.0f,
ImVec2(LAYOUT_MENU_WINDOW_X_PADDING, LAYOUT_MENU_WINDOW_Y_PADDING), 0, true) &&
!s_locals.save_state_selector_slots.empty())
{
ResetFocusHere();
BeginMenuButtons();
const ImGuiStyle& style = ImGui::GetStyle();
const float title_spacing = LayoutScale(10.0f);
const float summary_spacing = LayoutScale(4.0f);
const float item_spacing = LayoutScale(20.0f);
const float item_width_with_spacing = std::floor(LayoutScale(LAYOUT_SCREEN_WIDTH / 4.0f));
const float item_width = item_width_with_spacing - item_spacing;
const float image_width = item_width - (style.FramePadding.x * 2.0f);
const float image_height = image_width / 1.33f;
const ImVec2 image_size(image_width, image_height);
const float item_height = (style.FramePadding.y * 2.0f) + image_height + title_spacing + UIStyle.LargeFontSize +
summary_spacing + UIStyle.MediumFontSize;
const ImVec2 item_size(item_width, item_height);
const u32 grid_count_x = static_cast<u32>(std::floor(ImGui::GetContentRegionAvail().x / item_width_with_spacing));
const float start_x =
(static_cast<float>(ImGui::GetWindowWidth()) - (item_width_with_spacing * static_cast<float>(grid_count_x))) *
0.5f;
u32 grid_x = 0;
ImGui::SetCursorPosX(start_x);
for (u32 i = 0;;)
{
SaveStateListEntry& entry = s_locals.save_state_selector_slots[i];
ImGuiWindow* window = ImGui::GetCurrentWindow();
if (window->SkipItems)
{
i++;
continue;
}
const ImGuiID id = window->GetID(static_cast<int>(i));
const ImVec2 pos(window->DC.CursorPos);
ImRect bb(pos, pos + item_size);
ImGui::ItemSize(item_size);
if (ImGui::ItemAdd(bb, id))
{
bool held;
bool hovered;
bool pressed = ImGui::ButtonBehavior(bb, id, &hovered, &held, 0);
if (hovered)
DrawMenuButtonFrame(bb.Min, bb.Max, held);
bb.Min += style.FramePadding;
bb.Max -= style.FramePadding;
GPUTexture* const screenshot =
entry.preview_texture ? entry.preview_texture.get() : GetCachedTextureAsync("no-save.png");
const ImRect image_rect(
CenterImage(ImRect(bb.Min, bb.Min + image_size),
ImVec2(static_cast<float>(screenshot->GetWidth()), static_cast<float>(screenshot->GetHeight()))));
ImGui::GetWindowDrawList()->AddImage(screenshot, image_rect.Min, image_rect.Max, ImVec2(0.0f, 0.0f),
ImVec2(1.0f, 1.0f), IM_COL32(255, 255, 255, 255));
const ImVec2 title_pos(bb.Min.x, bb.Min.y + image_height + title_spacing);
const ImRect title_bb(title_pos, ImVec2(bb.Max.x, title_pos.y + UIStyle.LargeFontSize));
RenderShadowedTextClipped(UIStyle.Font, UIStyle.LargeFontSize, UIStyle.BoldFontWeight, title_bb.Min,
title_bb.Max, ImGui::GetColorU32(ImGuiCol_Text), entry.title, nullptr,
ImVec2(0.0f, 0.0f), 0.0f, &title_bb);
if (!entry.summary.empty())
{
const ImVec2 summary_pos(bb.Min.x, title_pos.y + UIStyle.LargeFontSize + summary_spacing);
const ImRect summary_bb(summary_pos, ImVec2(bb.Max.x, summary_pos.y + UIStyle.MediumFontSize));
RenderShadowedTextClipped(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.NormalFontWeight, summary_bb.Min,
summary_bb.Max, ImGui::GetColorU32(ImGuiCol_Text), entry.summary, nullptr,
ImVec2(0.0f, 0.0f), 0.0f, &summary_bb);
}
if (pressed)
{
// avoid closing while drawing
pressed_entry = &entry;
}
else if (hovered && (ImGui::IsItemClicked(ImGuiMouseButton_Right) ||
ImGui::IsKeyPressed(ImGuiKey_NavGamepadContextMenu, false) ||
ImGui::IsKeyPressed(ImGuiKey_F1, false)))
{
CancelPendingMenuClose();
std::string title;
if (s_locals.save_state_selector_loading)
title = FSUI_ICONVSTR(ICON_FA_FOLDER_OPEN, "Load State");
else
title = FSUI_ICONVSTR(ICON_FA_FOLDER_OPEN, "Save State");
ChoiceDialogOptions options;
options.reserve(3);
options.emplace_back(title, false);
if (!entry.state_path.empty())
options.emplace_back(FSUI_ICONVSTR(ICON_FA_FOLDER_MINUS, "Delete Save"), false);
options.emplace_back(FSUI_ICONVSTR(ICON_FA_SQUARE_XMARK, "Close Menu"), false);
OpenChoiceDialog(
std::move(title), false, std::move(options),
[i](s32 index, const std::string& title, bool checked) mutable {
if (index < 0 || i >= s_locals.save_state_selector_slots.size())
return;
SaveStateListEntry& entry = s_locals.save_state_selector_slots[i];
if (index == 0)
{
// load state
if (s_locals.save_state_selector_loading)
do_load_state(entry);
else
do_save_state(entry);
}
else if (!entry.state_path.empty() && index == 1)
{
// delete state
if (!FileSystem::FileExists(entry.state_path.c_str()))
{
ShowToast(OSDMessageType::Error, {},
fmt::format(FSUI_FSTR("{} does not exist."), RemoveHash(entry.title)));
}
else if (FileSystem::DeleteFile(entry.state_path.c_str()))
{
ShowToast(OSDMessageType::Quick, {}, fmt::format(FSUI_FSTR("{} deleted."), RemoveHash(entry.title)));
// need to preserve the texture, since it's going to be drawn this frame
QueueTextureRecycle(std::move(entry.preview_texture));
if (s_locals.save_state_selector_loading)
{
s_locals.save_state_selector_slots.erase(s_locals.save_state_selector_slots.begin() + i);
}
else
{
entry.state_path = {};
InitializePlaceholderSaveStateListEntry(&entry, entry.slot, entry.global);
}
}
else
{
ShowToast(OSDMessageType::Quick, {},
fmt::format(FSUI_FSTR("Failed to delete {}."), RemoveHash(entry.title)));
}
}
});
}
}
// avoid triggering imgui warning
i++;
if (i == s_locals.save_state_selector_slots.size())
break;
grid_x++;
if (grid_x == grid_count_x)
{
grid_x = 0;
ImGui::SetCursorPosX(start_x);
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + item_spacing);
}
else
{
ImGui::SameLine(start_x + static_cast<float>(grid_x) * (item_width + item_spacing));
}
}
EndMenuButtons();
SetWindowNavWrapping(true, true);
}
EndFullscreenWindow();
if (IsGamepadInputSource())
{
SetFullscreenFooterText(
std::array{std::make_pair(ICON_PF_XBOX_DPAD, FSUI_VSTR("Select State")),
std::make_pair(ICON_PF_BUTTON_Y, FSUI_VSTR("Delete State")),
std::make_pair(ICON_PF_BUTTON_A, s_locals.save_state_selector_loading ? FSUI_VSTR("Load State") :
FSUI_VSTR("Save State")),
std::make_pair(ICON_PF_BUTTON_B, FSUI_VSTR("Cancel"))});
}
else
{
SetFullscreenFooterText(
std::array{std::make_pair(ICON_PF_ARROW_UP ICON_PF_ARROW_DOWN ICON_PF_ARROW_LEFT ICON_PF_ARROW_RIGHT,
FSUI_VSTR("Select State")),
std::make_pair(ICON_PF_F1, FSUI_VSTR("Delete State")),
std::make_pair(ICON_PF_ENTER, s_locals.save_state_selector_loading ? FSUI_VSTR("Load State") :
FSUI_VSTR("Save State")),
std::make_pair(ICON_PF_ESC, FSUI_VSTR("Cancel"))});
}
if (pressed_entry)
{
if (s_locals.save_state_selector_loading)
do_load_state(*pressed_entry);
else
do_save_state(*pressed_entry);
}
else if ((!AreAnyDialogsOpen() && WantsToCloseMenu()) || closed)
{
ClearSaveStateEntryList();
ReturnToPreviousWindow(TransitionEffect::ZoomOut);
}
}
bool FullscreenUI::OpenLoadStateSelectorForGameResume(const GameList::Entry* entry)
{
Error error;
bool exists;
SaveStateListEntry slentry;
if (!InitializeSaveStateListEntryFromPath(&slentry, System::GetGameSaveStatePath(entry->serial, -1), -1, false,
&error, &exists))
{
if (!exists)
return false;
OpenConfirmMessageDialog(
ICON_EMOJI_NO_ENTRY_SIGN, FSUI_ICONVSTR(ICON_FA_FOLDER_OPEN, "Load Resume State"),
fmt::format(
FSUI_FSTR("A resume save state was found for this game, but it is corrupted and cannot be loaded:\n\n{}\n\n"
"Do you want to delete the save state and boot the game anyway?"),
error.GetDescription()),
[path = System::GetGameSaveStatePath(entry->serial, -1), game_path = entry->path](bool accepted) {
if (accepted)
{
Error error;
if (FileSystem::DeleteFile(path.c_str(), &error))
ShowToast(OSDMessageType::Info, {}, FSUI_STR("Save state deleted."));
else
ShowToast(OSDMessageType::Error, FSUI_STR("Failed to delete save state."), error.TakeDescription());
DoStartPath(std::move(game_path));
}
},
FSUI_ICONSTR(ICON_FA_FOLDER_MINUS, "Delete And Boot"), FSUI_ICONSTR(ICON_FA_XMARK, "Cancel"));
ClearSaveStateEntryList();
return true;
}
slentry.game_path = entry->path;
s_locals.save_state_selector_slots.push_back(std::move(slentry));
OpenFixedPopupDialog(RESUME_STATE_SELECTOR_DIALOG_NAME);
return true;
}
void FullscreenUI::DrawResumeStateSelector()
{
if (!BeginFixedPopupDialog(LayoutScale(30.0f), LayoutScale(40.0f), ImVec2(LayoutScale(850.0f), 0.0f),
LayoutScale(0.0, 300.0f)))
{
ClearSaveStateEntryList();
return;
}
SaveStateListEntry& entry = s_locals.save_state_selector_slots.front();
SmallString sick;
sick.format(FSUI_FSTR("Do you want to continue from the automatic save created {}?"),
Host::FormatRelativeDateTime(entry.timestamp, false, false));
ImGui::PushFont(nullptr, 0.0f, UIStyle.BoldFontWeight);
TextAlignedMultiLine(0.5f, IMSTR_START_END(sick));
ImGui::PopFont();
const GPUTexture* image = entry.preview_texture ? entry.preview_texture.get() : GetPlaceholderTexture();
const float image_height = LayoutScale(400.0f);
const float image_width =
image_height * (static_cast<float>(image->GetWidth()) / static_cast<float>(image->GetHeight()));
const ImVec2 pos(ImGui::GetCursorScreenPos() +
ImVec2((ImGui::GetCurrentWindow()->WorkRect.GetWidth() - image_width) * 0.5f, LayoutScale(20.0f)));
const ImRect image_bb(pos, pos + ImVec2(image_width, image_height));
ImGui::GetWindowDrawList()->AddImage(
static_cast<ImTextureID>(entry.preview_texture ? entry.preview_texture.get() : GetPlaceholderTexture()),
image_bb.Min, image_bb.Max, ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f),
ImGui::GetColorU32(IM_COL32(255, 255, 255, 255)));
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + image_height + LayoutScale(40.0f));
ResetFocusHere();
BeginHorizontalMenuButtons(4);
if (HorizontalMenuButton(FSUI_ICONVSTR(ICON_FA_FOLDER_OPEN, "Load State"), true, LAYOUT_CENTER_ALIGN_TEXT))
{
std::string game_path = std::move(entry.game_path);
std::string state_path = std::move(entry.state_path);
ClearSaveStateEntryList();
CloseFixedPopupDialogImmediately();
DoStartPath(std::move(game_path), std::move(state_path));
}
if (HorizontalMenuButton(FSUI_ICONVSTR(ICON_FA_PLAY, "Clean Boot"), true, LAYOUT_CENTER_ALIGN_TEXT))
{
std::string game_path = std::move(entry.game_path);
ClearSaveStateEntryList();
CloseFixedPopupDialogImmediately();
DoStartPath(std::move(game_path));
}
if (HorizontalMenuButton(FSUI_ICONVSTR(ICON_FA_TRASH, "Delete State"), true, LAYOUT_CENTER_ALIGN_TEXT))
{
Error error;
if (FileSystem::DeleteFile(entry.state_path.c_str(), &error))
{
ShowToast(OSDMessageType::Info, {}, FSUI_STR("Save state deleted."));
std::string game_path = std::move(entry.game_path);
ClearSaveStateEntryList();
CloseFixedPopupDialogImmediately();
DoStartPath(std::move(game_path));
}
else
{
ShowToast(OSDMessageType::Error, FSUI_STR("Failed to delete save state."), error.TakeDescription());
}
}
if (HorizontalMenuButton(FSUI_ICONVSTR(ICON_FA_SQUARE_XMARK, "Cancel"), true, LAYOUT_CENTER_ALIGN_TEXT))
CloseFixedPopupDialog();
EndHorizontalMenuButtons();
SetStandardSelectionFooterText(false);
EndFixedPopupDialog();
}
//////////////////////////////////////////////////////////////////////////
// Overlays
//////////////////////////////////////////////////////////////////////////
void FullscreenUI::ExitFullscreenAndOpenURL(std::string_view url)
{
Host::RunOnCoreThread([url = std::string(url)]() {
VideoThread::SetFullscreen(false);
Host::OpenURL(url);
});
}
void FullscreenUI::CopyTextToClipboard(std::string title, std::string_view text)
{
if (Host::CopyTextToClipboard(text))
ShowToast(OSDMessageType::Quick, {}, std::move(title));
else
ShowToast(OSDMessageType::Error, {}, FSUI_STR("Failed to copy text to clipboard."));
}
void FullscreenUI::DrawAboutWindow()
{
if (!BeginFixedPopupDialog(LayoutScale(LAYOUT_LARGE_POPUP_PADDING), LayoutScale(LAYOUT_LARGE_POPUP_ROUNDING),
LayoutScale(1100.0f, 0.0f), LayoutScale(0.0, 400.0f)))
{
return;
}
const ImVec2 image_size = LayoutScale(64.0f, 64.0f);
const float indent = image_size.x + LayoutScale(8.0f);
GPUTexture* const logo_texture = GetUserThemeableTexture("images/duck.png", "images/duck.svg", nullptr, image_size);
ImGui::GetWindowDrawList()->AddImage(logo_texture, ImGui::GetCursorScreenPos(),
ImGui::GetCursorScreenPos() + image_size);
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + indent);
ImGui::PushFont(nullptr, 0.0f, UIStyle.BoldFontWeight);
ImGui::TextUnformatted("DuckStation");
ImGui::PopFont();
ImGui::PushStyleColor(ImGuiCol_Text, DarkerColor(UIStyle.BackgroundTextColor));
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + indent);
ImGui::TextUnformatted(g_scm_tag_str);
ImGui::PopStyleColor();
ImGui::NewLine();
ImGui::TextWrapped("%s", FSUI_CSTR("DuckStation is a free simulator/emulator of the Sony PlayStation(TM) "
"console, focusing on playability, speed, and long-term maintainability."));
ImGui::NewLine();
ImGui::TextWrapped("%s",
FSUI_CSTR("Duck icon by icons8 (https://icons8.com/icon/74847/platforms.undefined.short-title)"));
ImGui::NewLine();
ImGui::TextWrapped(
"%s", FSUI_CSTR("\"PlayStation\" and \"PSX\" are registered trademarks of Sony Interactive Entertainment Europe "
"Limited. This software is not affiliated in any way with Sony Interactive Entertainment."));
ImGui::NewLine();
BeginHorizontalMenuButtons(4);
if (HorizontalMenuButton(FSUI_ICONVSTR(ICON_FA_GLOBE, "GitHub Repository")))
ExitFullscreenAndOpenURL("https://github.com/stenzek/duckstation/");
if (HorizontalMenuButton(FSUI_ICONVSTR(ICON_FA_COMMENT, "Discord Server")))
ExitFullscreenAndOpenURL("https://www.duckstation.org/discord.html");
if (HorizontalMenuButton(FSUI_ICONVSTR(ICON_FA_PEOPLE_CARRY_BOX, "Contributor List")))
ExitFullscreenAndOpenURL("https://github.com/stenzek/duckstation/blob/master/CONTRIBUTORS.md");
if (HorizontalMenuButton(FSUI_ICONVSTR(ICON_FA_SQUARE_XMARK, "Close")) || WantsToCloseMenu())
CloseFixedPopupDialog();
else
SetStandardSelectionFooterText(true);
EndHorizontalMenuButtons();
EndFixedPopupDialog();
}
#endif // __ANDROID__