// RPCS3 PS5 - the home screen, laid out like the PS3's XMB (cross media bar): categories in a row, // each category's items in a column under it, a slow translucent wave behind everything and the // clock in the corner. Drawn from scratch in that style (no Sony artwork or sounds). // SPDX-License-Identifier: GPL-3.0-or-later // // Left / Right category: Settings, Game, Install, System // Up / Down item; Cross: start / choose; Triangle: a game's options; Circle: back // Square look for games again // // The previous design (UI kit "Aurora Shelf") is in git history. #include "concepts/concepts.hpp" #include "core/tween.hpp" #include "ui/glyphs.hpp" #include "ui/motion.hpp" #include "core.hpp" #include "library.hpp" #include "requests.hpp" #include "tiles.hpp" #include "recommend.hpp" #include #include #include #include #include #include #include namespace hui::screen { namespace { using gfx::Color; using gfx::Rect; const Color kWhite = Color::rgb(0xffffff); // The bar: the focused category sits at kBarX; the others follow at kCategoryGap constexpr float kBarX = 470.0f; constexpr float kBarY = 300.0f; // centre of the category icons constexpr float kCategoryGap = 210.0f; constexpr float kItemX = kBarX; // items line up under the focused category constexpr float kFirstItemY = 470.0f; // centre of the focused item constexpr float kItemPitch = 104.0f; constexpr float kGameItemPitch = 150.0f; enum Category : int { kSettings = 0, kGame, kInstall, kSystem, kCategoryCount }; constexpr const char *kCategoryNames[kCategoryCount] = {"Settings", "Game", "Install", "System"}; // The Settings column: the PC version's settings tabs, each a page of RPCS3's settings in those sections struct SettingsTab { const char *title; const char *sections[2]; }; constexpr SettingsTab kTabs[] = { {"CPU", {"Core", nullptr}}, {"GPU", {"Video", nullptr}}, {"Audio", {"Audio", nullptr}}, {"I/O", {"Input/Output", nullptr}}, {"System", {"System", nullptr}}, {"Network", {"Net", nullptr}}, {"Emulator", {"Miscellaneous", "Savestate"}}, }; constexpr int kTabCount = static_cast(sizeof(kTabs) / sizeof(kTabs[0])); constexpr const char *kVibration[] = {"Off", "Low", "Medium", "High"}; // Plain-text lines of at most `width` characters std::vector wrap(const std::string &text, std::size_t width) { std::vector lines; std::string line; std::size_t at = 0; while (at < text.size()) { std::size_t end = text.find(' ', at); if (end == std::string::npos) end = text.size(); const std::string word = text.substr(at, end - at); if (!line.empty() && line.size() + 1 + word.size() > width) { lines.push_back(line); line.clear(); } line += (line.empty() ? "" : " ") + word; at = end + 1; } if (!line.empty()) lines.push_back(line); return lines; } constexpr const char *kTechniques[] = { "A category bar with item columns, as on the PS3", "Translucent wave ribbons over a slow gradient", "Springs for every movement; game icons from ICON0.PNG", }; // A PS3-style month colour for the background (the XMB changed its colour with the month) Color month_colour() { static const std::uint32_t kMonths[12] = {0x8c8c94, 0xb89b2a, 0x5f9b30, 0xc06a92, 0x2f8f3a, 0x8a5bb8, 0x2fa0b8, 0x2d58b8, 0x7a4fb0, 0xb87a2a, 0x8a6a3a, 0xb03030}; const std::time_t now = std::time(nullptr); std::tm local{}; localtime_r(&now, &local); return Color::rgb(kMonths[local.tm_mon % 12]); } class Home final : public app::Concept { public: explicit Home(app::Context &context) : context_(context) { theme_ = month_colour(); category_pos_.snap(static_cast(category_)); } const app::ConceptInfo &info() const override { static const app::ConceptInfo kInfo{ "home", "RPCS3 PS5", "The PS3 library and the emulator's home, XMB style", "examples/rpcs3ps5/kit/home.cpp", audio::SoundSet::glass, Color::rgb(0x9ec8ff), kTechniques, }; return kInfo; } void enter() override { age_ = 0.0f; close_options(); } void update(const InputFrame &input, float dt, app::Feedback &feedback) override { age_ += dt; clock_ += dt; clamp_items(); if (dialog_open_) { update_dialog(input, feedback); } else if (page_open_) { update_page(input, feedback); } else if (options_open_) { update_options(input, feedback); } else { update_bar(input, feedback); offer_new_games(); } category_pos_.target = static_cast(category_); category_pos_.update(dt, 14.0f); for (int c = 0; c < kCategoryCount; ++c) { item_pos_[c].target = static_cast(item_[c]); item_pos_[c].update(dt, 16.0f); } options_.target = options_open_ ? 1.0f : 0.0f; options_.update(dt, context_.settings.reduced_motion ? 40.0f : 14.0f); option_pos_.target = static_cast(option_); option_pos_.update(dt, 22.0f); nudge_.update(dt, 9.0f); page_pos_.target = static_cast(page_sel_); page_pos_.update(dt, 20.0f); } void draw(app::Frame &frame) const override { // A dark gradient tinted with the month's colour; the waves are drawn on top frame.backdrop.mode = gfx::BackdropMode::gradient; frame.backdrop.colors[0] = gfx::mix(theme_, Color::rgb(0x000000), 0.72f); frame.backdrop.colors[1] = gfx::mix(theme_, Color::rgb(0x000000), 0.35f); frame.backdrop.colors[2] = gfx::mix(theme_, kWhite, 0.25f); frame.backdrop.params[0] = 0.5f; frame.backdrop.params[1] = 0.55f; frame.backdrop.params[2] = 0.35f; frame.backdrop.time = clock_; gfx::DrawList &list = frame.scene; draw_waves(list); list.push_opacity(tween::stagger(age_, 0, 0.05f, 0.5f)); draw_clock(list); draw_items(list); draw_bar(list); draw_status(list); draw_hints(list); list.pop_opacity(); if (options_.value > 0.01f) draw_options(frame.scene); if (page_open_) draw_page(frame.scene); if (dialog_open_) draw_dialog(frame.scene); if (page_open_ || dialog_open_) draw_hints(frame.scene); } private: static const std::vector &games() { return rpcs3ps5::library().games(); } static int game_count() { return static_cast(games().size()); } // ---- the items of each category ---- int item_count(int category) const { switch (category) { case kSettings: return kTabCount + (rpcs3ps5::settings_for_game() ? 2 : 1); case kGame: return std::max(game_count(), 1); // "No games" placeholder case kInstall: return 3; default: return 4; } } void clamp_items() { for (int c = 0; c < kCategoryCount; ++c) item_[c] = std::clamp(item_[c], 0, item_count(c) - 1); } struct Item { std::string label; std::string detail; }; Item item(int category, int index) const { const rpcs3ps5::Library &lib = rpcs3ps5::library(); switch (category) { case kSettings: if (index < kTabCount) { const int count = tab_setting_count(index); return {kTabs[index].title, std::to_string(count) + " settings" + (rpcs3ps5::settings_for_game() ? " \xC2\xB7 for " + rpcs3ps5::settings_game_title() : " \xC2\xB7 all games")}; } if (!rpcs3ps5::settings_for_game()) return {"Vibration", kVibration[rpcs3ps5::vibration_level() & 3] + std::string(" \xC2\xB7 how strongly the controller rumbles")}; if (index == kTabCount) return {"Recommended Settings", "RPCS3's settings for this game, tuned for the PS5"}; return {"Use the global settings", rpcs3ps5::core_settings_custom(rpcs3ps5::settings_game_id()) ? "This game has its own settings: Cross removes them" : "This game uses the global settings"}; case kGame: if (!game_count()) return {"No games yet", "Copy PS3 games into " + rpcs3ps5::Library::root() + "/games/, then press Square"}; { const rpcs3ps5::Game &game = games()[static_cast(index)]; return {game.title, game.title_id + (game.disc ? " \xC2\xB7 disc" : " \xC2\xB7 installed")}; } case kInstall: { static const char *kLabels[] = {"Install PS3 System Software", "Install Package Files", "Install Licences (RAP)"}; std::string detail; if (index == 0) detail = lib.firmware_installed() ? "Installed: " + lib.firmware_version() + (lib.firmware_pup_present() ? " \xC2\xB7 Cross reinstalls" : "") : (lib.firmware_pup_present() ? std::string("PS3UPDAT.PUP found: Cross installs it") : "Put PS3UPDAT.PUP in " + rpcs3ps5::Library::root()); else if (index == 1) detail = "PKG files in " + rpcs3ps5::Library::root() + "/packages/"; else detail = "RAP files in " + rpcs3ps5::Library::root() + "/exdata/"; return {kLabels[index], detail}; } default: { switch (index) { case 0: return {"System Software", lib.firmware_installed() ? "PS3 " + lib.firmware_version() : "Not installed"}; case 1: return {"Games", std::to_string(lib.games().size()) + " found"}; case 2: return {"Data Folder", rpcs3ps5::Library::root() + " (FTP: /data/homebrew/PPSA99303/rpcs3)"}; default: return {"Emulator", std::string("RPCS3 \xC2\xB7 ") + rpcs3ps5::core_status() + " \xC2\xB7 your own console only (GPL-2.0)"}; } } } } // ---- every setting: the cache, the settings page, the recommended settings dialog ---- // The settings of the scope the Settings column shows (global, or the game whose settings are open) const std::vector &settings() const { const std::string scope = rpcs3ps5::settings_for_game() ? rpcs3ps5::settings_game_id() : std::string(); if (!settings_valid_ || settings_scope_ != scope) { settings_ = rpcs3ps5::core_settings_all(scope); settings_scope_ = scope; settings_valid_ = true; } return settings_; } void invalidate_settings() { settings_valid_ = false; } static bool in_tab(const rpcs3ps5::Setting &setting, int tab) { for (const char *section : kTabs[tab].sections) if (section && setting.section == section) return true; return false; } int tab_setting_count(int tab) const { int count = 0; for (const auto &setting : settings()) count += in_tab(setting, tab); return count; } int scope_game_index() const { for (int i = 0; i < game_count(); ++i) if (games()[static_cast(i)].title_id == rpcs3ps5::settings_game_id()) return i; return item_[kGame]; } void open_page(int tab) { page_open_ = true; page_tab_ = tab; page_rows_.clear(); const auto &all = settings(); for (std::size_t i = 0; i < all.size(); ++i) if (in_tab(all[i], tab)) page_rows_.push_back(i); page_sel_ = 0; page_pos_.snap(0.0f); } // The next (dir +1) or previous (-1) value of a setting static std::string step_value(const rpcs3ps5::Setting &setting, int dir) { switch (setting.kind) { case 'b': return setting.value == "true" ? "false" : "true"; case 'e': { if (setting.choices.empty()) return setting.value; const int n = static_cast(setting.choices.size()); int at = 0; for (int i = 0; i < n; ++i) if (setting.choices[static_cast(i)] == setting.value) at = i; return setting.choices[static_cast(((at + dir) % n + n) % n)]; } case 'i': { if (setting.choices.size() != 2) return setting.value; const long long lo = std::strtoll(setting.choices[0].c_str(), nullptr, 10); const long long hi = std::strtoll(setting.choices[1].c_str(), nullptr, 10); const long long range = hi - lo; long long step = range <= 100 ? 1 : range <= 1000 ? 5 : range <= 10000 ? 50 : range / 100; if (setting.label.find("Resolution Scale") != std::string::npos) step = 25; const long long value = std::clamp(std::strtoll(setting.value.c_str(), nullptr, 10) + dir * step, lo, hi); return std::to_string(value); } case 'f': { if (setting.choices.size() != 2) return setting.value; const double lo = std::strtod(setting.choices[0].c_str(), nullptr), hi = std::strtod(setting.choices[1].c_str(), nullptr); const double step = (hi - lo) / 100.0; char text[32]; std::snprintf(text, sizeof(text), "%g", std::clamp(std::strtod(setting.value.c_str(), nullptr) + dir * step, lo, hi)); return text; } default: return setting.value; } } void change_setting(std::size_t index, const std::string &value, app::Feedback &feedback) { const rpcs3ps5::Setting setting = settings()[index]; const std::string scope = rpcs3ps5::settings_for_game() ? rpcs3ps5::settings_game_id() : std::string(); if (setting.locked || setting.kind == 's') { rpcs3ps5::set_status(setting.label + (setting.locked ? " is fixed on the PS5" : " is changed in the config file")); feedback.play(audio::Cue::error, 1.0f, 0.0f, 0.5f); return; } if (rpcs3ps5::core_setting_set(scope, setting.path, value)) { invalidate_settings(); rpcs3ps5::set_status(setting.label + ": " + value + (scope.empty() ? " \xC2\xB7 all games" : " \xC2\xB7 " + rpcs3ps5::settings_game_title())); feedback.play(audio::Cue::toggle); } else { rpcs3ps5::set_status(setting.label + ": " + value + " was not accepted"); feedback.play(audio::Cue::error, 1.0f, 0.0f, 0.5f); } } void update_page(const InputFrame &input, app::Feedback &feedback) { const int count = static_cast(page_rows_.size()); if (input.nav == Direction::up || input.nav == Direction::down || input.is_pressed(Action::page_prev) || input.is_pressed(Action::page_next)) { const int move = input.nav == Direction::up ? -1 : input.nav == Direction::down ? 1 : input.is_pressed(Action::page_prev) ? -10 : 10; const int next = std::clamp(page_sel_ + move, 0, std::max(count - 1, 0)); if (next != page_sel_) { page_sel_ = next; feedback.play(audio::Cue::focus, 1.0f, 0.3f); } } if (count && (input.nav == Direction::left || input.nav == Direction::right || input.is_pressed(Action::confirm))) { const std::size_t index = page_rows_[static_cast(page_sel_)]; const int dir = input.nav == Direction::left ? -1 : 1; change_setting(index, step_value(settings()[index], dir), feedback); } if (count && input.is_pressed(Action::west)) { const std::size_t index = page_rows_[static_cast(page_sel_)]; change_setting(index, settings()[index].def, feedback); } if (input.is_pressed(Action::back)) { page_open_ = false; feedback.play(audio::Cue::modal_close); } } enum class Dialog { prompt, // a game was added: use its recommended settings? details, // the changes, from the prompt manage, // Triangle > Recommended Settings }; void open_dialog(int game, Dialog mode) { if (game < 0 || game >= game_count()) return; dialog_open_ = true; dialog_mode_ = mode; dialog_game_ = game; dialog_button_ = 0; dialog_scroll_ = 0; recommendation_ = rpcs3ps5::recommendation_for(games()[static_cast(game)]); } std::vector dialog_buttons() const { switch (dialog_mode_) { case Dialog::prompt: return {"Use recommended settings", "More details", "No thanks"}; case Dialog::details: return {"Use these settings", "Back"}; default: return {"Use recommended settings", "Use the global settings", "Close"}; } } void update_dialog(const InputFrame &input, app::Feedback &feedback) { const int buttons = static_cast(dialog_buttons().size()); if (input.nav == Direction::left || input.nav == Direction::right) { const int next = std::clamp(dialog_button_ + (input.nav == Direction::right ? 1 : -1), 0, buttons - 1); if (next != dialog_button_) { dialog_button_ = next; feedback.play(audio::Cue::focus, 1.0f, 0.3f); } } if (input.nav == Direction::up) dialog_scroll_ = std::max(0, dialog_scroll_ - 1); if (input.nav == Direction::down) dialog_scroll_ = std::min(dialog_scroll_ + 1, std::max(0, static_cast(recommendation_.changes.size()) - 1)); const rpcs3ps5::Game &game = games()[static_cast(dialog_game_)]; const auto apply = [&] { const bool ok = rpcs3ps5::apply_recommendation(game); invalidate_settings(); rpcs3ps5::set_status(game.title + (ok ? ": recommended settings in use" : ": recommended settings in use (some were not accepted)")); feedback.play(audio::Cue::launch); dialog_open_ = false; }; if (input.is_pressed(Action::confirm)) { switch (dialog_mode_) { case Dialog::prompt: if (dialog_button_ == 0) { apply(); } else if (dialog_button_ == 1) { dialog_mode_ = Dialog::details; dialog_button_ = 0; feedback.play(audio::Cue::select); } else { rpcs3ps5::mark_offered(game); rpcs3ps5::set_status(game.title + " keeps the global settings \xC2\xB7 Triangle > Recommended Settings to use them later"); feedback.play(audio::Cue::modal_close); dialog_open_ = false; } break; case Dialog::details: if (dialog_button_ == 0) { apply(); } else { dialog_mode_ = Dialog::prompt; dialog_button_ = 1; feedback.play(audio::Cue::back); } break; case Dialog::manage: if (dialog_button_ == 0) { apply(); } else if (dialog_button_ == 1) { rpcs3ps5::revert_to_global(game); invalidate_settings(); rpcs3ps5::set_status(game.title + " uses the global settings again"); feedback.play(audio::Cue::toggle); dialog_open_ = false; } else { feedback.play(audio::Cue::modal_close); dialog_open_ = false; } break; } } if (input.is_pressed(Action::back)) { if (dialog_mode_ == Dialog::details) { dialog_mode_ = Dialog::prompt; dialog_button_ = 1; } else { if (dialog_mode_ == Dialog::prompt) rpcs3ps5::mark_offered(game); dialog_open_ = false; } feedback.play(audio::Cue::modal_close); } } // A game the library lists that was never offered its recommended settings: offered now, one at a time void offer_new_games() { if (dialog_open_ || page_open_ || options_open_ || age_ < 1.0f || rpcs3ps5::tiles_busy()) return; if (game_count() != offered_scan_count_) { offered_scan_count_ = game_count(); pending_offers_ = rpcs3ps5::unoffered_games(games()); } if (pending_offers_.empty()) return; const std::size_t game = pending_offers_.front(); pending_offers_.erase(pending_offers_.begin()); if (game < games().size()) open_dialog(static_cast(game), Dialog::prompt); } // ---- input ---- void refuse(app::Feedback &feedback, float direction) { feedback.play(audio::Cue::error, 1.0f, 0.0f, 0.6f); nudge_direction_ = direction; nudge_.trigger(); } void update_bar(const InputFrame &input, app::Feedback &feedback) { if (input.nav == Direction::left || input.nav == Direction::right || input.is_pressed(Action::page_prev) || input.is_pressed(Action::page_next)) { const bool right = input.nav == Direction::right || input.is_pressed(Action::page_next); const int next = category_ + (right ? 1 : -1); if (next >= 0 && next < kCategoryCount) { // Leaving a game's own settings goes back to the global ones if (category_ == kSettings && rpcs3ps5::settings_for_game()) rpcs3ps5::settings_open("", ""); category_ = next; feedback.play(audio::Cue::tab); } else if (!input.nav_repeat) { refuse(feedback, right ? 1.0f : -1.0f); } } if (input.nav == Direction::up || input.nav == Direction::down) { const int next = item_[category_] + (input.nav == Direction::down ? 1 : -1); if (next >= 0 && next < item_count(category_)) { item_[category_] = next; feedback.play(audio::Cue::focus); } else if (!input.nav_repeat) { feedback.play(audio::Cue::error, 1.0f, 0.0f, 0.4f); } } if (input.is_pressed(Action::west)) { rpcs3ps5::request(rpcs3ps5::Request::rescan); feedback.play(audio::Cue::select); } if (input.is_pressed(Action::back) && category_ == kSettings && rpcs3ps5::settings_for_game()) { // A game's settings: Circle goes back to the game rpcs3ps5::settings_open("", ""); category_ = kGame; feedback.play(audio::Cue::back); } if (input.is_pressed(Action::north) && category_ == kGame && game_count()) { options_open_ = true; option_ = 0; option_pos_.snap(0.0f); feedback.play(audio::Cue::modal_open); } if (input.is_pressed(Action::confirm)) { const int index = item_[category_]; switch (category_) { case kGame: if (game_count()) { feedback.play(audio::Cue::launch); feedback.rumble(0.5f, 0.15f); rpcs3ps5::request(rpcs3ps5::Request::boot, index); } else { refuse(feedback, 0.0f); } break; case kInstall: { static constexpr rpcs3ps5::Request kInstall[] = {rpcs3ps5::Request::install_firmware, rpcs3ps5::Request::install_packages, rpcs3ps5::Request::install_licenses}; feedback.play(audio::Cue::select); rpcs3ps5::request(kInstall[index]); break; } case kSettings: if (index < kTabCount) { open_page(index); feedback.play(audio::Cue::modal_open); } else if (!rpcs3ps5::settings_for_game()) { rpcs3ps5::set_vibration_level((rpcs3ps5::vibration_level() + 1) % 4); rpcs3ps5::set_status(std::string("Vibration: ") + kVibration[rpcs3ps5::vibration_level() & 3]); feedback.play(audio::Cue::toggle); } else if (index == kTabCount) { open_dialog(scope_game_index(), Dialog::manage); feedback.play(audio::Cue::modal_open); } else { rpcs3ps5::core_settings_clear(rpcs3ps5::settings_game_id()); invalidate_settings(); rpcs3ps5::set_status(rpcs3ps5::settings_game_title() + " uses the global settings"); feedback.play(audio::Cue::toggle); } break; default: break; } } } // The options panel's rows: a game's options, or the delete confirmation enum Option { kStart, kGameSettings, kRecommended, kTile, kDelete, kClose, kDeleteKeepSaves, kDeleteWithSaves, kCancel, }; std::vector