mirror of
https://github.com/ARMSX2/ARMSX2.git
synced 2026-08-24 16:50:16 -07:00
Progress could only ever be shown for a game that had been loaded, because
the core is only able to report on the game it currently has: set sizes for
everything else are not on the device at all. So a game you own but have
never launched showed nothing, which is most of a library.
Fetch it from RetroAchievements instead — and this needs two requests for
an entire library, not one per game:
- API_GetGameList (i=21 PS2, f=1, h=1) returns every PS2 set's size
together with its MD5 hashes. Cached on disk for a week; set sizes
change on the order of months.
- API_GetUserCompletionProgress returns NumAwarded / NumAwardedHardcore
/ MaxPossible per game id, paginated 500 at a time.
Matching is by disc hash, the only reliable key: RA carries no PS2 serials
and title matching would confuse regional variants and multi-disc sets.
Achievements::GetGameHashForImage computes it without booting, mirroring
the game-list scanner's open/detect/read/close over CDVDapi_Iso. It refuses
while a VM is valid — CDVD is a global, so repointing it mid-session would
swap the disc out from under the running game.
A game with a set and no unlocks now correctly reads 0/N rather than
nothing. Automatic syncs are limited to one a day and only run once a web
API key is present; the RA panel has a manual "Sync library" button that
ignores the interval. The web API key is a separate credential from the
login token, so it is entered once in the panel and trimmed on the way in
(the site's copy button brings whitespace, which would otherwise look like
the feature silently failing).
Also captures progress on every RA sound, so the figure moves as
achievements are earned instead of waiting for the slow poll.
Requested by Isshin.
4407 lines
152 KiB
C++
4407 lines
152 KiB
C++
// SPDX-FileCopyrightText: 2002-2026 PCSX2 Dev Team
|
|
// SPDX-License-Identifier: GPL-3.0+
|
|
|
|
#include "Achievements.h"
|
|
#include "BuildVersion.h"
|
|
#include "CDVD/CDVD.h"
|
|
#include "CDVD/CDVDcommon.h"
|
|
#include "Elfheader.h"
|
|
#include "Host.h"
|
|
#include "GS/Renderers/Common/GSTexture.h"
|
|
#include "ImGui/FullscreenUI.h"
|
|
#include "ImGui/ImGuiFullscreen.h"
|
|
#include "ImGui/ImGuiManager.h"
|
|
#include "IopMem.h"
|
|
#include "MTGS.h"
|
|
#include "Memory.h"
|
|
#include "SaveState.h"
|
|
#include "VMManager.h"
|
|
#include "vtlb.h"
|
|
|
|
#include "common/Assertions.h"
|
|
#include "common/Console.h"
|
|
#include "common/Error.h"
|
|
#include "common/FileSystem.h"
|
|
#include "common/HTTPDownloader.h"
|
|
#include "common/HeapArray.h"
|
|
#include "common/MD5Digest.h"
|
|
#include "common/Path.h"
|
|
#include "common/ScopedGuard.h"
|
|
#include "common/SettingsInterface.h"
|
|
#include "common/SmallString.h"
|
|
#include "common/StringUtil.h"
|
|
#include "common/Timer.h"
|
|
|
|
#include "IconsPromptFont.h"
|
|
#include "fmt/format.h"
|
|
#include "rc_client.h"
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cstdarg>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <functional>
|
|
#include <limits>
|
|
#include <map>
|
|
#include <optional>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#ifdef ENABLE_RAINTEGRATION
|
|
// RA_Interface ends up including windows.h, with its silly macros.
|
|
#include "common/RedtapeWindows.h"
|
|
#include "RA_Interface.h"
|
|
#endif
|
|
|
|
namespace Achievements
|
|
{
|
|
static constexpr u32 LEADERBOARD_NEARBY_ENTRIES_TO_FETCH = 10;
|
|
static constexpr u32 LEADERBOARD_ALL_FETCH_SIZE = 20;
|
|
|
|
static constexpr float LOGIN_NOTIFICATION_TIME = 5.0f;
|
|
static constexpr float ACHIEVEMENT_SUMMARY_NOTIFICATION_TIME = 5.0f;
|
|
static constexpr float GAME_COMPLETE_NOTIFICATION_TIME = 20.0f;
|
|
static constexpr float LEADERBOARD_STARTED_NOTIFICATION_TIME = 3.0f;
|
|
static constexpr float LEADERBOARD_FAILED_NOTIFICATION_TIME = 3.0f;
|
|
|
|
static constexpr const char* DEFAULT_INFO_SOUND_NAME = "sounds/achievements/message.wav";
|
|
static constexpr const char* DEFAULT_UNLOCK_SOUND_NAME = "sounds/achievements/unlock.wav";
|
|
static constexpr const char* DEFAULT_LBSUBMIT_SOUND_NAME = "sounds/achievements/lbsubmit.wav";
|
|
|
|
static constexpr float INDICATOR_FADE_IN_TIME = 0.1f;
|
|
static constexpr float INDICATOR_FADE_OUT_TIME = 0.5f;
|
|
|
|
static constexpr size_t URL_BUFFER_SIZE = 256;
|
|
|
|
// Some API calls are really slow. Set a longer timeout.
|
|
static constexpr float SERVER_CALL_TIMEOUT = 60.0f;
|
|
|
|
// Chrome uses 10 server calls per domain, seems reasonable.
|
|
static constexpr u32 MAX_CONCURRENT_SERVER_CALLS = 10;
|
|
|
|
namespace
|
|
{
|
|
struct LoginWithPasswordParameters
|
|
{
|
|
const char* username;
|
|
Error* error;
|
|
rc_client_async_handle_t* request;
|
|
bool result;
|
|
};
|
|
struct LeaderboardTrackerIndicator
|
|
{
|
|
u32 tracker_id;
|
|
std::string text;
|
|
Common::Timer show_hide_time;
|
|
bool active;
|
|
};
|
|
|
|
struct AchievementChallengeIndicator
|
|
{
|
|
const rc_client_achievement_t* achievement;
|
|
std::string badge_path;
|
|
Common::Timer show_hide_time;
|
|
bool active;
|
|
};
|
|
|
|
struct AchievementProgressIndicator
|
|
{
|
|
const rc_client_achievement_t* achievement;
|
|
std::string badge_path;
|
|
Common::Timer show_hide_time;
|
|
bool active;
|
|
};
|
|
} // namespace
|
|
|
|
static void ReportError(const std::string_view sv);
|
|
template <typename... T>
|
|
static void ReportFmtError(fmt::format_string<T...> fmt, T&&... args);
|
|
template <typename... T>
|
|
static void ReportRCError(int err, fmt::format_string<T...> fmt, T&&... args);
|
|
static void EnsureCacheDirectoriesExist();
|
|
static void ClearGameInfo();
|
|
static void ClearGameHash();
|
|
static void BeginLoadingScreen(const char* text, bool* was_running_idle);
|
|
static void EndLoadingScreen(bool was_running_idle);
|
|
static std::string_view GetELFNameForHash(const std::string& elf_path);
|
|
static std::string GetGameHash(const std::string& elf_path);
|
|
static void SetHardcoreMode(bool enabled, bool force_display_message);
|
|
static bool IsLoggedInOrLoggingIn();
|
|
static bool CanEnableHardcoreMode();
|
|
static void ShowLoginSuccess(const rc_client_t* client);
|
|
static void IdentifyGame(u32 disc_crc, u32 crc);
|
|
static void BeginLoadGame();
|
|
static void UpdateGameSummary();
|
|
static void DownloadImage(std::string url, std::string cache_filename);
|
|
|
|
// Size of the EE physical memory exposed to RetroAchievements.
|
|
static u32 GetExposedEEMemorySize();
|
|
|
|
static bool CreateClient(rc_client_t** client, std::unique_ptr<HTTPDownloader>* http);
|
|
static void DestroyClient(rc_client_t** client, std::unique_ptr<HTTPDownloader>* http);
|
|
static void ClientMessageCallback(const char* message, const rc_client_t* client);
|
|
static uint32_t ClientReadMemory(uint32_t address, uint8_t* buffer, uint32_t num_bytes, rc_client_t* client);
|
|
static void ClientServerCall(
|
|
const rc_api_request_t* request, rc_client_server_callback_t callback, void* callback_data, rc_client_t* client);
|
|
|
|
static void ClientEventHandler(const rc_client_event_t* event, rc_client_t* client);
|
|
static void HandleResetEvent(const rc_client_event_t* event);
|
|
static void HandleUnlockEvent(const rc_client_event_t* event);
|
|
static void HandleGameCompleteEvent(const rc_client_event_t* event);
|
|
static void HandleSubsetCompleteEvent(const rc_client_event_t* event);
|
|
static void HandleLeaderboardStartedEvent(const rc_client_event_t* event);
|
|
static void HandleLeaderboardFailedEvent(const rc_client_event_t* event);
|
|
static void HandleLeaderboardSubmittedEvent(const rc_client_event_t* event);
|
|
static void HandleLeaderboardScoreboardEvent(const rc_client_event_t* event);
|
|
static void HandleLeaderboardTrackerShowEvent(const rc_client_event_t* event);
|
|
static void HandleLeaderboardTrackerHideEvent(const rc_client_event_t* event);
|
|
static void HandleLeaderboardTrackerUpdateEvent(const rc_client_event_t* event);
|
|
static void HandleAchievementChallengeIndicatorShowEvent(const rc_client_event_t* event);
|
|
static void HandleAchievementChallengeIndicatorHideEvent(const rc_client_event_t* event);
|
|
static void HandleAchievementProgressIndicatorShowEvent(const rc_client_event_t* event);
|
|
static void HandleAchievementProgressIndicatorHideEvent(const rc_client_event_t* event);
|
|
static void HandleAchievementProgressIndicatorUpdateEvent(const rc_client_event_t* event);
|
|
static void HandleServerErrorEvent(const rc_client_event_t* event);
|
|
static void HandleServerDisconnectedEvent(const rc_client_event_t* event);
|
|
static void HandleServerReconnectedEvent(const rc_client_event_t* event);
|
|
|
|
static void ClientLoginWithTokenCallback(int result, const char* error_message, rc_client_t* client, void* userdata);
|
|
static void ClientLoginWithPasswordCallback(int result, const char* error_message, rc_client_t* client, void* userdata);
|
|
static void ClientLoadGameCallback(int result, const char* error_message, rc_client_t* client, void* userdata);
|
|
|
|
static void DisplayHardcoreDeferredMessage();
|
|
static void DisplayAchievementSummary();
|
|
static void UpdateRichPresence(std::unique_lock<std::recursive_mutex>& lock);
|
|
static void UpdateNotificationPosition();
|
|
|
|
static std::string GetAchievementBadgePath(const rc_client_achievement_t* achievement, int state);
|
|
static std::string GetSubsetBadgePath(const rc_client_subset_t* subset);
|
|
static std::string GetUserBadgePath(const std::string_view username);
|
|
static std::string GetLeaderboardUserBadgePath(const rc_client_leaderboard_entry_t* entry);
|
|
|
|
static void DrawAchievement(const rc_client_achievement_t* cheevo);
|
|
static void DrawLeaderboardListEntry(const rc_client_leaderboard_t* lboard);
|
|
static void DrawLeaderboardEntry(const rc_client_leaderboard_entry_t& entry, bool is_self, float rank_column_width,
|
|
float name_column_width, float time_column_width, float column_spacing);
|
|
static void OpenSubset(const rc_client_subset_t* subset);
|
|
static bool DrawSubsetSidebar(float sidebar_width, bool& sidebar_has_focus, bool& focus_sidebar, bool leaderboards_only);
|
|
static void DrawSubsetSidebarFooter(bool sidebar_has_focus);
|
|
static void OpenLeaderboard(const rc_client_leaderboard_t* lboard);
|
|
static void LeaderboardFetchNearbyCallback(
|
|
int result, const char* error_message, rc_client_leaderboard_entry_list_t* list, rc_client_t* client, void* callback_userdata);
|
|
static void LeaderboardFetchAllCallback(
|
|
int result, const char* error_message, rc_client_leaderboard_entry_list_t* list, rc_client_t* client, void* callback_userdata);
|
|
static void FetchNextLeaderboardEntries();
|
|
static void CloseLeaderboard();
|
|
static void CloseSubset();
|
|
|
|
static bool s_hardcore_mode = false;
|
|
|
|
#ifdef ENABLE_RAINTEGRATION
|
|
static bool s_using_raintegration = false;
|
|
#endif
|
|
|
|
static std::recursive_mutex s_achievements_mutex;
|
|
static rc_client_t* s_client;
|
|
static std::string s_image_directory;
|
|
static std::unique_ptr<HTTPDownloader> s_http_downloader;
|
|
|
|
static std::string s_game_hash;
|
|
static std::string s_game_title;
|
|
static std::string s_game_icon;
|
|
static std::string s_game_icon_url;
|
|
static u32 s_game_crc;
|
|
static rc_client_user_game_summary_t s_game_summary;
|
|
static u32 s_game_id = 0;
|
|
|
|
static bool s_has_achievements = false;
|
|
static bool s_has_leaderboards = false;
|
|
static bool s_has_rich_presence = false;
|
|
static std::string s_rich_presence_string;
|
|
static Common::Timer s_rich_presence_poll_time;
|
|
|
|
static rc_client_async_handle_t* s_login_request;
|
|
static rc_client_async_handle_t* s_load_game_request;
|
|
|
|
static rc_client_achievement_list_t* s_achievement_list;
|
|
static rc_client_subset_list_t* s_subset_list;
|
|
static rc_client_leaderboard_list_t* s_leaderboard_list;
|
|
static std::vector<std::pair<const void*, std::string>> s_achievement_badge_paths;
|
|
static const rc_client_leaderboard_t* s_open_leaderboard = nullptr;
|
|
static const rc_client_subset_t* s_open_subset = nullptr;
|
|
static rc_client_async_handle_t* s_leaderboard_fetch_handle = nullptr;
|
|
static std::vector<rc_client_leaderboard_entry_list_t*> s_leaderboard_entry_lists;
|
|
static rc_client_leaderboard_entry_list_t* s_leaderboard_nearby_entries;
|
|
static std::vector<std::pair<const rc_client_leaderboard_entry_t*, std::string>> s_leaderboard_user_icon_paths;
|
|
static bool s_is_showing_all_leaderboard_entries = false;
|
|
static u32 s_last_selected_leaderboard_id = 0;
|
|
static bool s_has_last_selected_leaderboard = false;
|
|
static bool s_restore_leaderboard_focus = false;
|
|
static float s_leaderboard_list_scroll = 0.0f;
|
|
static bool s_restore_leaderboard_scroll = false;
|
|
|
|
static std::map<std::pair<u32, u32>, bool> s_achievement_buckets_collapsed;
|
|
static std::map<std::pair<u32, u8>, bool> s_leaderboard_buckets_collapsed;
|
|
|
|
static std::vector<LeaderboardTrackerIndicator> s_active_leaderboard_trackers;
|
|
static std::vector<AchievementChallengeIndicator> s_active_challenge_indicators;
|
|
static std::optional<AchievementProgressIndicator> s_active_progress_indicator;
|
|
} // namespace Achievements
|
|
|
|
|
|
std::unique_lock<std::recursive_mutex> Achievements::GetLock()
|
|
{
|
|
return std::unique_lock(s_achievements_mutex);
|
|
}
|
|
|
|
void Achievements::BeginLoadingScreen(const char* text, bool* was_running_idle)
|
|
{
|
|
// FullscreenUI's progress dialog is offscreen on platforms with their own UI (iOS),
|
|
// and initializing it here would spin up its per-frame render loop for nothing.
|
|
if (Host::HasNativeAchievementNotifications())
|
|
return;
|
|
|
|
MTGS::RunOnGSThread(&ImGuiManager::InitializeFullscreenUI);
|
|
ImGuiFullscreen::OpenProgressDialog("achievements_loading", text, 0, 0, 0);
|
|
}
|
|
|
|
void Achievements::EndLoadingScreen(bool was_running_idle)
|
|
{
|
|
ImGuiFullscreen::CloseProgressDialog("achievements_loading");
|
|
}
|
|
|
|
void Achievements::ReportError(const std::string_view sv)
|
|
{
|
|
std::string error = fmt::format("Achievements error: {}", sv);
|
|
Console.Error(error);
|
|
Host::AddOSDMessage(std::move(error), Host::OSD_CRITICAL_ERROR_DURATION);
|
|
}
|
|
|
|
template <typename... T>
|
|
void Achievements::ReportFmtError(fmt::format_string<T...> fmt, T&&... args)
|
|
{
|
|
SmallString str;
|
|
fmt::vformat_to(std::back_inserter(str), fmt, fmt::make_format_args(args...));
|
|
ReportError(str);
|
|
}
|
|
|
|
template <typename... T>
|
|
void Achievements::ReportRCError(int err, fmt::format_string<T...> fmt, T&&... args)
|
|
{
|
|
SmallString str;
|
|
fmt::vformat_to(std::back_inserter(str), fmt, fmt::make_format_args(args...));
|
|
str.append_format("{} ({})", rc_error_str(err), err);
|
|
ReportError(str);
|
|
}
|
|
|
|
std::string_view Achievements::GetELFNameForHash(const std::string& elf_path)
|
|
{
|
|
std::string::size_type start = elf_path.rfind('\\');
|
|
if (start == std::string::npos)
|
|
start = 0;
|
|
else
|
|
start++; // skip backslash
|
|
|
|
std::string::size_type end = elf_path.rfind(';');
|
|
if (end == std::string::npos)
|
|
end = elf_path.size();
|
|
|
|
if (end < start)
|
|
end = start;
|
|
|
|
return std::string_view(elf_path).substr(start, end - start);
|
|
}
|
|
|
|
std::string Achievements::GetGameHash(const std::string& elf_path)
|
|
{
|
|
// this.. really shouldn't be invalid
|
|
const std::string_view name_for_hash = GetELFNameForHash(elf_path);
|
|
if (name_for_hash.empty())
|
|
return {};
|
|
|
|
ElfObject elfo;
|
|
Error error;
|
|
if (!cdvdLoadElf(&elfo, elf_path, false, &error))
|
|
{
|
|
Console.Error(fmt::format("Achievements: Failed to read ELF '{}' on disc: {}", elf_path, error.GetDescription()));
|
|
return {};
|
|
}
|
|
|
|
// See rcheevos hash.c - rc_hash_ps2().
|
|
const u32 MAX_HASH_SIZE = 64 * 1024 * 1024;
|
|
const u32 hash_size = std::min<u32>(elfo.GetSize(), MAX_HASH_SIZE);
|
|
pxAssert(hash_size <= elfo.GetSize());
|
|
|
|
MD5Digest digest;
|
|
if (!name_for_hash.empty())
|
|
digest.Update(name_for_hash.data(), static_cast<u32>(name_for_hash.size()));
|
|
if (hash_size > 0)
|
|
digest.Update(elfo.GetData().data(), hash_size);
|
|
|
|
u8 hash[16];
|
|
digest.Final(hash);
|
|
|
|
const std::string hash_str =
|
|
StringUtil::StdStringFromFormat("%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x", hash[0], hash[1], hash[2],
|
|
hash[3], hash[4], hash[5], hash[6], hash[7], hash[8], hash[9], hash[10], hash[11], hash[12], hash[13], hash[14], hash[15]);
|
|
|
|
Console.WriteLn(fmt::format("Hash for '{}' ({} bytes, {} bytes hashed): {}", name_for_hash, elfo.GetSize(), hash_size, hash_str));
|
|
return hash_str;
|
|
}
|
|
|
|
std::string Achievements::GetGameHashForImage(const std::string& image_path)
|
|
{
|
|
// CDVD is a global, not thread-local (see the same note in GameList::GetIsoSerialAndCRC), so
|
|
// repointing it while a game is running would swap that game's disc out from under it. Refuse
|
|
// rather than corrupt a live session.
|
|
if (VMManager::HasValidVM())
|
|
{
|
|
Console.Warning("Achievements: refusing to hash '%s' while a VM is running.", image_path.c_str());
|
|
return {};
|
|
}
|
|
|
|
Error error;
|
|
CDVD = &CDVDapi_Iso;
|
|
if (!CDVD->open(image_path, &error))
|
|
{
|
|
Console.Error(fmt::format("Achievements: CDVD open of '{}' failed: {}", image_path, error.GetDescription()));
|
|
return {};
|
|
}
|
|
|
|
// Mirrors the game-list scanner: detect, read the disc info, hash the boot ELF, close. The hash
|
|
// is of the ELF name plus its contents, so it needs the ELF path from SYSTEM.CNF rather than the
|
|
// image path — cdvdGetDiscInfo hands that back.
|
|
DoCDVDdetectDiskType();
|
|
std::string elf_path;
|
|
cdvdGetDiscInfo(nullptr, &elf_path, nullptr, nullptr, nullptr);
|
|
std::string hash = elf_path.empty() ? std::string() : GetGameHash(elf_path);
|
|
DoCDVDclose();
|
|
return hash;
|
|
}
|
|
|
|
void Achievements::DownloadImage(std::string url, std::string cache_filename)
|
|
{
|
|
auto callback = [cache_filename = std::move(cache_filename)](s32 status_code, const std::string& content_type, HTTPDownloader::Request::Data data) {
|
|
if (status_code != HTTPDownloader::HTTP_STATUS_OK)
|
|
return;
|
|
|
|
if (!FileSystem::WriteBinaryFile(cache_filename.c_str(), data.data(), data.size()))
|
|
{
|
|
Console.Error("Failed to write badge image to '%s'", cache_filename.c_str());
|
|
return;
|
|
}
|
|
|
|
ImGuiFullscreen::InvalidateCachedTexture(cache_filename);
|
|
};
|
|
|
|
s_http_downloader->CreateRequest(std::move(url), std::move(callback));
|
|
}
|
|
|
|
bool Achievements::IsActive()
|
|
{
|
|
#ifdef ENABLE_RAINTEGRATION
|
|
return (s_client != nullptr) || s_using_raintegration;
|
|
#else
|
|
return (s_client != nullptr);
|
|
#endif
|
|
}
|
|
|
|
bool Achievements::IsHardcoreModeActive()
|
|
{
|
|
#ifdef ENABLE_RAINTEGRATION
|
|
if (IsUsingRAIntegration())
|
|
return RA_HardcoreModeIsActive() != 0;
|
|
#endif
|
|
|
|
return s_hardcore_mode;
|
|
}
|
|
|
|
bool Achievements::HasActiveGame()
|
|
{
|
|
return s_game_id != 0;
|
|
}
|
|
|
|
u32 Achievements::GetGameID()
|
|
{
|
|
return s_game_id;
|
|
}
|
|
|
|
bool Achievements::HasAchievementsOrLeaderboards()
|
|
{
|
|
return s_has_achievements || s_has_leaderboards;
|
|
}
|
|
|
|
bool Achievements::HasAchievements()
|
|
{
|
|
return s_has_achievements;
|
|
}
|
|
|
|
std::string Achievements::GetAchievementsAsJSON()
|
|
{
|
|
// JSON-quote a string into the buffer, escaping the bare minimum so
|
|
// org.json on the Java side can parse the payload. We don't expect
|
|
// achievement titles/descriptions to contain control chars beyond \n,
|
|
// but escape them defensively. Characters > 0x7F are passed through —
|
|
// the JSON spec allows raw UTF-8 in string contents.
|
|
auto append_json_string = [](std::string& dst, const char* src) {
|
|
dst += '"';
|
|
if (src)
|
|
{
|
|
for (const char* p = src; *p; ++p)
|
|
{
|
|
const unsigned char c = static_cast<unsigned char>(*p);
|
|
switch (c)
|
|
{
|
|
case '"': dst += "\\\""; break;
|
|
case '\\': dst += "\\\\"; break;
|
|
case '\n': dst += "\\n"; break;
|
|
case '\r': dst += "\\r"; break;
|
|
case '\t': dst += "\\t"; break;
|
|
default:
|
|
if (c < 0x20)
|
|
{
|
|
char esc[8];
|
|
std::snprintf(esc, sizeof(esc), "\\u%04x", c);
|
|
dst += esc;
|
|
}
|
|
else
|
|
{
|
|
dst += static_cast<char>(c);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
dst += '"';
|
|
};
|
|
|
|
std::string out;
|
|
out.reserve(4096);
|
|
out += '{';
|
|
|
|
auto lock = GetLock();
|
|
|
|
const bool active = HasActiveGame() && s_has_achievements;
|
|
|
|
// `s_client` is the persistent rc_client created by Achievements::Initialize,
|
|
// which only runs when a VM is initialized AND EmuConfig.Achievements.Enabled.
|
|
// On Android the user typically logs in BEFORE loading a game, via the
|
|
// overlay's right-side panel — that path uses a TEMPORARY client which is
|
|
// destroyed when Login returns. So `rc_client_get_user_info(s_client)` reports
|
|
// "not logged in" even though the auth token was just written to the secrets
|
|
// layer. Fall back to the persisted Username / Token to detect post-login
|
|
// state. Token lives in the LAYER_SECRETS in-memory store; Username in BASE.
|
|
const rc_client_user_t* user = s_client ? rc_client_get_user_info(s_client) : nullptr;
|
|
std::string display_name;
|
|
bool logged_in = false;
|
|
if (user && user->display_name)
|
|
{
|
|
display_name = user->display_name;
|
|
logged_in = true;
|
|
}
|
|
else
|
|
{
|
|
const std::string saved_user = Host::GetBaseStringSettingValue("Achievements", "Username", "");
|
|
const std::string saved_token = Host::GetStringSettingValue("Achievements", "Token");
|
|
if (!saved_user.empty() && !saved_token.empty())
|
|
{
|
|
display_name = saved_user;
|
|
logged_in = true;
|
|
}
|
|
}
|
|
|
|
out += "\"active\":";
|
|
out += active ? "true" : "false";
|
|
out += ",\"loggedIn\":";
|
|
out += logged_in ? "true" : "false";
|
|
out += ",\"hardcore\":";
|
|
out += s_hardcore_mode ? "true" : "false";
|
|
out += ",\"userName\":";
|
|
append_json_string(out, display_name.c_str());
|
|
|
|
// Player score. Prefer the live persistent-client value; when it's unavailable — logged in
|
|
// but no game with achievements loaded yet, e.g. the library RA menu — fall back to the score
|
|
// cached at login (Host::OnAchievementsLoginSuccess persists it to secrets). Only a genuinely
|
|
// unknown score (never logged in) stays -1, which the panel treats as "hide the chip".
|
|
const long long score_val = user
|
|
? static_cast<long long>(user->score)
|
|
: static_cast<long long>(Host::GetIntSettingValue("Achievements", "LastScore", -1));
|
|
const long long score_sc_val = user
|
|
? static_cast<long long>(user->score_softcore)
|
|
: static_cast<long long>(Host::GetIntSettingValue("Achievements", "LastScoreSoftcore", -1));
|
|
out += ",\"score\":";
|
|
out += std::to_string(score_val);
|
|
out += ",\"softcoreScore\":";
|
|
out += std::to_string(score_sc_val);
|
|
|
|
// User avatar (RA UserPic). Prefer the client's canonical URL when a live user is
|
|
// present; otherwise reconstruct it from the saved account username so the library
|
|
// RA menu (logged in, no game loaded yet) can still show the picture.
|
|
std::string avatar_url;
|
|
if (user)
|
|
{
|
|
char url_buf[512];
|
|
if (rc_client_user_get_image_url(user, url_buf, std::size(url_buf)) == RC_OK)
|
|
avatar_url = url_buf;
|
|
}
|
|
if (avatar_url.empty())
|
|
{
|
|
const std::string uname = Host::GetBaseStringSettingValue("Achievements", "Username", "");
|
|
if (!uname.empty())
|
|
avatar_url = "https://media.retroachievements.org/UserPic/" + uname + ".png";
|
|
}
|
|
out += ",\"avatarUrl\":";
|
|
append_json_string(out, avatar_url.c_str());
|
|
|
|
// RA presentation options (global [Achievements] settings) so the panel
|
|
// can show + toggle them without a second JNI poll. Defaults mirror
|
|
// Pcsx2Config::AchievementsOptions() (all on).
|
|
out += ",\"notifications\":";
|
|
out += Host::GetBaseBoolSettingValue("Achievements", "Notifications", true) ? "true" : "false";
|
|
out += ",\"leaderboardNotifications\":";
|
|
out += Host::GetBaseBoolSettingValue("Achievements", "LeaderboardNotifications", true) ? "true" : "false";
|
|
out += ",\"overlays\":";
|
|
out += Host::GetBaseBoolSettingValue("Achievements", "Overlays", true) ? "true" : "false";
|
|
out += ",\"lbOverlays\":";
|
|
out += Host::GetBaseBoolSettingValue("Achievements", "LBOverlays", true) ? "true" : "false";
|
|
out += ",\"soundEffects\":";
|
|
out += Host::GetBaseBoolSettingValue("Achievements", "SoundEffects", true) ? "true" : "false";
|
|
// Achievement MODES (default off) — Encore / Spectator / Unofficial-test. The UI shows
|
|
// + toggles them like the options above; the native rc_client wiring already exists.
|
|
out += ",\"encoreMode\":";
|
|
out += Host::GetBaseBoolSettingValue("Achievements", "EncoreMode", false) ? "true" : "false";
|
|
out += ",\"spectatorMode\":";
|
|
out += Host::GetBaseBoolSettingValue("Achievements", "SpectatorMode", false) ? "true" : "false";
|
|
out += ",\"unofficialTestMode\":";
|
|
out += Host::GetBaseBoolSettingValue("Achievements", "UnofficialTestMode", false) ? "true" : "false";
|
|
|
|
// Notification/leaderboard durations (seconds) and the two overlay positions. Positions are
|
|
// stored as the enum's integer value (SettingsWrapIntEnumEx -> EntryBitfield): NotificationPosition
|
|
// is an OsdOverlayPos (None=0, TopLeft=1..BottomRight=9) and OverlayPosition an
|
|
// AchievementOverlayPosition (TopLeft=0..BottomRight=8). The Android RA panel exposes these as
|
|
// sliders + a 3x3 position grid and writes them back via setAchievementsOptionInt.
|
|
out += ",\"notificationsDuration\":";
|
|
out += std::to_string(Host::GetBaseIntSettingValue("Achievements", "NotificationsDuration", 5));
|
|
out += ",\"leaderboardsDuration\":";
|
|
out += std::to_string(Host::GetBaseIntSettingValue("Achievements", "LeaderboardsDuration", 10));
|
|
out += ",\"notificationPosition\":";
|
|
out += std::to_string(Host::GetBaseIntSettingValue("Achievements", "NotificationPosition",
|
|
static_cast<int>(OsdOverlayPos::TopLeft)));
|
|
out += ",\"overlayPosition\":";
|
|
out += std::to_string(Host::GetBaseIntSettingValue("Achievements", "OverlayPosition",
|
|
static_cast<int>(AchievementOverlayPosition::BottomRight)));
|
|
|
|
out += ",\"items\":[";
|
|
|
|
if (active && s_client)
|
|
{
|
|
// Build a fresh list — don't reuse s_achievement_list, that one's
|
|
// owned by the ImGui pause-menu Achievements window and we don't
|
|
// want to step on its lifecycle.
|
|
rc_client_achievement_list_t* list = rc_client_create_achievement_list(
|
|
s_client, RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE_AND_UNOFFICIAL,
|
|
RC_CLIENT_ACHIEVEMENT_LIST_GROUPING_LOCK_STATE); // per-subset LOCKED/UNLOCKED buckets carry real subset_id (PROGRESS misfiles in-progress to subset 0)
|
|
|
|
if (list)
|
|
{
|
|
// Same display order the ImGui window uses.
|
|
static constexpr u32 bucket_order[] = {
|
|
RC_CLIENT_ACHIEVEMENT_BUCKET_ACTIVE_CHALLENGE,
|
|
RC_CLIENT_ACHIEVEMENT_BUCKET_RECENTLY_UNLOCKED,
|
|
RC_CLIENT_ACHIEVEMENT_BUCKET_UNLOCKED,
|
|
RC_CLIENT_ACHIEVEMENT_BUCKET_ALMOST_THERE,
|
|
RC_CLIENT_ACHIEVEMENT_BUCKET_LOCKED,
|
|
RC_CLIENT_ACHIEVEMENT_BUCKET_UNOFFICIAL,
|
|
RC_CLIENT_ACHIEVEMENT_BUCKET_UNSUPPORTED,
|
|
};
|
|
|
|
bool first = true;
|
|
for (u32 bucket_type : bucket_order)
|
|
{
|
|
for (u32 b = 0; b < list->num_buckets; b++)
|
|
{
|
|
const rc_client_achievement_bucket_t& bucket = list->buckets[b];
|
|
if (bucket.bucket_type != bucket_type)
|
|
continue;
|
|
for (u32 a = 0; a < bucket.num_achievements; a++)
|
|
{
|
|
const rc_client_achievement_t* ach = bucket.achievements[a];
|
|
if (!ach)
|
|
continue;
|
|
if (!first)
|
|
out += ',';
|
|
first = false;
|
|
out += "{\"id\":";
|
|
out += std::to_string(ach->id);
|
|
out += ",\"title\":";
|
|
append_json_string(out, ach->title);
|
|
out += ",\"description\":";
|
|
append_json_string(out, ach->description);
|
|
out += ",\"points\":";
|
|
out += std::to_string(ach->points);
|
|
out += ",\"unlocked\":";
|
|
out += (ach->state == RC_CLIENT_ACHIEVEMENT_STATE_UNLOCKED) ? "true" : "false";
|
|
// ACTIVE CHALLENGE = RA "primed" (a can-do-right-now challenge = the
|
|
// on-screen challenge indicator). Per-achievement ach->bucket is refreshed
|
|
// every rebuild regardless of list grouping; the outer bucket.bucket_type
|
|
// can't carry it under LOCK_STATE grouping. The UI floats these to the top.
|
|
out += ",\"primed\":";
|
|
out += (ach->bucket == RC_CLIENT_ACHIEVEMENT_BUCKET_ACTIVE_CHALLENGE) ? "true" : "false";
|
|
out += ",\"bucket\":";
|
|
out += std::to_string(static_cast<int>(bucket.bucket_type));
|
|
// Subset this achievement belongs to (0 = base/shared set). Lets the
|
|
// Android UI split base-game vs bonus-subset achievements into tabs.
|
|
out += ",\"subsetId\":";
|
|
out += std::to_string(bucket.subset_id);
|
|
out += ",\"rarity\":";
|
|
{
|
|
char buf[32];
|
|
std::snprintf(buf, sizeof(buf), "%.1f", ach->rarity);
|
|
out += buf;
|
|
}
|
|
out += ",\"measuredProgress\":";
|
|
append_json_string(out, ach->measured_progress);
|
|
out += ",\"measuredPercent\":";
|
|
{
|
|
char buf[32];
|
|
std::snprintf(buf, sizeof(buf), "%.1f", ach->measured_percent);
|
|
out += buf;
|
|
}
|
|
// Type lets the UI tag MISSABLE / PROGRESSION / WIN
|
|
// achievements (STANDARD = 0 → no tag). Unlocked is
|
|
// the SOFTCORE/HARDCORE bitmask — distinguishes how
|
|
// the user earned it for the HC indicator. unlockTime
|
|
// is unix-seconds (0 if locked) so the panel can
|
|
// render a relative timestamp.
|
|
out += ",\"type\":";
|
|
out += std::to_string(static_cast<int>(ach->type));
|
|
out += ",\"unlockedMask\":";
|
|
out += std::to_string(static_cast<int>(ach->unlocked));
|
|
out += ",\"unlockTime\":";
|
|
out += std::to_string(static_cast<long long>(ach->unlock_time));
|
|
// Badge image URL for the achievement's current state
|
|
// (unlocked variant vs `_lock` greyscale). Java side
|
|
// fetches via Coil into the persistent cover_cache —
|
|
// RA badge URLs are immutable per badge_name so they
|
|
// cache forever. Empty string on failure → panel
|
|
// falls back to the glyph placeholder.
|
|
{
|
|
char url_buf[256];
|
|
const int rc = rc_client_achievement_get_image_url(
|
|
ach, ach->state, url_buf, std::size(url_buf));
|
|
out += ",\"iconUrl\":";
|
|
append_json_string(out, rc == RC_OK ? url_buf : "");
|
|
}
|
|
out += '}';
|
|
}
|
|
}
|
|
}
|
|
rc_client_destroy_achievement_list(list);
|
|
}
|
|
}
|
|
|
|
out += "]";
|
|
// Subsets (RA bonus/base sets) so the UI can offer per-subset tabs; subset_id 0 is the
|
|
// base/shared set. Single-subset games get one entry and the UI shows no tabs.
|
|
out += ",\"subsets\":[";
|
|
if (active && s_client)
|
|
{
|
|
rc_client_subset_list_t* subset_list = rc_client_create_subset_list(s_client);
|
|
if (subset_list)
|
|
{
|
|
bool sfirst = true;
|
|
for (u32 si = 0; si < subset_list->num_subsets; si++)
|
|
{
|
|
const rc_client_subset_t* sub = subset_list->subsets[si];
|
|
if (!sub)
|
|
continue;
|
|
if (!sfirst)
|
|
out += ',';
|
|
sfirst = false;
|
|
out += "{\"id\":";
|
|
out += std::to_string(sub->id);
|
|
out += ",\"title\":";
|
|
append_json_string(out, sub->title);
|
|
out += ",\"numAchievements\":";
|
|
out += std::to_string(sub->num_achievements);
|
|
out += '}';
|
|
}
|
|
rc_client_destroy_subset_list(subset_list);
|
|
}
|
|
}
|
|
out += "]}";
|
|
return out;
|
|
}
|
|
|
|
bool Achievements::HasLeaderboards()
|
|
{
|
|
return s_has_leaderboards;
|
|
}
|
|
|
|
bool Achievements::HasRichPresence()
|
|
{
|
|
return s_has_rich_presence;
|
|
}
|
|
|
|
const std::string& Achievements::GetGameTitle()
|
|
{
|
|
return s_game_title;
|
|
}
|
|
|
|
const std::string& Achievements::GetRichPresenceString()
|
|
{
|
|
return s_rich_presence_string;
|
|
}
|
|
|
|
const std::string& Achievements::GetGameIconURL()
|
|
{
|
|
return s_game_icon_url;
|
|
}
|
|
|
|
bool Achievements::Initialize()
|
|
{
|
|
if (IsUsingRAIntegration())
|
|
return true;
|
|
|
|
EnsureCacheDirectoriesExist();
|
|
|
|
auto lock = GetLock();
|
|
pxAssertRel(EmuConfig.Achievements.Enabled, "Achievements are enabled");
|
|
pxAssertRel(!s_client && !s_http_downloader, "No client and downloader");
|
|
|
|
if (!CreateClient(&s_client, &s_http_downloader))
|
|
return false;
|
|
|
|
// Hardcore starts off. We enable it on first boot.
|
|
s_hardcore_mode = false;
|
|
|
|
rc_client_set_event_handler(s_client, ClientEventHandler);
|
|
|
|
rc_client_set_hardcore_enabled(s_client, s_hardcore_mode);
|
|
rc_client_set_encore_mode_enabled(s_client, EmuConfig.Achievements.EncoreMode);
|
|
rc_client_set_unofficial_enabled(s_client, EmuConfig.Achievements.UnofficialTestMode);
|
|
rc_client_set_spectator_mode_enabled(s_client, EmuConfig.Achievements.SpectatorMode);
|
|
|
|
// Begin disc identification early, before the login finishes.
|
|
if (VMManager::HasValidVM())
|
|
IdentifyGame(VMManager::GetDiscCRC(), VMManager::GetCurrentCRC());
|
|
|
|
const std::string username = Host::GetBaseStringSettingValue("Achievements", "Username");
|
|
|
|
// Check the base settings file to see if the token is defined inside. Move if found.
|
|
std::string oldToken = Host::GetBaseStringSettingValue("Achievements", "Token");
|
|
if (!oldToken.empty())
|
|
{
|
|
auto secretsLock = Host::GetSecretsSettingsLock();
|
|
SettingsInterface* secretsInterface = Host::Internal::GetSecretsSettingsLayer();
|
|
secretsInterface->SetStringValue("Achievements", "Token", oldToken.c_str());
|
|
secretsInterface->Save();
|
|
|
|
oldToken.clear();
|
|
|
|
auto baseLock = Host::GetSettingsLock();
|
|
SettingsInterface* baseInterface = Host::Internal::GetBaseSettingsLayer();
|
|
baseInterface->DeleteValue("Achievements", "Token");
|
|
baseInterface->Save();
|
|
}
|
|
|
|
const std::string api_token = Host::GetStringSettingValue("Achievements", "Token");
|
|
if (!username.empty() && !api_token.empty())
|
|
{
|
|
Console.WriteLn("Achievements: Attempting login with user '%s'...", username.c_str());
|
|
s_login_request =
|
|
rc_client_begin_login_with_token(s_client, username.c_str(), api_token.c_str(), ClientLoginWithTokenCallback, nullptr);
|
|
}
|
|
|
|
// Hardcore mode isn't enabled when achievements first starts, if a game is already running.
|
|
if (VMManager::HasValidVM() && IsLoggedInOrLoggingIn() && EmuConfig.Achievements.HardcoreMode)
|
|
DisplayHardcoreDeferredMessage();
|
|
|
|
// Set initial notification position
|
|
UpdateNotificationPosition();
|
|
|
|
return true;
|
|
}
|
|
|
|
u32 Achievements::GetExposedEEMemorySize()
|
|
{
|
|
return Ps2MemSize::ExposedRam + Ps2MemSize::Scratch;
|
|
}
|
|
|
|
bool Achievements::CreateClient(rc_client_t** client, std::unique_ptr<HTTPDownloader>* http)
|
|
{
|
|
*http = HTTPDownloader::Create(Host::GetHTTPUserAgent());
|
|
if (!*http)
|
|
{
|
|
Host::ReportErrorAsync("Achievements Error", "Failed to create HTTPDownloader, cannot use achievements");
|
|
return false;
|
|
}
|
|
|
|
(*http)->SetTimeout(SERVER_CALL_TIMEOUT);
|
|
(*http)->SetMaxActiveRequests(MAX_CONCURRENT_SERVER_CALLS);
|
|
|
|
rc_client_t* new_client = rc_client_create(ClientReadMemory, ClientServerCall);
|
|
if (!new_client)
|
|
{
|
|
Host::ReportErrorAsync("Achievements Error", "rc_client_create() failed, cannot use achievements");
|
|
http->reset();
|
|
return false;
|
|
}
|
|
|
|
#ifdef PCSX2_DEVBUILD
|
|
rc_client_enable_logging(new_client, RC_CLIENT_LOG_LEVEL_VERBOSE, ClientMessageCallback);
|
|
#else
|
|
rc_client_enable_logging(new_client, RC_CLIENT_LOG_LEVEL_INFO, ClientMessageCallback);
|
|
#endif
|
|
|
|
rc_client_set_userdata(new_client, http->get());
|
|
|
|
const std::string custom_host = Host::GetBaseStringSettingValue("Achievements", "Host", "");
|
|
if (!custom_host.empty())
|
|
{
|
|
Console.WriteLn("Achievements: Using custom host %s", custom_host.c_str());
|
|
rc_client_set_host(new_client, custom_host.c_str());
|
|
}
|
|
|
|
*client = new_client;
|
|
return true;
|
|
}
|
|
|
|
void Achievements::DestroyClient(rc_client_t** client, std::unique_ptr<HTTPDownloader>* http)
|
|
{
|
|
(*http)->WaitForAllRequests();
|
|
|
|
rc_client_destroy(*client);
|
|
*client = nullptr;
|
|
|
|
http->reset();
|
|
}
|
|
|
|
void Achievements::UpdateNotificationPosition()
|
|
{
|
|
// Set notification position based on achievement settings
|
|
float horizontal_position, vertical_position, direction;
|
|
|
|
// Determine horizontal alignment
|
|
switch (EmuConfig.Achievements.NotificationPosition)
|
|
{
|
|
case OsdOverlayPos::TopLeft:
|
|
case OsdOverlayPos::CenterLeft:
|
|
case OsdOverlayPos::BottomLeft:
|
|
horizontal_position = 0.0f; // Left
|
|
break;
|
|
|
|
case OsdOverlayPos::TopCenter:
|
|
case OsdOverlayPos::Center:
|
|
case OsdOverlayPos::BottomCenter:
|
|
horizontal_position = 0.5f; // Center
|
|
break;
|
|
|
|
case OsdOverlayPos::TopRight:
|
|
case OsdOverlayPos::CenterRight:
|
|
case OsdOverlayPos::BottomRight:
|
|
default:
|
|
horizontal_position = 1.0f; // Right
|
|
break;
|
|
}
|
|
|
|
// Determine vertical alignment and stacking direction
|
|
switch (EmuConfig.Achievements.NotificationPosition)
|
|
{
|
|
case OsdOverlayPos::TopLeft:
|
|
case OsdOverlayPos::TopCenter:
|
|
case OsdOverlayPos::TopRight:
|
|
vertical_position = 0.15f; // Top area
|
|
direction = 1.0f; // Stack downward
|
|
break;
|
|
|
|
case OsdOverlayPos::CenterLeft:
|
|
case OsdOverlayPos::Center:
|
|
case OsdOverlayPos::CenterRight:
|
|
vertical_position = 0.5f; // Center
|
|
direction = 1.0f; // Stack downward
|
|
break;
|
|
|
|
case OsdOverlayPos::BottomLeft:
|
|
case OsdOverlayPos::BottomCenter:
|
|
case OsdOverlayPos::BottomRight:
|
|
default:
|
|
vertical_position = 0.85f; // Bottom area
|
|
direction = -1.0f; // Stack upward
|
|
break;
|
|
}
|
|
|
|
ImGuiFullscreen::SetNotificationPosition(horizontal_position, vertical_position, direction);
|
|
}
|
|
|
|
void Achievements::UpdateSettings(const Pcsx2Config::AchievementsOptions& old_config)
|
|
{
|
|
if (IsUsingRAIntegration())
|
|
return;
|
|
|
|
if (!EmuConfig.Achievements.Enabled)
|
|
{
|
|
// we're done here
|
|
Shutdown(false);
|
|
return;
|
|
}
|
|
|
|
if (!IsActive())
|
|
{
|
|
// we just got enabled
|
|
Initialize();
|
|
return;
|
|
}
|
|
|
|
if (EmuConfig.Achievements.HardcoreMode != old_config.HardcoreMode)
|
|
{
|
|
// Hardcore mode can only be enabled through reset (ResetChallengeMode()).
|
|
if (s_hardcore_mode && !EmuConfig.Achievements.HardcoreMode)
|
|
{
|
|
ResetHardcoreMode(false);
|
|
}
|
|
else if (!s_hardcore_mode && EmuConfig.Achievements.HardcoreMode)
|
|
{
|
|
if (HasActiveGame())
|
|
DisplayHardcoreDeferredMessage();
|
|
}
|
|
}
|
|
|
|
// These cannot be modified while a game is loaded, so just toss state and reload.
|
|
if (HasActiveGame())
|
|
{
|
|
if (EmuConfig.Achievements.EncoreMode != old_config.EncoreMode ||
|
|
EmuConfig.Achievements.SpectatorMode != old_config.SpectatorMode ||
|
|
EmuConfig.Achievements.UnofficialTestMode != old_config.UnofficialTestMode)
|
|
{
|
|
Shutdown(false);
|
|
Initialize();
|
|
return;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (EmuConfig.Achievements.EncoreMode != old_config.EncoreMode)
|
|
rc_client_set_encore_mode_enabled(s_client, EmuConfig.Achievements.EncoreMode);
|
|
if (EmuConfig.Achievements.SpectatorMode != old_config.SpectatorMode)
|
|
rc_client_set_spectator_mode_enabled(s_client, EmuConfig.Achievements.SpectatorMode);
|
|
if (EmuConfig.Achievements.UnofficialTestMode != old_config.UnofficialTestMode)
|
|
rc_client_set_unofficial_enabled(s_client, EmuConfig.Achievements.UnofficialTestMode);
|
|
}
|
|
|
|
// Update notification position if it changed
|
|
if (EmuConfig.Achievements.NotificationPosition != old_config.NotificationPosition)
|
|
UpdateNotificationPosition();
|
|
|
|
// in case cache directory changed
|
|
EnsureCacheDirectoriesExist();
|
|
}
|
|
|
|
|
|
bool Achievements::Shutdown(bool allow_cancel)
|
|
{
|
|
#ifdef ENABLE_RAINTEGRATION
|
|
if (IsUsingRAIntegration())
|
|
{
|
|
if (VMManager::HasValidVM() && allow_cancel && !RA_ConfirmLoadNewRom(true))
|
|
return false;
|
|
|
|
RA_SetPaused(false);
|
|
RA_ActivateGame(0);
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
if (!IsActive())
|
|
return true;
|
|
|
|
auto lock = GetLock();
|
|
pxAssertRel(s_client && s_http_downloader, "Has client and downloader");
|
|
|
|
DisableHardcoreMode();
|
|
ClearGameInfo();
|
|
ClearGameHash();
|
|
|
|
if (s_login_request)
|
|
{
|
|
rc_client_abort_async(s_client, s_login_request);
|
|
s_login_request = nullptr;
|
|
}
|
|
|
|
s_hardcore_mode = false;
|
|
DestroyClient(&s_client, &s_http_downloader);
|
|
return true;
|
|
}
|
|
|
|
|
|
void Achievements::EnsureCacheDirectoriesExist()
|
|
{
|
|
s_image_directory = Path::Combine(EmuFolders::Cache, "achievement_images");
|
|
|
|
if (!FileSystem::DirectoryExists(s_image_directory.c_str()) && !FileSystem::CreateDirectoryPath(s_image_directory.c_str(), false))
|
|
{
|
|
ReportFmtError("Failed to create cache directory '{}'", s_image_directory);
|
|
}
|
|
}
|
|
|
|
void Achievements::ClientMessageCallback(const char* message, const rc_client_t* client)
|
|
{
|
|
Console.WriteLn("Achievements: %s", message);
|
|
}
|
|
|
|
uint32_t Achievements::ClientReadMemory(uint32_t address, uint8_t* buffer, uint32_t num_bytes, rc_client_t* client)
|
|
{
|
|
if ((static_cast<u64>(address) + num_bytes) > GetExposedEEMemorySize()) [[unlikely]]
|
|
{
|
|
DevCon.Warning("[Achievements] Ignoring out of bounds memory peek of %u bytes at %08X.", num_bytes, address);
|
|
return 0u;
|
|
}
|
|
|
|
// RA uses a fake memory map with the scratchpad directly above physical memory.
|
|
// The scratchpad is not meant to be accessible via physical addressing, only virtual.
|
|
// This also means that the upper 96MB of memory will never be accessible to achievements.
|
|
const u8* ptr = (address < Ps2MemSize::ExposedRam) ? &eeMem->Main[address] : &eeMem->Scratch[address - Ps2MemSize::ExposedRam];
|
|
|
|
// Fast paths for known data sizes.
|
|
switch (num_bytes)
|
|
{
|
|
// clang-format off
|
|
case 1: std::memcpy(buffer, ptr, 1); break;
|
|
case 2: std::memcpy(buffer, ptr, 2); break;
|
|
case 4: std::memcpy(buffer, ptr, 4); break;
|
|
case 8: std::memcpy(buffer, ptr, 8); break;
|
|
default: std::memcpy(buffer, ptr, num_bytes); break;
|
|
// clang-format on
|
|
}
|
|
|
|
return num_bytes;
|
|
}
|
|
|
|
void Achievements::ClientServerCall(
|
|
const rc_api_request_t* request, rc_client_server_callback_t callback, void* callback_data, rc_client_t* client)
|
|
{
|
|
HTTPDownloader::Request::Callback hd_callback = [callback, callback_data](s32 status_code, const std::string& content_type, HTTPDownloader::Request::Data data)
|
|
{
|
|
const bool is_error = (status_code <= 0);
|
|
const bool is_retryable =
|
|
is_error && (status_code == HTTPDownloader::HTTP_STATUS_TIMEOUT);
|
|
|
|
rc_api_server_response_t rr;
|
|
rr.http_status_code =
|
|
is_error
|
|
? (is_retryable
|
|
? RC_API_SERVER_RESPONSE_RETRYABLE_CLIENT_ERROR
|
|
: RC_API_SERVER_RESPONSE_CLIENT_ERROR)
|
|
: status_code;
|
|
|
|
rr.body_length = data.size();
|
|
rr.body = reinterpret_cast<const char*>(data.data());
|
|
|
|
callback(&rr, callback_data);
|
|
};
|
|
|
|
HTTPDownloader* http = static_cast<HTTPDownloader*>(rc_client_get_userdata(client));
|
|
|
|
// TODO: Content-type for post
|
|
if (request->post_data)
|
|
{
|
|
// const auto pd = std::string_view(request->post_data);
|
|
// Console.WriteLn(fmt::format("Server POST: {}", pd.substr(0, std::min<size_t>(pd.length(), 10))).c_str());
|
|
http->CreatePostRequest(request->url, request->post_data, std::move(hd_callback));
|
|
}
|
|
else
|
|
{
|
|
http->CreateRequest(request->url, std::move(hd_callback));
|
|
}
|
|
}
|
|
|
|
|
|
void Achievements::IdleUpdate()
|
|
{
|
|
if (!IsActive())
|
|
return;
|
|
|
|
#ifdef ENABLE_RAINTEGRATION
|
|
if (IsUsingRAIntegration())
|
|
return;
|
|
#endif
|
|
|
|
const auto lock = GetLock();
|
|
|
|
s_http_downloader->PollRequests();
|
|
rc_client_idle(s_client);
|
|
}
|
|
|
|
void Achievements::FrameUpdate()
|
|
{
|
|
if (!IsActive())
|
|
return;
|
|
|
|
#ifdef ENABLE_RAINTEGRATION
|
|
if (IsUsingRAIntegration())
|
|
{
|
|
RA_DoAchievementsFrame();
|
|
return;
|
|
}
|
|
#endif
|
|
|
|
auto lock = GetLock();
|
|
|
|
s_http_downloader->PollRequests();
|
|
|
|
// Don't update the actual achievements until an ELF has loaded.
|
|
if (VMManager::Internal::HasBootedELF())
|
|
rc_client_do_frame(s_client);
|
|
else
|
|
rc_client_idle(s_client);
|
|
|
|
UpdateRichPresence(lock);
|
|
}
|
|
|
|
void Achievements::ClientEventHandler(const rc_client_event_t* event, rc_client_t* client)
|
|
{
|
|
switch (event->type)
|
|
{
|
|
case RC_CLIENT_EVENT_RESET:
|
|
HandleResetEvent(event);
|
|
break;
|
|
|
|
case RC_CLIENT_EVENT_ACHIEVEMENT_TRIGGERED:
|
|
HandleUnlockEvent(event);
|
|
break;
|
|
|
|
case RC_CLIENT_EVENT_GAME_COMPLETED:
|
|
HandleGameCompleteEvent(event);
|
|
break;
|
|
|
|
case RC_CLIENT_EVENT_SUBSET_COMPLETED:
|
|
HandleSubsetCompleteEvent(event);
|
|
break;
|
|
|
|
case RC_CLIENT_EVENT_LEADERBOARD_STARTED:
|
|
HandleLeaderboardStartedEvent(event);
|
|
break;
|
|
|
|
case RC_CLIENT_EVENT_LEADERBOARD_FAILED:
|
|
HandleLeaderboardFailedEvent(event);
|
|
break;
|
|
|
|
case RC_CLIENT_EVENT_LEADERBOARD_SUBMITTED:
|
|
HandleLeaderboardSubmittedEvent(event);
|
|
break;
|
|
|
|
case RC_CLIENT_EVENT_LEADERBOARD_SCOREBOARD:
|
|
HandleLeaderboardScoreboardEvent(event);
|
|
break;
|
|
|
|
case RC_CLIENT_EVENT_LEADERBOARD_TRACKER_SHOW:
|
|
HandleLeaderboardTrackerShowEvent(event);
|
|
break;
|
|
|
|
case RC_CLIENT_EVENT_LEADERBOARD_TRACKER_HIDE:
|
|
HandleLeaderboardTrackerHideEvent(event);
|
|
break;
|
|
|
|
case RC_CLIENT_EVENT_LEADERBOARD_TRACKER_UPDATE:
|
|
HandleLeaderboardTrackerUpdateEvent(event);
|
|
break;
|
|
|
|
case RC_CLIENT_EVENT_ACHIEVEMENT_CHALLENGE_INDICATOR_SHOW:
|
|
HandleAchievementChallengeIndicatorShowEvent(event);
|
|
break;
|
|
|
|
case RC_CLIENT_EVENT_ACHIEVEMENT_CHALLENGE_INDICATOR_HIDE:
|
|
HandleAchievementChallengeIndicatorHideEvent(event);
|
|
break;
|
|
|
|
case RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_SHOW:
|
|
HandleAchievementProgressIndicatorShowEvent(event);
|
|
break;
|
|
|
|
case RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_HIDE:
|
|
HandleAchievementProgressIndicatorHideEvent(event);
|
|
break;
|
|
|
|
case RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_UPDATE:
|
|
HandleAchievementProgressIndicatorUpdateEvent(event);
|
|
break;
|
|
|
|
case RC_CLIENT_EVENT_SERVER_ERROR:
|
|
HandleServerErrorEvent(event);
|
|
break;
|
|
|
|
case RC_CLIENT_EVENT_DISCONNECTED:
|
|
HandleServerDisconnectedEvent(event);
|
|
break;
|
|
|
|
case RC_CLIENT_EVENT_RECONNECTED:
|
|
HandleServerReconnectedEvent(event);
|
|
break;
|
|
|
|
default:
|
|
Console.Error("Unhandled event: %u", event->type);
|
|
break;
|
|
}
|
|
}
|
|
|
|
void Achievements::UpdateGameSummary()
|
|
{
|
|
rc_client_get_user_game_summary(s_client, &s_game_summary);
|
|
}
|
|
|
|
void Achievements::UpdateRichPresence(std::unique_lock<std::recursive_mutex>& lock)
|
|
{
|
|
// Limit rich presence updates to once per second, since it could change per frame.
|
|
if (!s_has_rich_presence || !s_rich_presence_poll_time.ResetIfSecondsPassed(1.0))
|
|
return;
|
|
|
|
char buffer[URL_BUFFER_SIZE];
|
|
const size_t res = rc_client_get_rich_presence_message(s_client, buffer, std::size(buffer));
|
|
const std::string_view sv(buffer, res);
|
|
if (s_rich_presence_string == sv)
|
|
return;
|
|
|
|
s_rich_presence_string.assign(sv);
|
|
|
|
Console.WriteLn(Color_StrongGreen, fmt::format("Rich presence updated: {}", s_rich_presence_string));
|
|
Host::OnAchievementsRefreshed();
|
|
|
|
lock.unlock();
|
|
VMManager::UpdateDiscordPresence(false);
|
|
lock.lock();
|
|
}
|
|
|
|
void Achievements::PlayAchievementSound(bool is_specific_sound_enabled, const std::string& custom_sound_name, const std::string& default_sound_name)
|
|
{
|
|
if (!EmuConfig.Achievements.SoundEffects || !is_specific_sound_enabled)
|
|
return;
|
|
|
|
if (custom_sound_name.empty() || !FileSystem::FileExists(custom_sound_name.c_str()))
|
|
Common::PlaySoundAsync(Path::Combine(EmuFolders::Resources, default_sound_name).c_str());
|
|
else
|
|
Common::PlaySoundAsync(custom_sound_name.c_str());
|
|
}
|
|
|
|
void Achievements::GameChanged(u32 disc_crc, u32 crc)
|
|
{
|
|
std::unique_lock lock(s_achievements_mutex);
|
|
|
|
if (!IsActive())
|
|
return;
|
|
|
|
IdentifyGame(disc_crc, crc);
|
|
}
|
|
|
|
void Achievements::IdentifyGame(u32 disc_crc, u32 crc)
|
|
{
|
|
// If we're currently loading the ELF, assume that we're going to load the default ELF.
|
|
// That way we can download achievement data while the PS2 logo runs. Pretty safe assumption.
|
|
const bool booted_elf = VMManager::Internal::HasBootedELF();
|
|
const u32 crc_to_use = booted_elf ? crc : disc_crc;
|
|
|
|
// Avoid reading+hashing the executable if the crc hasn't changed.
|
|
if (s_game_crc == crc_to_use)
|
|
return;
|
|
|
|
const std::string game_hash = GetGameHash(booted_elf ? VMManager::GetCurrentELF() : VMManager::GetDiscELF());
|
|
if (s_game_hash == game_hash)
|
|
return;
|
|
|
|
ClearGameHash();
|
|
s_game_crc = crc_to_use;
|
|
s_game_hash = std::move(game_hash);
|
|
|
|
#ifdef ENABLE_RAINTEGRATION
|
|
if (IsUsingRAIntegration())
|
|
{
|
|
RAIntegration::GameChanged();
|
|
return;
|
|
}
|
|
#endif
|
|
|
|
// shouldn't have a load game request when we're not logged in.
|
|
pxAssertRel(IsLoggedInOrLoggingIn() || !s_load_game_request, "Logged in with load game request");
|
|
|
|
// bail out if we're not logged in, just save the hash
|
|
if (!IsLoggedInOrLoggingIn())
|
|
{
|
|
Console.WriteLn(Color_StrongYellow, "Achievements: Skipping load game because we're not logged in.");
|
|
DisableHardcoreMode();
|
|
return;
|
|
}
|
|
|
|
BeginLoadGame();
|
|
}
|
|
|
|
void Achievements::BeginLoadGame()
|
|
{
|
|
// cancel previous requests
|
|
if (s_load_game_request)
|
|
{
|
|
rc_client_abort_async(s_client, s_load_game_request);
|
|
s_load_game_request = nullptr;
|
|
}
|
|
|
|
ClearGameInfo();
|
|
|
|
if (s_game_hash.empty())
|
|
{
|
|
// when we're booting the bios, or shutting down, this will fail
|
|
if (s_game_crc != 0)
|
|
{
|
|
Host::AddKeyedOSDMessage("retroachievements_disc_read_failed",
|
|
TRANSLATE_STR("Achievements", "Failed to read executable from disc. Achievements disabled."),
|
|
Host::OSD_CRITICAL_ERROR_DURATION);
|
|
}
|
|
|
|
DisableHardcoreMode();
|
|
return;
|
|
}
|
|
|
|
s_load_game_request = rc_client_begin_load_game(s_client, s_game_hash.c_str(), ClientLoadGameCallback, nullptr);
|
|
}
|
|
|
|
void Achievements::ClientLoadGameCallback(int result, const char* error_message, rc_client_t* client, void* userdata)
|
|
{
|
|
s_load_game_request = nullptr;
|
|
|
|
if (result == RC_NO_GAME_LOADED)
|
|
{
|
|
// Unknown game.
|
|
Console.WriteLn(Color_StrongYellow, "Achievements: Unknown game '%s', disabling achievements.", s_game_hash.c_str());
|
|
DisableHardcoreMode();
|
|
return;
|
|
}
|
|
else if (result == RC_LOGIN_REQUIRED)
|
|
{
|
|
// We would've asked to re-authenticate, so leave HC on for now.
|
|
// Once we've done so, we'll reload the game.
|
|
return;
|
|
}
|
|
else if (result != RC_OK)
|
|
{
|
|
ReportFmtError("Loading game failed: {}", error_message);
|
|
DisableHardcoreMode();
|
|
return;
|
|
}
|
|
|
|
const rc_client_game_t* info = rc_client_get_game_info(s_client);
|
|
if (!info)
|
|
{
|
|
ReportError("rc_client_get_game_info() returned NULL");
|
|
DisableHardcoreMode();
|
|
return;
|
|
}
|
|
|
|
const bool has_achievements = rc_client_has_achievements(client);
|
|
const bool has_leaderboards = rc_client_has_leaderboards(client);
|
|
|
|
// If the game has a RetroAchievements entry but no achievements or leaderboards,
|
|
// enforcing hardcore mode is pointless.
|
|
if (!has_achievements && !has_leaderboards)
|
|
{
|
|
DisableHardcoreMode();
|
|
}
|
|
|
|
// We should have matched hardcore mode state.
|
|
pxAssertRel(s_hardcore_mode == (rc_client_get_hardcore_enabled(client) != 0), "Hardcore status mismatch");
|
|
|
|
s_game_id = info->id;
|
|
s_game_title = info->title;
|
|
s_has_achievements = has_achievements;
|
|
s_has_leaderboards = has_leaderboards;
|
|
s_has_rich_presence = rc_client_has_rich_presence(client);
|
|
s_game_icon = {};
|
|
s_game_icon_url = info->badge_url;
|
|
|
|
// FullscreenUI is only needed when notifications render through ImGui. Platforms with
|
|
// native notifications skip this so FullscreenUI never initializes and its per-frame
|
|
// render work stays disabled.
|
|
if (!Host::HasNativeAchievementNotifications())
|
|
MTGS::RunOnGSThread(&ImGuiManager::InitializeFullscreenUI);
|
|
|
|
if (const std::string_view badge_name = info->badge_name; !badge_name.empty())
|
|
{
|
|
s_game_icon = Path::Combine(s_image_directory, fmt::format("game_{}.png", badge_name));
|
|
if (!s_game_icon.empty() && !s_game_icon_url.empty() && !FileSystem::FileExists(s_game_icon.c_str()))
|
|
DownloadImage(s_game_icon_url, s_game_icon);
|
|
}
|
|
|
|
UpdateGameSummary();
|
|
DisplayAchievementSummary();
|
|
|
|
Host::OnAchievementsRefreshed();
|
|
}
|
|
|
|
void Achievements::ClearGameInfo()
|
|
{
|
|
if (MTGS::IsOpen())
|
|
MTGS::RunOnGSThread(&Achievements::ClearUIState);
|
|
|
|
if (s_load_game_request)
|
|
{
|
|
rc_client_abort_async(s_client, s_load_game_request);
|
|
s_load_game_request = nullptr;
|
|
}
|
|
rc_client_unload_game(s_client);
|
|
|
|
s_active_leaderboard_trackers = {};
|
|
s_active_challenge_indicators = {};
|
|
s_active_progress_indicator.reset();
|
|
s_game_id = 0;
|
|
s_game_title = {};
|
|
s_game_icon = {};
|
|
s_game_icon_url = {};
|
|
s_has_achievements = false;
|
|
s_has_leaderboards = false;
|
|
s_has_rich_presence = false;
|
|
s_rich_presence_string = {};
|
|
s_game_summary = {};
|
|
|
|
Host::OnAchievementsRefreshed();
|
|
}
|
|
|
|
void Achievements::ClearGameHash()
|
|
{
|
|
s_game_crc = 0;
|
|
std::string().swap(s_game_hash);
|
|
}
|
|
|
|
// Send an achievement notification to the native UI when
|
|
// Host::HasNativeAchievementNotifications() is true, otherwise to the ImGui
|
|
// overlay. On native platforms this also keeps FullscreenUI uninitialized,
|
|
// which would otherwise render offscreen on iOS but still run every frame.
|
|
static void PostAchievementNotification(std::string key, float duration, std::string title,
|
|
std::string message, std::string badge_path)
|
|
{
|
|
if (Host::HasNativeAchievementNotifications())
|
|
{
|
|
Host::OnAchievementNotification(key.c_str(), duration, title.c_str(), message.c_str(),
|
|
badge_path.c_str());
|
|
return;
|
|
}
|
|
|
|
MTGS::RunOnGSThread([key = std::move(key), duration, title = std::move(title),
|
|
message = std::move(message), badge_path = std::move(badge_path)]() {
|
|
if (ImGuiManager::InitializeFullscreenUI())
|
|
{
|
|
ImGuiFullscreen::AddNotification(key, duration, std::move(title), std::move(message),
|
|
std::move(badge_path));
|
|
}
|
|
});
|
|
}
|
|
|
|
void Achievements::DisplayAchievementSummary()
|
|
{
|
|
if (EmuConfig.Achievements.Notifications)
|
|
{
|
|
std::string title;
|
|
if (IsHardcoreModeActive())
|
|
title = fmt::format(TRANSLATE_FS("Achievements", "{} (Hardcore Mode)"), s_game_title);
|
|
else
|
|
title = s_game_title;
|
|
|
|
std::string summary;
|
|
if (s_game_summary.num_core_achievements > 0)
|
|
{
|
|
summary = fmt::format(
|
|
TRANSLATE_FS("Achievements", "{0}, {1}."),
|
|
SmallString::from_format(TRANSLATE_PLURAL_FS("Achievements", "You have unlocked {} of %n achievements",
|
|
"Achievement popup", s_game_summary.num_core_achievements),
|
|
s_game_summary.num_unlocked_achievements),
|
|
SmallString::from_format(TRANSLATE_PLURAL_FS("Achievements", "and earned {} of %n points", "Achievement popup",
|
|
s_game_summary.points_core),
|
|
s_game_summary.points_unlocked));
|
|
}
|
|
else
|
|
{
|
|
summary = TRANSLATE_STR("Achievements", "This game has no achievements.");
|
|
}
|
|
|
|
PostAchievementNotification("achievement_summary", ACHIEVEMENT_SUMMARY_NOTIFICATION_TIME,
|
|
std::move(title), std::move(summary), s_game_icon);
|
|
}
|
|
Achievements::PlayAchievementSound(EmuConfig.Achievements.InfoSound, EmuConfig.Achievements.InfoSoundName, DEFAULT_INFO_SOUND_NAME);
|
|
}
|
|
|
|
void Achievements::DisplayHardcoreDeferredMessage()
|
|
{
|
|
// This OSD message renders through ImGui FullscreenUI, which is invisible (and kept
|
|
// uninitialized) on platforms with native notifications.
|
|
if (Host::HasNativeAchievementNotifications())
|
|
return;
|
|
|
|
MTGS::RunOnGSThread([]() {
|
|
if (VMManager::HasValidVM() && EmuConfig.Achievements.HardcoreMode && !s_hardcore_mode &&
|
|
ImGuiManager::InitializeFullscreenUI())
|
|
{
|
|
Host::AddIconOSDMessage(
|
|
"hardcore_on_reset", ICON_PF_DUMBELL, TRANSLATE_STR("Achievements", "Hardcore mode will be enabled on system reset."),
|
|
Host::OSD_WARNING_DURATION);
|
|
}
|
|
});
|
|
}
|
|
|
|
void Achievements::HandleResetEvent(const rc_client_event_t* event)
|
|
{
|
|
// We handle system resets ourselves, but still need to reset the client's state.
|
|
Console.WriteLn("Resetting runtime due to reset event");
|
|
rc_client_reset(s_client);
|
|
|
|
if (HasActiveGame())
|
|
UpdateGameSummary();
|
|
}
|
|
|
|
void Achievements::HandleUnlockEvent(const rc_client_event_t* event)
|
|
{
|
|
const rc_client_achievement_t* cheevo = event->achievement;
|
|
pxAssert(cheevo);
|
|
|
|
Console.WriteLn("Achievements: Achievement %s (%u) for game %u unlocked", cheevo->title, cheevo->id, s_game_id);
|
|
UpdateGameSummary();
|
|
|
|
if (EmuConfig.Achievements.Notifications)
|
|
{
|
|
std::string title;
|
|
if (cheevo->category == RC_CLIENT_ACHIEVEMENT_CATEGORY_UNOFFICIAL)
|
|
title = fmt::format(TRANSLATE_FS("Achievements", "{} (Unofficial)"), cheevo->title);
|
|
else
|
|
title = cheevo->title;
|
|
|
|
std::string badge_path = GetAchievementBadgePath(cheevo, cheevo->state);
|
|
|
|
PostAchievementNotification(fmt::format("achievement_unlock_{}", cheevo->id),
|
|
EmuConfig.Achievements.NotificationsDuration, std::move(title),
|
|
std::string(cheevo->description), std::move(badge_path));
|
|
}
|
|
Achievements::PlayAchievementSound(EmuConfig.Achievements.UnlockSound, EmuConfig.Achievements.UnlockSoundName, DEFAULT_UNLOCK_SOUND_NAME);
|
|
}
|
|
|
|
void Achievements::HandleGameCompleteEvent(const rc_client_event_t* event)
|
|
{
|
|
Console.WriteLn("Achievements: Game %u complete", s_game_id);
|
|
UpdateGameSummary();
|
|
|
|
if (EmuConfig.Achievements.Notifications)
|
|
{
|
|
std::string title = fmt::format(TRANSLATE_FS("Achievements", "Mastered {}"), s_game_title);
|
|
std::string message = fmt::format(
|
|
TRANSLATE_FS("Achievements", "{0}, {1}"),
|
|
TRANSLATE_PLURAL_STR("Achievements", "%n achievements", "Mastery popup",
|
|
s_game_summary.num_unlocked_achievements),
|
|
TRANSLATE_PLURAL_STR("Achievements", "%n points", "Mastery popup", s_game_summary.points_unlocked));
|
|
|
|
PostAchievementNotification("achievement_mastery", GAME_COMPLETE_NOTIFICATION_TIME,
|
|
std::move(title), std::move(message), s_game_icon);
|
|
}
|
|
}
|
|
|
|
void Achievements::HandleSubsetCompleteEvent(const rc_client_event_t* event)
|
|
{
|
|
const rc_client_subset_t* subset = event->subset;
|
|
Console.WriteLn("Achievements: Subset '%s' (%u) complete", subset->title, subset->id);
|
|
UpdateGameSummary();
|
|
|
|
if (EmuConfig.Achievements.Notifications)
|
|
{
|
|
std::string title = fmt::format(TRANSLATE_FS("Achievements", "Mastered {}"), subset->title);
|
|
std::string message = fmt::format(
|
|
TRANSLATE_FS("Achievements", "{0}, {1}"),
|
|
TRANSLATE_PLURAL_STR("Achievements", "%n achievements", "Mastery popup",
|
|
s_game_summary.num_unlocked_achievements),
|
|
TRANSLATE_PLURAL_STR("Achievements", "%n points", "Mastery popup", s_game_summary.points_unlocked));
|
|
|
|
std::string badge_path = GetSubsetBadgePath(subset);
|
|
|
|
PostAchievementNotification("achievement_subset_mastery", GAME_COMPLETE_NOTIFICATION_TIME,
|
|
std::move(title), std::move(message), std::move(badge_path));
|
|
}
|
|
}
|
|
|
|
void Achievements::HandleLeaderboardStartedEvent(const rc_client_event_t* event)
|
|
{
|
|
DevCon.WriteLn("Achievements: Leaderboard %u (%s) started", event->leaderboard->id, event->leaderboard->title);
|
|
|
|
if (EmuConfig.Achievements.LeaderboardNotifications)
|
|
{
|
|
std::string title = event->leaderboard->title;
|
|
std::string message = TRANSLATE_STR("Achievements", "Leaderboard attempt started.");
|
|
|
|
PostAchievementNotification(fmt::format("leaderboard_{}", event->leaderboard->id),
|
|
LEADERBOARD_STARTED_NOTIFICATION_TIME, std::move(title), std::move(message), s_game_icon);
|
|
}
|
|
}
|
|
|
|
void Achievements::HandleLeaderboardFailedEvent(const rc_client_event_t* event)
|
|
{
|
|
DevCon.WriteLn("Achievements: Leaderboard %u (%s) failed", event->leaderboard->id, event->leaderboard->title);
|
|
|
|
if (EmuConfig.Achievements.LeaderboardNotifications)
|
|
{
|
|
std::string title = event->leaderboard->title;
|
|
std::string message = TRANSLATE_STR("Achievements", "Leaderboard attempt failed.");
|
|
|
|
PostAchievementNotification(fmt::format("leaderboard_{}", event->leaderboard->id),
|
|
LEADERBOARD_FAILED_NOTIFICATION_TIME, std::move(title), std::move(message), s_game_icon);
|
|
}
|
|
}
|
|
|
|
void Achievements::HandleLeaderboardSubmittedEvent(const rc_client_event_t* event)
|
|
{
|
|
Console.WriteLn("Achievements: Leaderboard %u (%s) submitted", event->leaderboard->id, event->leaderboard->title);
|
|
|
|
if (EmuConfig.Achievements.LeaderboardNotifications)
|
|
{
|
|
static const char* value_strings[NUM_RC_CLIENT_LEADERBOARD_FORMATS] = {
|
|
TRANSLATE_NOOP("Achievements", "Your Time: {}{}"),
|
|
TRANSLATE_NOOP("Achievements", "Your Score: {}{}"),
|
|
TRANSLATE_NOOP("Achievements", "Your Value: {}{}"),
|
|
};
|
|
|
|
std::string title = event->leaderboard->title;
|
|
std::string message =
|
|
fmt::format(fmt::runtime(Host::TranslateToStringView("Achievements",
|
|
value_strings[std::min<u8>(event->leaderboard->format, NUM_RC_CLIENT_LEADERBOARD_FORMATS - 1)])),
|
|
event->leaderboard->tracker_value ? event->leaderboard->tracker_value : "Unknown",
|
|
EmuConfig.Achievements.SpectatorMode ? std::string_view() : TRANSLATE_SV("Achievements", " (Submitting)"));
|
|
|
|
PostAchievementNotification(fmt::format("leaderboard_{}", event->leaderboard->id),
|
|
EmuConfig.Achievements.LeaderboardsDuration, std::move(title), std::move(message), s_game_icon);
|
|
}
|
|
Achievements::PlayAchievementSound(EmuConfig.Achievements.LBSubmitSound, EmuConfig.Achievements.LBSubmitSoundName, DEFAULT_LBSUBMIT_SOUND_NAME);
|
|
}
|
|
|
|
void Achievements::HandleLeaderboardScoreboardEvent(const rc_client_event_t* event)
|
|
{
|
|
Console.WriteLn("Achievements: Leaderboard %u scoreboard rank %u of %u", event->leaderboard_scoreboard->leaderboard_id,
|
|
event->leaderboard_scoreboard->new_rank, event->leaderboard_scoreboard->num_entries);
|
|
|
|
if (EmuConfig.Achievements.LeaderboardNotifications)
|
|
{
|
|
static const char* value_strings[NUM_RC_CLIENT_LEADERBOARD_FORMATS] = {
|
|
TRANSLATE_NOOP("Achievements", "Your Time: {0} (Best: {1})"),
|
|
TRANSLATE_NOOP("Achievements", "Your Score: {0} (Best: {1})"),
|
|
TRANSLATE_NOOP("Achievements", "Your Value: {0} (Best: {1})"),
|
|
};
|
|
|
|
std::string title = event->leaderboard->title;
|
|
std::string message = fmt::format(TRANSLATE_FS("Achievements", "{0}\nLeaderboard Position: {1} of {2}"),
|
|
fmt::format(fmt::runtime(Host::TranslateToStringView("Achievements",
|
|
value_strings[std::min<u8>(event->leaderboard->format, NUM_RC_CLIENT_LEADERBOARD_FORMATS - 1)])),
|
|
event->leaderboard_scoreboard->submitted_score, event->leaderboard_scoreboard->best_score),
|
|
event->leaderboard_scoreboard->new_rank, event->leaderboard_scoreboard->num_entries);
|
|
|
|
PostAchievementNotification(fmt::format("leaderboard_{}", event->leaderboard->id),
|
|
EmuConfig.Achievements.LeaderboardsDuration, std::move(title), std::move(message), s_game_icon);
|
|
}
|
|
}
|
|
|
|
void Achievements::HandleLeaderboardTrackerShowEvent(const rc_client_event_t* event)
|
|
{
|
|
DevCon.WriteLn(
|
|
"Achievements: Showing leaderboard tracker: %u: %s", event->leaderboard_tracker->id, event->leaderboard_tracker->display);
|
|
|
|
LeaderboardTrackerIndicator indicator;
|
|
indicator.tracker_id = event->leaderboard_tracker->id;
|
|
indicator.text = event->leaderboard_tracker->display;
|
|
indicator.active = true;
|
|
s_active_leaderboard_trackers.push_back(std::move(indicator));
|
|
}
|
|
|
|
void Achievements::HandleLeaderboardTrackerHideEvent(const rc_client_event_t* event)
|
|
{
|
|
const u32 id = event->leaderboard_tracker->id;
|
|
auto it = std::find_if(
|
|
s_active_leaderboard_trackers.begin(), s_active_leaderboard_trackers.end(), [id](const auto& it) { return it.tracker_id == id; });
|
|
if (it == s_active_leaderboard_trackers.end())
|
|
return;
|
|
|
|
DevCon.WriteLn("Achievements: Hiding leaderboard tracker: %u", id);
|
|
it->active = false;
|
|
it->show_hide_time.Reset();
|
|
}
|
|
|
|
void Achievements::HandleLeaderboardTrackerUpdateEvent(const rc_client_event_t* event)
|
|
{
|
|
const u32 id = event->leaderboard_tracker->id;
|
|
auto it = std::find_if(
|
|
s_active_leaderboard_trackers.begin(), s_active_leaderboard_trackers.end(), [id](const auto& it) { return it.tracker_id == id; });
|
|
if (it == s_active_leaderboard_trackers.end())
|
|
return;
|
|
|
|
DevCon.WriteLn(
|
|
"Achievements: Updating leaderboard tracker: %u: %s", event->leaderboard_tracker->id, event->leaderboard_tracker->display);
|
|
|
|
it->text = event->leaderboard_tracker->display;
|
|
}
|
|
|
|
void Achievements::HandleAchievementChallengeIndicatorShowEvent(const rc_client_event_t* event)
|
|
{
|
|
if (auto it = std::find_if(s_active_challenge_indicators.begin(), s_active_challenge_indicators.end(),
|
|
[event](const AchievementChallengeIndicator& it) { return it.achievement == event->achievement; });
|
|
it != s_active_challenge_indicators.end())
|
|
{
|
|
it->show_hide_time.Reset();
|
|
it->active = true;
|
|
return;
|
|
}
|
|
|
|
AchievementChallengeIndicator indicator;
|
|
indicator.achievement = event->achievement;
|
|
indicator.badge_path = GetAchievementBadgePath(event->achievement, RC_CLIENT_ACHIEVEMENT_STATE_UNLOCKED);
|
|
indicator.active = true;
|
|
s_active_challenge_indicators.push_back(std::move(indicator));
|
|
|
|
DevCon.WriteLn("Achievements: Show challenge indicator for %u (%s)", event->achievement->id, event->achievement->title);
|
|
}
|
|
|
|
void Achievements::HandleAchievementChallengeIndicatorHideEvent(const rc_client_event_t* event)
|
|
{
|
|
auto it = std::find_if(s_active_challenge_indicators.begin(), s_active_challenge_indicators.end(),
|
|
[event](const AchievementChallengeIndicator& it) { return it.achievement == event->achievement; });
|
|
if (it == s_active_challenge_indicators.end())
|
|
return;
|
|
|
|
DevCon.WriteLn("Achievements: Hide challenge indicator for %u (%s)", event->achievement->id, event->achievement->title);
|
|
it->show_hide_time.Reset();
|
|
it->active = false;
|
|
}
|
|
|
|
void Achievements::HandleAchievementProgressIndicatorShowEvent(const rc_client_event_t* event)
|
|
{
|
|
DevCon.WriteLn("Achievements: Showing progress indicator: %u (%s): %s", event->achievement->id, event->achievement->title,
|
|
event->achievement->measured_progress);
|
|
|
|
if (!s_active_progress_indicator.has_value())
|
|
s_active_progress_indicator.emplace();
|
|
else
|
|
s_active_progress_indicator->show_hide_time.Reset();
|
|
|
|
s_active_progress_indicator->achievement = event->achievement;
|
|
s_active_progress_indicator->badge_path = GetAchievementBadgePath(event->achievement, RC_CLIENT_ACHIEVEMENT_STATE_UNLOCKED);
|
|
s_active_progress_indicator->active = true;
|
|
}
|
|
|
|
void Achievements::HandleAchievementProgressIndicatorHideEvent(const rc_client_event_t* event)
|
|
{
|
|
if (!s_active_progress_indicator.has_value())
|
|
return;
|
|
|
|
DevCon.WriteLn("Achievements: Hiding progress indicator");
|
|
s_active_progress_indicator->show_hide_time.Reset();
|
|
s_active_progress_indicator->active = false;
|
|
}
|
|
|
|
void Achievements::HandleAchievementProgressIndicatorUpdateEvent(const rc_client_event_t* event)
|
|
{
|
|
DevCon.WriteLn("Achievements: Updating progress indicator: %u (%s): %s", event->achievement->id, event->achievement->title,
|
|
event->achievement->measured_progress);
|
|
s_active_progress_indicator->achievement = event->achievement;
|
|
s_active_progress_indicator->active = true;
|
|
}
|
|
|
|
void Achievements::HandleServerErrorEvent(const rc_client_event_t* event)
|
|
{
|
|
std::string message = fmt::format(TRANSLATE_FS("Achievements", "Server error in {0}:\n{1}"),
|
|
event->server_error->api ? event->server_error->api : "UNKNOWN",
|
|
event->server_error->error_message ? event->server_error->error_message : "UNKNOWN");
|
|
Console.Error("Achievements: %s", message.c_str());
|
|
Host::AddOSDMessage(std::move(message), Host::OSD_ERROR_DURATION);
|
|
}
|
|
|
|
void Achievements::HandleServerDisconnectedEvent(const rc_client_event_t* event)
|
|
{
|
|
Console.Warning("Achievements: Server disconnected.");
|
|
|
|
PostAchievementNotification("achievements_disconnect", Host::OSD_ERROR_DURATION,
|
|
TRANSLATE_STR("Achievements", "Achievements Disconnected"),
|
|
TRANSLATE_STR("Achievements", "An unlock request could not be completed. We will keep retrying to submit this request."),
|
|
s_game_icon);
|
|
}
|
|
|
|
void Achievements::HandleServerReconnectedEvent(const rc_client_event_t* event)
|
|
{
|
|
Console.Warning("Achievements: Server reconnected.");
|
|
|
|
PostAchievementNotification("achievements_reconnect", Host::OSD_INFO_DURATION,
|
|
TRANSLATE_STR("Achievements", "Achievements Reconnected"),
|
|
TRANSLATE_STR("Achievements", "All pending unlock requests have completed."),
|
|
s_game_icon);
|
|
}
|
|
|
|
|
|
void Achievements::ResetClient()
|
|
{
|
|
#ifdef ENABLE_RAINTEGRATION
|
|
if (IsUsingRAIntegration())
|
|
{
|
|
RA_OnReset();
|
|
return;
|
|
}
|
|
#endif
|
|
|
|
if (!IsActive())
|
|
return;
|
|
|
|
Console.WriteLn("Achievements: Reset client");
|
|
rc_client_reset(s_client);
|
|
}
|
|
|
|
void Achievements::OnVMPaused(bool paused)
|
|
{
|
|
#ifdef ENABLE_RAINTEGRATION
|
|
if (IsUsingRAIntegration())
|
|
RA_SetPaused(paused);
|
|
#endif
|
|
}
|
|
|
|
void Achievements::DisableHardcoreMode()
|
|
{
|
|
if (!IsActive())
|
|
return;
|
|
|
|
#ifdef ENABLE_RAINTEGRATION
|
|
if (IsUsingRAIntegration())
|
|
{
|
|
if (RA_HardcoreModeIsActive())
|
|
RA_DisableHardcore();
|
|
|
|
if (s_hardcore_mode && !RA_HardcoreModeIsActive())
|
|
{
|
|
s_hardcore_mode = false;
|
|
Host::OnAchievementsHardcoreModeChanged(false);
|
|
}
|
|
return;
|
|
}
|
|
#endif
|
|
|
|
if (s_hardcore_mode)
|
|
SetHardcoreMode(false, true);
|
|
}
|
|
|
|
bool Achievements::ResetHardcoreMode(bool is_booting)
|
|
{
|
|
if (!IsActive())
|
|
return false;
|
|
|
|
#ifdef ENABLE_RAINTEGRATION
|
|
if (IsUsingRAIntegration())
|
|
{
|
|
const bool ra_hardcore = (RA_HardcoreModeIsActive() != 0);
|
|
if (ra_hardcore == s_hardcore_mode)
|
|
return false;
|
|
s_hardcore_mode = ra_hardcore;
|
|
Host::OnAchievementsHardcoreModeChanged(ra_hardcore);
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
const auto lock = GetLock();
|
|
|
|
// If we're not logged in, don't apply hardcore mode restrictions.
|
|
// If we later log in, we'll start with it off anyway.
|
|
// If we're running an unknown game, don't enable HC mode. We have to do this here,
|
|
// because the gameid can be cached, and identify immediately on GameChanged(),
|
|
// which gets called before ResetHardcoreMode().
|
|
const bool wanted_hardcore_mode = (IsLoggedInOrLoggingIn() || s_load_game_request) &&
|
|
EmuConfig.Achievements.HardcoreMode;
|
|
if (s_hardcore_mode == wanted_hardcore_mode)
|
|
return false;
|
|
|
|
if (!is_booting && wanted_hardcore_mode && !CanEnableHardcoreMode())
|
|
return false;
|
|
|
|
SetHardcoreMode(wanted_hardcore_mode, false);
|
|
return true;
|
|
}
|
|
|
|
void Achievements::SetHardcoreMode(bool enabled, bool force_display_message)
|
|
{
|
|
if (enabled == s_hardcore_mode)
|
|
return;
|
|
|
|
// new mode
|
|
s_hardcore_mode = enabled;
|
|
|
|
if (VMManager::HasValidVM() && (HasActiveGame() || force_display_message) &&
|
|
!Host::HasNativeAchievementNotifications())
|
|
{
|
|
MTGS::RunOnGSThread([enabled]() {
|
|
if (ImGuiManager::InitializeFullscreenUI())
|
|
{
|
|
Host::AddIconOSDMessage("hardcore_status", ICON_PF_DUMBELL,
|
|
enabled ? TRANSLATE_STR("Achievements", "Hardcore mode is now enabled.") :
|
|
TRANSLATE_STR("Achievements", "Hardcore mode is now disabled."),
|
|
Host::OSD_INFO_DURATION);
|
|
}
|
|
});
|
|
}
|
|
|
|
rc_client_set_hardcore_enabled(s_client, enabled);
|
|
pxAssert((rc_client_get_hardcore_enabled(s_client) != 0) == enabled);
|
|
if (HasActiveGame())
|
|
{
|
|
UpdateGameSummary();
|
|
DisplayAchievementSummary();
|
|
}
|
|
|
|
// Toss away UI state, because it's invalid now
|
|
if (MTGS::IsOpen())
|
|
MTGS::RunOnGSThread(&Achievements::ClearUIState);
|
|
|
|
Host::OnAchievementsHardcoreModeChanged(enabled);
|
|
}
|
|
|
|
void Achievements::LoadState(std::span<const u8> data)
|
|
{
|
|
const auto lock = GetLock();
|
|
|
|
if (!IsActive())
|
|
return;
|
|
|
|
// this assumes that the CRC and ELF name has been loaded prior to the cheevos state (it should be).
|
|
GameChanged(VMManager::GetDiscCRC(), VMManager::GetCurrentCRC());
|
|
|
|
#ifdef ENABLE_RAINTEGRATION
|
|
if (IsUsingRAIntegration())
|
|
{
|
|
if (data.empty())
|
|
{
|
|
Console.Warning("State is missing cheevos data, resetting RAIntegration");
|
|
RA_OnReset();
|
|
}
|
|
else
|
|
{
|
|
RA_RestoreState(reinterpret_cast<const char*>(data.data()));
|
|
}
|
|
|
|
return;
|
|
}
|
|
#endif
|
|
|
|
// if we're active, make sure we've downloaded and activated all the achievements
|
|
// before deserializing, otherwise that state's going to get lost.
|
|
if (s_http_downloader->HasAnyRequests())
|
|
{
|
|
bool was_running_idle;
|
|
BeginLoadingScreen("Downloading achievements data...", &was_running_idle);
|
|
s_http_downloader->WaitForAllRequests();
|
|
EndLoadingScreen(was_running_idle);
|
|
}
|
|
|
|
if (data.empty())
|
|
{
|
|
// reset runtime, no data (state might've been created without cheevos)
|
|
Console.Warning("State is missing cheevos data, resetting runtime");
|
|
rc_client_reset(s_client);
|
|
return;
|
|
}
|
|
|
|
// These routines scare me a bit.. the data isn't bounds checked.
|
|
// Really hope that nobody puts any thing malicious in a save state...
|
|
const int result = rc_client_deserialize_progress_sized(s_client, data.data(), data.size());
|
|
if (result != RC_OK)
|
|
{
|
|
Console.Warning("Failed to deserialize cheevos state (%d), resetting", result);
|
|
rc_client_reset(s_client);
|
|
}
|
|
}
|
|
|
|
void Achievements::SaveState(SaveStateBase& writer)
|
|
{
|
|
const auto lock = GetLock();
|
|
|
|
#ifdef ENABLE_RAINTEGRATION
|
|
if (IsUsingRAIntegration())
|
|
{
|
|
const int size = RA_CaptureState(nullptr, 0);
|
|
|
|
const u32 data_size = (size >= 0) ? static_cast<u32>(size) : 0;
|
|
if (data_size > 0)
|
|
{
|
|
writer.PrepBlock(static_cast<int>(data_size));
|
|
|
|
const int result = RA_CaptureState(reinterpret_cast<char*>(writer.GetBlockPtr()), static_cast<int>(data_size));
|
|
if (result != static_cast<int>(data_size))
|
|
Console.Warning("Failed to serialize cheevos state from RAIntegration.");
|
|
else
|
|
writer.CommitBlock(static_cast<int>(data_size));
|
|
}
|
|
|
|
return;
|
|
}
|
|
#endif
|
|
|
|
if (IsActive())
|
|
{
|
|
// internally this happens twice.. not great.
|
|
const size_t data_size = rc_client_progress_size(s_client);
|
|
if (data_size > 0)
|
|
{
|
|
writer.PrepBlock(static_cast<int>(data_size));
|
|
|
|
const int result = rc_client_serialize_progress_sized(s_client, writer.GetBlockPtr(), data_size);
|
|
if (result != RC_OK)
|
|
Console.Warning("Failed to serialize cheevos state (%d)", result);
|
|
else
|
|
writer.CommitBlock(static_cast<int>(data_size));
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
std::string Achievements::GetAchievementBadgePath(const rc_client_achievement_t* achievement, int state)
|
|
{
|
|
std::string path;
|
|
|
|
const std::string_view badge_name(achievement->badge_name);
|
|
if (badge_name.empty())
|
|
return path;
|
|
|
|
const std::string_view suffix = state == RC_CLIENT_ACHIEVEMENT_STATE_UNLOCKED ? "" : "_lock";
|
|
|
|
path = Path::Combine(s_image_directory,
|
|
TinyString::from_format("achievement_{}{}.png", badge_name, suffix));
|
|
|
|
if (!FileSystem::FileExists(path.c_str()))
|
|
{
|
|
char buf[URL_BUFFER_SIZE];
|
|
const int res = rc_client_achievement_get_image_url(achievement, state, buf, std::size(buf));
|
|
if (res == RC_OK)
|
|
DownloadImage(buf, path);
|
|
else
|
|
ReportRCError(res, "rc_client_achievement_get_image_url() for {} failed", achievement->title);
|
|
}
|
|
|
|
return path;
|
|
}
|
|
|
|
std::string Achievements::GetUserBadgePath(const std::string_view username)
|
|
{
|
|
// definitely want to sanitize usernames... :)
|
|
std::string path;
|
|
const std::string clean_username = Path::SanitizeFileName(username);
|
|
if (!clean_username.empty())
|
|
path = Path::Combine(s_image_directory, TinyString::from_format("user_{}.png", clean_username));
|
|
return path;
|
|
}
|
|
|
|
std::string Achievements::GetLeaderboardUserBadgePath(const rc_client_leaderboard_entry_t* entry)
|
|
{
|
|
// TODO: maybe we should just cache these in memory...
|
|
std::string path = GetUserBadgePath(entry->user);
|
|
|
|
if (!FileSystem::FileExists(path.c_str()))
|
|
{
|
|
char buf[URL_BUFFER_SIZE];
|
|
const int res = rc_client_leaderboard_entry_get_user_image_url(entry, buf, std::size(buf));
|
|
if (res == RC_OK)
|
|
DownloadImage(buf, path);
|
|
else
|
|
ReportRCError(res, "rc_client_leaderboard_entry_get_user_image_url() for {} failed", entry->user);
|
|
}
|
|
|
|
return path;
|
|
}
|
|
|
|
bool Achievements::IsLoggedInOrLoggingIn()
|
|
{
|
|
return (rc_client_get_user_info(s_client) != nullptr || s_login_request);
|
|
}
|
|
|
|
bool Achievements::CanEnableHardcoreMode()
|
|
{
|
|
return (s_load_game_request || s_has_achievements || s_has_leaderboards);
|
|
}
|
|
|
|
bool Achievements::Login(const char* username, const char* password, Error* error)
|
|
{
|
|
auto lock = GetLock();
|
|
|
|
// We need to use a temporary client if achievements aren't currently active.
|
|
rc_client_t* client = s_client;
|
|
HTTPDownloader* http = s_http_downloader.get();
|
|
const bool is_temporary_client = (client == nullptr);
|
|
std::unique_ptr<HTTPDownloader> temporary_downloader;
|
|
ScopedGuard temporary_client_guard = [&client, is_temporary_client, &temporary_downloader]() {
|
|
if (is_temporary_client)
|
|
DestroyClient(&client, &temporary_downloader);
|
|
};
|
|
if (is_temporary_client)
|
|
{
|
|
if (!CreateClient(&client, &temporary_downloader))
|
|
{
|
|
Error::SetString(error, "Failed to create client.");
|
|
return false;
|
|
}
|
|
http = temporary_downloader.get();
|
|
}
|
|
|
|
LoginWithPasswordParameters params = {username, error, nullptr, false};
|
|
|
|
params.request = rc_client_begin_login_with_password(client, username, password, ClientLoginWithPasswordCallback, ¶ms);
|
|
if (!params.request)
|
|
{
|
|
Error::SetString(error, "Failed to create login request.");
|
|
return false;
|
|
}
|
|
|
|
// Wait until the login request completes.
|
|
http->WaitForAllRequests();
|
|
pxAssert(!params.request);
|
|
|
|
// Success? Assume the callback set the error message.
|
|
if (!params.result)
|
|
return false;
|
|
|
|
// If we were't a temporary client, get the game loaded.
|
|
if (VMManager::HasValidVM() && !is_temporary_client)
|
|
BeginLoadGame();
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
void Achievements::ClientLoginWithPasswordCallback(int result, const char* error_message, rc_client_t* client, void* userdata)
|
|
{
|
|
pxAssert(userdata);
|
|
|
|
LoginWithPasswordParameters* params = static_cast<LoginWithPasswordParameters*>(userdata);
|
|
params->request = nullptr;
|
|
|
|
if (result != RC_OK)
|
|
{
|
|
Console.Error("Login failed: %s: %s", rc_error_str(result), error_message ? error_message : "Unknown");
|
|
Error::SetString(params->error, fmt::format("{}: {}", rc_error_str(result), error_message ? error_message : "Unknown"));
|
|
params->result = false;
|
|
return;
|
|
}
|
|
|
|
// Grab the token from the client, and save it to the config.
|
|
const rc_client_user_t* user = rc_client_get_user_info(client);
|
|
if (!user || !user->token)
|
|
{
|
|
Console.Error("rc_client_get_user_info() returned NULL");
|
|
Error::SetString(params->error, "rc_client_get_user_info() returned NULL");
|
|
params->result = false;
|
|
return;
|
|
}
|
|
|
|
params->result = true;
|
|
|
|
// Store configuration.
|
|
Host::SetBaseStringSettingValue("Achievements", "Username", params->username);
|
|
Host::SetBaseStringSettingValue("Achievements", "LoginTimestamp", fmt::format("{}", std::time(nullptr)).c_str());
|
|
Host::CommitBaseSettingChanges();
|
|
|
|
SettingsInterface* secretsInterface = Host::Internal::GetSecretsSettingsLayer();
|
|
secretsInterface->SetStringValue("Achievements", "Token", user->token);
|
|
secretsInterface->Save();
|
|
|
|
ShowLoginSuccess(client);
|
|
}
|
|
|
|
void Achievements::ClientLoginWithTokenCallback(int result, const char* error_message, rc_client_t* client, void* userdata)
|
|
{
|
|
s_login_request = nullptr;
|
|
|
|
if (result != RC_OK)
|
|
{
|
|
ReportFmtError("Login failed: {}", error_message);
|
|
Host::OnAchievementsLoginRequested(LoginRequestReason::TokenInvalid);
|
|
return;
|
|
}
|
|
|
|
ShowLoginSuccess(client);
|
|
|
|
if (VMManager::HasValidVM())
|
|
BeginLoadGame();
|
|
}
|
|
|
|
void Achievements::ShowLoginSuccess(const rc_client_t* client)
|
|
{
|
|
const rc_client_user_t* user = rc_client_get_user_info(client);
|
|
if (!user)
|
|
return;
|
|
|
|
Host::OnAchievementsLoginSuccess(user->username, user->score, user->score_softcore, user->num_unread_messages);
|
|
|
|
// Were we logging in with a temporary client?
|
|
const auto lock = GetLock();
|
|
if (s_client != client)
|
|
return;
|
|
|
|
if (EmuConfig.Achievements.Notifications && (Host::HasNativeAchievementNotifications() || MTGS::IsOpen()))
|
|
{
|
|
std::string badge_path = GetLoggedInUserBadgePath();
|
|
|
|
//: Summary for login notification.
|
|
std::string title = user->display_name;
|
|
std::string summary = fmt::format(TRANSLATE_FS("Achievements", "Score: {0} pts (Casual: {1} pts)\nUnread messages: {2}"), user->score,
|
|
user->score_softcore, user->num_unread_messages);
|
|
|
|
PostAchievementNotification("achievements_login", LOGIN_NOTIFICATION_TIME,
|
|
std::move(title), std::move(summary), std::move(badge_path));
|
|
}
|
|
}
|
|
|
|
const char* Achievements::GetLoggedInUserName()
|
|
{
|
|
const rc_client_user_t* user = rc_client_get_user_info(s_client);
|
|
if (!user) [[unlikely]]
|
|
return nullptr;
|
|
|
|
return user->username;
|
|
}
|
|
|
|
std::string Achievements::GetLoggedInUserBadgePath()
|
|
{
|
|
std::string badge_path;
|
|
|
|
const rc_client_user_t* user = rc_client_get_user_info(s_client);
|
|
if (!user) [[unlikely]]
|
|
return badge_path;
|
|
|
|
badge_path = GetUserBadgePath(user->username);
|
|
if (!FileSystem::FileExists(badge_path.c_str())) [[unlikely]]
|
|
{
|
|
char url[URL_BUFFER_SIZE];
|
|
const int res = rc_client_user_get_image_url(user, url, std::size(url));
|
|
if (res == RC_OK)
|
|
DownloadImage(url, badge_path);
|
|
else
|
|
ReportRCError(res, "rc_client_user_get_image_url() failed: ");
|
|
}
|
|
|
|
return badge_path;
|
|
}
|
|
|
|
void Achievements::Logout()
|
|
{
|
|
if (IsActive())
|
|
{
|
|
const auto lock = GetLock();
|
|
|
|
if (HasActiveGame())
|
|
ClearGameInfo();
|
|
|
|
Console.WriteLn("Achievements: Logging out...");
|
|
rc_client_logout(s_client);
|
|
}
|
|
|
|
Console.WriteLn("Achievements: Clearing credentials...");
|
|
Host::RemoveBaseSettingValue("Achievements", "Username");
|
|
Host::RemoveBaseSettingValue("Achievements", "LoginTimestamp");
|
|
Host::CommitBaseSettingChanges();
|
|
|
|
auto secretsLock = Host::GetSecretsSettingsLock();
|
|
SettingsInterface* secretsInterface = Host::Internal::GetSecretsSettingsLayer();
|
|
secretsInterface->DeleteValue("Achievements", "Token");
|
|
secretsInterface->Save();
|
|
}
|
|
|
|
|
|
bool Achievements::ConfirmSystemReset()
|
|
{
|
|
#ifdef ENABLE_RAINTEGRATION
|
|
if (IsUsingRAIntegration())
|
|
return RA_ConfirmLoadNewRom(false);
|
|
#endif
|
|
|
|
return true;
|
|
}
|
|
|
|
const char* Achievements::GetHardcoreModeDisableTitle()
|
|
{
|
|
return TRANSLATE("Achievements", "Confirm Hardcore Mode");
|
|
}
|
|
|
|
std::string Achievements::GetHardcoreModeDisableText(const char* reason)
|
|
{
|
|
return fmt::format(
|
|
TRANSLATE_FS("Achievements",
|
|
"{0} cannot be performed while hardcore mode is active. "
|
|
"Do you want to disable hardcore mode? "
|
|
"{0} will be cancelled if you select No."),
|
|
reason);
|
|
}
|
|
|
|
void Achievements::ClearUIState()
|
|
{
|
|
if (FullscreenUI::IsAchievementsWindowOpen() || FullscreenUI::IsLeaderboardsWindowOpen())
|
|
FullscreenUI::ReturnToMainWindow();
|
|
|
|
s_achievement_badge_paths = {};
|
|
|
|
CloseLeaderboard();
|
|
s_leaderboard_user_icon_paths = {};
|
|
s_leaderboard_entry_lists = {};
|
|
if (s_leaderboard_list)
|
|
{
|
|
rc_client_destroy_leaderboard_list(s_leaderboard_list);
|
|
s_leaderboard_list = nullptr;
|
|
}
|
|
|
|
if (s_achievement_list)
|
|
{
|
|
rc_client_destroy_achievement_list(s_achievement_list);
|
|
s_achievement_list = nullptr;
|
|
}
|
|
|
|
s_achievement_buckets_collapsed.clear();
|
|
s_leaderboard_buckets_collapsed.clear();
|
|
}
|
|
|
|
template <typename T>
|
|
static float IndicatorOpacity(const T& i)
|
|
{
|
|
const float elapsed = static_cast<float>(i.show_hide_time.GetTimeSeconds());
|
|
const float time = i.active ? Achievements::INDICATOR_FADE_IN_TIME : Achievements::INDICATOR_FADE_OUT_TIME;
|
|
const float opacity = (elapsed >= time) ? 1.0f : (elapsed / time);
|
|
return (i.active) ? opacity : (1.0f - opacity);
|
|
}
|
|
|
|
static ImVec2 CalculateOverlayPosition(const ImGuiIO& io, float padding, AchievementOverlayPosition alignment)
|
|
{
|
|
switch (alignment)
|
|
{
|
|
case AchievementOverlayPosition::TopLeft:
|
|
return ImVec2(padding, padding);
|
|
case AchievementOverlayPosition::TopCenter:
|
|
return ImVec2(io.DisplaySize.x * 0.5f, padding);
|
|
case AchievementOverlayPosition::TopRight:
|
|
return ImVec2(io.DisplaySize.x - padding, padding);
|
|
case AchievementOverlayPosition::CenterLeft:
|
|
return ImVec2(padding, io.DisplaySize.y * 0.5f);
|
|
case AchievementOverlayPosition::Center:
|
|
return ImVec2(io.DisplaySize.x * 0.5f, io.DisplaySize.y * 0.5f);
|
|
case AchievementOverlayPosition::CenterRight:
|
|
return ImVec2(io.DisplaySize.x - padding, io.DisplaySize.y * 0.5f);
|
|
case AchievementOverlayPosition::BottomLeft:
|
|
return ImVec2(padding, io.DisplaySize.y - padding);
|
|
case AchievementOverlayPosition::BottomCenter:
|
|
return ImVec2(io.DisplaySize.x * 0.5f, io.DisplaySize.y - padding);
|
|
case AchievementOverlayPosition::BottomRight:
|
|
default:
|
|
return ImVec2(io.DisplaySize.x - padding, io.DisplaySize.y - padding);
|
|
}
|
|
}
|
|
|
|
static ImVec2 AdjustPositionForAlignment(const ImVec2& base_position, const ImVec2& element_size, AchievementOverlayPosition alignment)
|
|
{
|
|
ImVec2 adjusted = base_position;
|
|
|
|
// Adjust for horizontal alignment
|
|
switch (alignment)
|
|
{
|
|
case AchievementOverlayPosition::TopLeft:
|
|
case AchievementOverlayPosition::CenterLeft:
|
|
case AchievementOverlayPosition::BottomLeft:
|
|
// Left aligned no adjustment needed for x
|
|
break;
|
|
|
|
case AchievementOverlayPosition::TopCenter:
|
|
case AchievementOverlayPosition::Center:
|
|
case AchievementOverlayPosition::BottomCenter:
|
|
// Center aligned offset by half element width
|
|
adjusted.x -= element_size.x * 0.5f;
|
|
break;
|
|
|
|
case AchievementOverlayPosition::TopRight:
|
|
case AchievementOverlayPosition::CenterRight:
|
|
case AchievementOverlayPosition::BottomRight:
|
|
default:
|
|
// Right aligned offset by full element width
|
|
adjusted.x -= element_size.x;
|
|
break;
|
|
}
|
|
|
|
// Adjust for vertical alignment
|
|
switch (alignment)
|
|
{
|
|
case AchievementOverlayPosition::TopLeft:
|
|
case AchievementOverlayPosition::TopCenter:
|
|
case AchievementOverlayPosition::TopRight:
|
|
// Top aligned no adjustment needed for y
|
|
break;
|
|
|
|
case AchievementOverlayPosition::CenterLeft:
|
|
case AchievementOverlayPosition::Center:
|
|
case AchievementOverlayPosition::CenterRight:
|
|
// Center aligned offset by half element height
|
|
adjusted.y -= element_size.y * 0.5f;
|
|
break;
|
|
|
|
case AchievementOverlayPosition::BottomLeft:
|
|
case AchievementOverlayPosition::BottomCenter:
|
|
case AchievementOverlayPosition::BottomRight:
|
|
default:
|
|
// Bottom aligned offset by full element height
|
|
adjusted.y -= element_size.y;
|
|
break;
|
|
}
|
|
|
|
return adjusted;
|
|
}
|
|
|
|
static ImVec2 GetStackingDirection(AchievementOverlayPosition alignment)
|
|
{
|
|
switch (alignment)
|
|
{
|
|
case AchievementOverlayPosition::TopLeft:
|
|
case AchievementOverlayPosition::TopCenter:
|
|
case AchievementOverlayPosition::TopRight:
|
|
return ImVec2(0.0f, 1.0f); // Stack downward
|
|
|
|
case AchievementOverlayPosition::BottomLeft:
|
|
case AchievementOverlayPosition::BottomCenter:
|
|
case AchievementOverlayPosition::BottomRight:
|
|
default:
|
|
return ImVec2(0.0f, -1.0f); // Stack upward
|
|
|
|
case AchievementOverlayPosition::CenterLeft:
|
|
return ImVec2(1.0f, 0.0f); // Stack rightward
|
|
|
|
case AchievementOverlayPosition::CenterRight:
|
|
return ImVec2(-1.0f, 0.0f); // Stack leftward
|
|
|
|
case AchievementOverlayPosition::Center:
|
|
return ImVec2(0.0f, -1.0f); // Stack upward for center
|
|
}
|
|
}
|
|
|
|
|
|
void Achievements::DrawGameOverlays()
|
|
{
|
|
using ImGuiFullscreen::g_medium_font;
|
|
using ImGuiFullscreen::LayoutScale;
|
|
|
|
if (!HasActiveGame() || !(EmuConfig.Achievements.Overlays || EmuConfig.Achievements.LBOverlays))
|
|
return;
|
|
|
|
const auto lock = GetLock();
|
|
|
|
const float spacing = LayoutScale(10.0f);
|
|
const float padding = LayoutScale(10.0f);
|
|
const ImVec2 image_size = LayoutScale(ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT, ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT);
|
|
const ImGuiIO& io = ImGui::GetIO();
|
|
ImVec2 position = CalculateOverlayPosition(io, padding, EmuConfig.Achievements.OverlayPosition);
|
|
ImDrawList* dl = ImGui::GetBackgroundDrawList();
|
|
|
|
if (!s_active_challenge_indicators.empty() && EmuConfig.Achievements.Overlays)
|
|
{
|
|
const ImVec2 stack_direction = GetStackingDirection(EmuConfig.Achievements.OverlayPosition);
|
|
ImVec2 current_position = AdjustPositionForAlignment(position, image_size, EmuConfig.Achievements.OverlayPosition);
|
|
const float x_advance = image_size.x + spacing;
|
|
|
|
for (auto it = s_active_challenge_indicators.begin(); it != s_active_challenge_indicators.end();)
|
|
{
|
|
const AchievementChallengeIndicator& indicator = *it;
|
|
const float opacity = IndicatorOpacity(indicator);
|
|
const u32 col = ImGui::GetColorU32(ImVec4(1.0f, 1.0f, 1.0f, opacity));
|
|
|
|
GSTexture* badge = ImGuiFullscreen::GetCachedTextureAsync(indicator.badge_path.c_str());
|
|
if (badge)
|
|
{
|
|
dl->AddImage(reinterpret_cast<ImTextureID>(badge->GetNativeHandle()),
|
|
current_position, current_position + image_size, ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f), col);
|
|
|
|
// For horizontal layouts, go horizontally for vertical layouts, stay in the same row
|
|
if (std::abs(stack_direction.x) > 0.0f)
|
|
{
|
|
current_position.x += stack_direction.x * x_advance;
|
|
}
|
|
else
|
|
{
|
|
// For right-aligned vertical layouts, move left for next indicator
|
|
switch (EmuConfig.Achievements.OverlayPosition)
|
|
{
|
|
case AchievementOverlayPosition::TopRight:
|
|
case AchievementOverlayPosition::BottomRight:
|
|
current_position.x -= x_advance;
|
|
break;
|
|
default:
|
|
current_position.x += x_advance;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!indicator.active && opacity <= 0.01f)
|
|
{
|
|
DevCon.WriteLn("Achievements: Remove challenge indicator");
|
|
it = s_active_challenge_indicators.erase(it);
|
|
}
|
|
else
|
|
{
|
|
++it;
|
|
}
|
|
}
|
|
|
|
// Go to the next row/column for anymore overlays
|
|
position.x += stack_direction.x * (image_size.x + padding);
|
|
position.y += stack_direction.y * (image_size.y + padding);
|
|
}
|
|
|
|
if (s_active_progress_indicator.has_value() && EmuConfig.Achievements.Overlays)
|
|
{
|
|
const AchievementProgressIndicator& indicator = s_active_progress_indicator.value();
|
|
const float opacity = IndicatorOpacity(indicator);
|
|
const u32 col = ImGui::GetColorU32(ImVec4(1.0f, 1.0f, 1.0f, opacity));
|
|
|
|
const char* text_start = s_active_progress_indicator->achievement->measured_progress;
|
|
const char* text_end = text_start + std::strlen(text_start);
|
|
const ImVec2 text_size = g_medium_font.first->CalcTextSizeA(g_medium_font.second, FLT_MAX, 0.0f, text_start, text_end);
|
|
|
|
const ImVec2 progress_box_size = ImVec2(image_size.x + text_size.x + spacing + padding * 2.0f, image_size.y + padding * 2.0f);
|
|
const ImVec2 stack_direction = GetStackingDirection(EmuConfig.Achievements.OverlayPosition);
|
|
const ImVec2 box_position = AdjustPositionForAlignment(position, progress_box_size, EmuConfig.Achievements.OverlayPosition);
|
|
|
|
const ImVec2 box_min = box_position;
|
|
const ImVec2 box_max = box_position + progress_box_size;
|
|
const float box_rounding = LayoutScale(1.0f);
|
|
|
|
dl->AddRectFilled(box_min, box_max, ImGui::GetColorU32(ImVec4(0.13f, 0.13f, 0.13f, opacity * 0.5f)), box_rounding);
|
|
dl->AddRect(box_min, box_max, ImGui::GetColorU32(ImVec4(0.8f, 0.8f, 0.8f, opacity)), box_rounding);
|
|
|
|
GSTexture* badge = ImGuiFullscreen::GetCachedTextureAsync(indicator.badge_path.c_str());
|
|
if (badge)
|
|
{
|
|
const ImVec2 badge_pos = box_min + ImVec2(padding, padding);
|
|
dl->AddImage(reinterpret_cast<ImTextureID>(badge->GetNativeHandle()),
|
|
badge_pos, badge_pos + image_size, ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f), col);
|
|
}
|
|
|
|
const ImVec2 text_pos = box_min + ImVec2(padding + image_size.x + spacing, (box_max.y - box_min.y - text_size.y) * 0.5f);
|
|
const ImVec4 text_clip_rect(text_pos.x, text_pos.y, box_max.x, box_max.y);
|
|
dl->AddText(g_medium_font.first, g_medium_font.second, text_pos, col, text_start, text_end, 0.0f, &text_clip_rect);
|
|
|
|
if (!indicator.active && opacity <= 0.01f)
|
|
{
|
|
DevCon.WriteLn("Achievements: Remove progress indicator");
|
|
s_active_progress_indicator.reset();
|
|
}
|
|
|
|
// Go to the next row/column for anymore overlays
|
|
position.x += stack_direction.x * (progress_box_size.x + padding);
|
|
position.y += stack_direction.y * (progress_box_size.y + padding);
|
|
}
|
|
|
|
if (!s_active_leaderboard_trackers.empty() && EmuConfig.Achievements.LBOverlays)
|
|
{
|
|
const ImVec2 stack_direction = GetStackingDirection(EmuConfig.Achievements.OverlayPosition);
|
|
|
|
for (auto it = s_active_leaderboard_trackers.begin(); it != s_active_leaderboard_trackers.end();)
|
|
{
|
|
const LeaderboardTrackerIndicator& indicator = *it;
|
|
const float opacity = IndicatorOpacity(indicator);
|
|
|
|
TinyString width_string;
|
|
width_string.append(ICON_FA_STOPWATCH);
|
|
for (u32 i = 0; i < indicator.text.length(); i++)
|
|
width_string.append('0');
|
|
const ImVec2 size = g_medium_font.first->CalcTextSizeA(
|
|
g_medium_font.second, FLT_MAX, 0.0f, width_string.c_str(), width_string.end_ptr());
|
|
|
|
const ImVec2 tracker_box_size = ImVec2(size.x + padding * 2.0f, size.y + padding * 2.0f);
|
|
const ImVec2 box_position = AdjustPositionForAlignment(position, tracker_box_size, EmuConfig.Achievements.OverlayPosition);
|
|
|
|
const ImVec2 box_min = box_position;
|
|
const ImVec2 box_max = box_position + tracker_box_size;
|
|
const float box_rounding = LayoutScale(1.0f);
|
|
dl->AddRectFilled(box_min, box_max, ImGui::GetColorU32(ImVec4(0.13f, 0.13f, 0.13f, opacity * 0.5f)), box_rounding);
|
|
dl->AddRect(box_min, box_max, ImGui::GetColorU32(ImVec4(0.8f, 0.8f, 0.8f, opacity)), box_rounding);
|
|
|
|
const u32 text_col = ImGui::GetColorU32(ImVec4(1.0f, 1.0f, 1.0f, opacity));
|
|
const ImVec2 text_size = g_medium_font.first->CalcTextSizeA(
|
|
g_medium_font.second, FLT_MAX, 0.0f, indicator.text.c_str(),
|
|
indicator.text.c_str() + indicator.text.length());
|
|
const ImVec2 text_pos = ImVec2(box_max.x - padding - text_size.x, box_min.y + padding);
|
|
const ImVec4 text_clip_rect(box_min.x, box_min.y, box_max.x, box_max.y);
|
|
dl->AddText(g_medium_font.first, g_medium_font.second, text_pos, text_col, indicator.text.c_str(),
|
|
indicator.text.c_str() + indicator.text.length(), 0.0f, &text_clip_rect);
|
|
|
|
const ImVec2 icon_pos = ImVec2(box_min.x + padding, box_min.y + padding);
|
|
dl->AddText(g_medium_font.first, g_medium_font.second, icon_pos, text_col, ICON_FA_STOPWATCH,
|
|
nullptr, 0.0f, &text_clip_rect);
|
|
|
|
if (!indicator.active && opacity <= 0.01f)
|
|
{
|
|
DevCon.WriteLn("Achievements: Remove tracker indicator");
|
|
it = s_active_leaderboard_trackers.erase(it);
|
|
}
|
|
else
|
|
{
|
|
++it;
|
|
}
|
|
|
|
// Go to the next position for anymore trackers
|
|
position.x += stack_direction.x * (tracker_box_size.x + padding);
|
|
position.y += stack_direction.y * (tracker_box_size.y + padding);
|
|
}
|
|
|
|
// Uncomment if there are any other overlays above this one.
|
|
//position.y -= image_size.y + padding * 3.0f;
|
|
}
|
|
}
|
|
|
|
void Achievements::DrawPauseMenuOverlays()
|
|
{
|
|
using ImGuiFullscreen::g_large_font;
|
|
using ImGuiFullscreen::g_medium_font;
|
|
using ImGuiFullscreen::GetLineHeight;
|
|
using ImGuiFullscreen::LayoutScale;
|
|
|
|
if (!HasActiveGame())
|
|
return;
|
|
|
|
const auto lock = GetLock();
|
|
|
|
if (s_active_challenge_indicators.empty() && !s_active_progress_indicator.has_value())
|
|
return;
|
|
|
|
const ImGuiIO& io = ImGui::GetIO();
|
|
const std::pair<ImFont*, float> font = g_medium_font;
|
|
|
|
const ImVec2 image_size(
|
|
LayoutScale(ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY, ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY));
|
|
const float start_y = LayoutScale(10.0f + 4.0f + 4.0f) + GetLineHeight(g_large_font) + (GetLineHeight(g_medium_font) * 2.0f);
|
|
const float margin = LayoutScale(10.0f);
|
|
const float spacing = LayoutScale(10.0f);
|
|
const float padding = LayoutScale(10.0f);
|
|
|
|
const float max_text_width = ImGuiFullscreen::LayoutScale(300.0f);
|
|
const float row_width = max_text_width + padding + padding + image_size.x + spacing;
|
|
const float title_height = padding + GetLineHeight(font) + padding;
|
|
|
|
if (!s_active_challenge_indicators.empty())
|
|
{
|
|
const ImVec2 box_min(io.DisplaySize.x - row_width - margin, start_y + margin);
|
|
const ImVec2 box_max(box_min.x + row_width,
|
|
box_min.y + title_height + (static_cast<float>(s_active_challenge_indicators.size()) * (image_size.y + padding)));
|
|
|
|
ImDrawList* dl = ImGui::GetBackgroundDrawList();
|
|
dl->AddRectFilled(box_min, box_max, IM_COL32(0x21, 0x21, 0x21, 200), LayoutScale(10.0f));
|
|
dl->AddText(font.first, font.second, ImVec2(box_min.x + padding, box_min.y + padding), IM_COL32(255, 255, 255, 255),
|
|
TRANSLATE("Achievements", "Active Challenge Achievements"));
|
|
|
|
const float y_advance = image_size.y + spacing;
|
|
const float acheivement_name_offset = (image_size.y - GetLineHeight(font)) / 2.0f;
|
|
const float max_non_ellipised_text_width = max_text_width - LayoutScale(10.0f);
|
|
ImVec2 position(box_min.x + padding, box_min.y + title_height);
|
|
|
|
for (const AchievementChallengeIndicator& indicator : s_active_challenge_indicators)
|
|
{
|
|
GSTexture* badge = ImGuiFullscreen::GetCachedTextureAsync(indicator.badge_path.c_str());
|
|
if (!badge)
|
|
continue;
|
|
|
|
dl->AddImage(reinterpret_cast<ImTextureID>(badge->GetNativeHandle()), position, position + image_size);
|
|
|
|
const char* achievement_title = indicator.achievement->title;
|
|
const char* achievement_title_end = achievement_title + std::strlen(indicator.achievement->title);
|
|
const char* remaining_text = nullptr;
|
|
const ImVec2 text_width(font.first->CalcTextSizeA(
|
|
font.second, max_non_ellipised_text_width, 0.0f, achievement_title, achievement_title_end, &remaining_text));
|
|
const ImVec2 text_position(position.x + image_size.x + spacing, position.y + acheivement_name_offset);
|
|
const ImVec4 text_bbox(text_position.x, text_position.y, text_position.x + max_text_width, text_position.y + image_size.y);
|
|
const u32 text_color = IM_COL32(255, 255, 255, 255);
|
|
|
|
if (remaining_text < achievement_title_end)
|
|
{
|
|
dl->AddText(font.first, font.second, text_position, text_color, achievement_title, remaining_text, 0.0f, &text_bbox);
|
|
dl->AddText(font.first, font.second, ImVec2(text_position.x + text_width.x, text_position.y), text_color, "...", nullptr, 0.0f,
|
|
&text_bbox);
|
|
}
|
|
else
|
|
{
|
|
dl->AddText(font.first, font.second, text_position, text_color, achievement_title, achievement_title_end, 0.0f, &text_bbox);
|
|
}
|
|
|
|
position.y += y_advance;
|
|
}
|
|
}
|
|
}
|
|
|
|
bool Achievements::PrepareAchievementsWindow()
|
|
{
|
|
auto lock = Achievements::GetLock();
|
|
|
|
s_achievement_badge_paths = {};
|
|
|
|
if (s_subset_list)
|
|
{
|
|
rc_client_destroy_subset_list(s_subset_list);
|
|
s_subset_list = nullptr;
|
|
}
|
|
CloseSubset();
|
|
|
|
s_subset_list = rc_client_create_subset_list(s_client);
|
|
if (!s_subset_list)
|
|
Console.Warning("Achievements: rc_client_create_subset_list() returned null");
|
|
s_achievement_buckets_collapsed.clear();
|
|
|
|
if (s_achievement_list)
|
|
rc_client_destroy_achievement_list(s_achievement_list);
|
|
s_achievement_list = rc_client_create_achievement_list(s_client, RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE_AND_UNOFFICIAL,
|
|
RC_CLIENT_ACHIEVEMENT_LIST_GROUPING_PROGRESS /*RC_CLIENT_ACHIEVEMENT_LIST_GROUPING_LOCK_STATE*/);
|
|
if (!s_achievement_list)
|
|
{
|
|
Console.Error("Achievements: rc_client_create_achievement_list() returned null");
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
bool Achievements::DrawSubsetSidebar(float sidebar_width, bool& sidebar_has_focus, bool& focus_sidebar, bool leaderboards_only)
|
|
{
|
|
if (!s_subset_list)
|
|
return false;
|
|
|
|
using ImGuiFullscreen::g_medium_font;
|
|
using ImGuiFullscreen::LayoutScale;
|
|
|
|
static constexpr float alpha = 0.8f;
|
|
const ImVec4 sidebar_bg(0.10f, 0.10f, 0.10f, alpha);
|
|
ImGui::PushStyleColor(ImGuiCol_ChildBg, sidebar_bg);
|
|
ImGui::PushStyleColor(ImGuiCol_NavCursor, IM_COL32(0, 0, 0, 0));
|
|
|
|
bool subset_selected = false;
|
|
|
|
if (ImGui::BeginChild("subset_sidebar", ImVec2(sidebar_width, 0.0f), ImGuiChildFlags_NavFlattened, 0))
|
|
{
|
|
sidebar_has_focus = ImGui::IsWindowFocused();
|
|
ImGuiFullscreen::BeginMenuButtons();
|
|
|
|
const float padding = LayoutScale(10.0f);
|
|
const float spacing = LayoutScale(8.0f);
|
|
const float icon_size = LayoutScale(28.0f);
|
|
|
|
for (u32 i = 0; i < s_subset_list->num_subsets; i++)
|
|
{
|
|
const rc_client_subset_t* subset = s_subset_list->subsets[i];
|
|
if (leaderboards_only && subset->num_leaderboards == 0)
|
|
continue;
|
|
|
|
TinyString id_str;
|
|
id_str.format("sidebar_subset_{}", subset->id);
|
|
|
|
ImRect bb;
|
|
bool visible, hovered;
|
|
ImGuiFullscreen::MenuButtonFrame(id_str, true, ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY,
|
|
&visible, &hovered, &bb.Min, &bb.Max, 0, 1.0f);
|
|
|
|
if (focus_sidebar && ((s_open_subset && s_open_subset->id == subset->id) || (!s_open_subset && i == 0)))
|
|
{
|
|
ImGui::SetItemDefaultFocus();
|
|
}
|
|
|
|
if (s_open_subset && s_open_subset->id == subset->id)
|
|
{
|
|
ImGui::GetWindowDrawList()->AddRectFilled(
|
|
ImVec2(bb.Min.x, bb.Min.y), ImVec2(bb.Min.x + LayoutScale(4.0f), bb.Max.y), IM_COL32(52, 152, 219, 255));
|
|
}
|
|
|
|
const ImVec2 icon_min(bb.Min.x + padding, bb.Min.y + (bb.Max.y - bb.Min.y - icon_size) * 0.5f);
|
|
const ImVec2 icon_max(icon_min.x + icon_size, icon_min.y + icon_size);
|
|
|
|
std::string badge_path = GetSubsetBadgePath(subset);
|
|
if (!badge_path.empty())
|
|
{
|
|
GSTexture* badge = ImGuiFullscreen::GetCachedTextureAsync(badge_path);
|
|
if (badge)
|
|
{
|
|
ImGui::GetWindowDrawList()->AddImage(reinterpret_cast<ImTextureID>(badge->GetNativeHandle()),
|
|
icon_min, icon_max, ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f), IM_COL32(255, 255, 255, 255));
|
|
}
|
|
}
|
|
|
|
const ImRect text_bb(ImVec2(icon_max.x + spacing, bb.Min.y), ImVec2(bb.Max.x - padding, bb.Max.y));
|
|
ImGui::PushFont(g_medium_font.first, g_medium_font.second);
|
|
ImGuiFullscreen::RenderTextClippedWithShadow(
|
|
text_bb.Min, text_bb.Max, subset->title, nullptr, nullptr, ImVec2(0.0f, 0.5f), &text_bb);
|
|
ImGui::PopFont();
|
|
|
|
if (ImGui::IsItemClicked() || ImGui::IsItemActivated())
|
|
{
|
|
OpenSubset(subset);
|
|
subset_selected = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
ImGuiFullscreen::EndMenuButtons();
|
|
}
|
|
ImGui::EndChild();
|
|
|
|
ImGui::PopStyleColor(2);
|
|
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0.0f, 0.0f));
|
|
ImGui::SameLine();
|
|
ImGui::PopStyleVar();
|
|
|
|
if (focus_sidebar)
|
|
focus_sidebar = false;
|
|
|
|
return subset_selected;
|
|
}
|
|
|
|
void Achievements::DrawSubsetSidebarFooter(bool sidebar_has_focus)
|
|
{
|
|
if (ImGuiFullscreen::IsGamepadInputSource())
|
|
{
|
|
const bool circleOK = ImGui::GetIO().ConfigNavSwapGamepadButtons;
|
|
const auto glyphs = ImGuiFullscreen::GetGamepadGlyphs();
|
|
if (sidebar_has_focus)
|
|
{
|
|
ImGuiFullscreen::SetFullscreenFooterText(std::array{
|
|
std::make_pair(glyphs.dpad_ud, TRANSLATE_SV("Achievements", "Navigate Subsets")),
|
|
std::make_pair(glyphs.confirm(circleOK), TRANSLATE_SV("Achievements", "Select")),
|
|
std::make_pair(glyphs.cancel(circleOK), TRANSLATE_SV("Achievements", "Back")),
|
|
});
|
|
}
|
|
else
|
|
{
|
|
ImGuiFullscreen::SetFullscreenFooterText(std::array{
|
|
std::make_pair(glyphs.dpad_ud, TRANSLATE_SV("Achievements", "Change Selection")),
|
|
std::make_pair(glyphs.dpad_lr, TRANSLATE_SV("Achievements", "Subsets")),
|
|
std::make_pair(glyphs.cancel(circleOK), TRANSLATE_SV("Achievements", "Back")),
|
|
});
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (sidebar_has_focus)
|
|
{
|
|
ImGuiFullscreen::SetFullscreenFooterText(std::array{
|
|
std::make_pair(ICON_PF_ARROW_UP ICON_PF_ARROW_DOWN, TRANSLATE_SV("Achievements", "Navigate Subsets")),
|
|
std::make_pair(ICON_PF_ENTER, TRANSLATE_SV("Achievements", "Select")),
|
|
std::make_pair(ICON_PF_ESC, TRANSLATE_SV("Achievements", "Back")),
|
|
});
|
|
}
|
|
else
|
|
{
|
|
ImGuiFullscreen::SetFullscreenFooterText(std::array{
|
|
std::make_pair(ICON_PF_ARROW_UP ICON_PF_ARROW_DOWN, TRANSLATE_SV("Achievements", "Change Selection")),
|
|
std::make_pair(ICON_PF_ARROW_LEFT, TRANSLATE_SV("Achievements", "Subsets")),
|
|
std::make_pair(ICON_PF_ESC, TRANSLATE_SV("Achievements", "Back")),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
void Achievements::DrawAchievementsWindow()
|
|
{
|
|
using ImGuiFullscreen::g_large_font;
|
|
using ImGuiFullscreen::g_medium_font;
|
|
using ImGuiFullscreen::GetLineHeight;
|
|
using ImGuiFullscreen::LayoutScale;
|
|
|
|
if (!s_achievement_list)
|
|
return;
|
|
|
|
auto lock = Achievements::GetLock();
|
|
|
|
// ensure image downloads still happen while we're paused
|
|
Achievements::IdleUpdate();
|
|
|
|
const bool has_multiple_subsets = (s_subset_list && s_subset_list->num_subsets > 1);
|
|
static bool sidebar_has_focus = false;
|
|
static bool focus_sidebar = false;
|
|
if (!has_multiple_subsets)
|
|
sidebar_has_focus = false;
|
|
|
|
static constexpr float alpha = 0.8f;
|
|
static constexpr float heading_alpha = 0.95f;
|
|
static constexpr float heading_height_unscaled = 110.0f;
|
|
|
|
const ImVec4 background(0.13f, 0.13f, 0.13f, alpha);
|
|
const ImVec4 heading_background(0.13f, 0.13f, 0.13f, heading_alpha);
|
|
const ImVec2 display_size(ImGui::GetIO().DisplaySize);
|
|
const float heading_height = ImGuiFullscreen::LayoutScale(heading_height_unscaled);
|
|
|
|
if (ImGuiFullscreen::BeginFullscreenWindow(ImVec2(0.0f, 0.0f), ImVec2(display_size.x, heading_height), "achievements_heading",
|
|
heading_background, 0.0f, ImVec2(), ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoScrollWithMouse))
|
|
{
|
|
ImRect bb;
|
|
bool visible, hovered;
|
|
ImGuiFullscreen::MenuButtonFrame(
|
|
"achievements_heading", false, heading_height_unscaled, &visible, &hovered, &bb.Min, &bb.Max, 0, heading_alpha);
|
|
if (visible)
|
|
{
|
|
const float padding = ImGuiFullscreen::LayoutScale(10.0f);
|
|
const float spacing = ImGuiFullscreen::LayoutScale(10.0f);
|
|
const float image_height = ImGuiFullscreen::LayoutScale(85.0f);
|
|
|
|
const ImVec2 icon_min(bb.Min + ImVec2(padding, padding));
|
|
const ImVec2 icon_max(icon_min + ImVec2(image_height, image_height));
|
|
|
|
if (!s_game_icon.empty())
|
|
{
|
|
GSTexture* badge = ImGuiFullscreen::GetCachedTextureAsync(s_game_icon.c_str());
|
|
if (badge)
|
|
{
|
|
ImGui::GetWindowDrawList()->AddImage(reinterpret_cast<ImTextureID>(badge->GetNativeHandle()),
|
|
icon_min, icon_max, ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f), IM_COL32(255, 255, 255, 255));
|
|
}
|
|
}
|
|
|
|
float left = bb.Min.x + padding + image_height + spacing;
|
|
float right = bb.Max.x - padding;
|
|
float top = bb.Min.y + padding;
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
SmallString text;
|
|
ImVec2 text_size;
|
|
|
|
const bool wants_close = ImGuiFullscreen::WantsToCloseMenu();
|
|
if (ImGuiFullscreen::FloatingButton(ICON_FA_SQUARE_XMARK, 10.0f, 10.0f, -1.0f, -1.0f, 1.0f, 0.0f, true, g_large_font) ||
|
|
wants_close)
|
|
{
|
|
sidebar_has_focus = false;
|
|
FullscreenUI::ReturnToPreviousWindow();
|
|
}
|
|
|
|
const ImRect title_bb(ImVec2(left, top), ImVec2(right, top + GetLineHeight(g_large_font)));
|
|
text.assign(s_open_subset ? s_open_subset->title : s_game_title);
|
|
|
|
if (s_hardcore_mode)
|
|
text.append(TRANSLATE_SV("Achievements", " (Hardcore Mode)"));
|
|
|
|
top += GetLineHeight(g_large_font) + spacing;
|
|
|
|
ImGui::PushFont(g_large_font.first, g_large_font.second);
|
|
ImGuiFullscreen::RenderTextClippedWithShadow(
|
|
title_bb.Min, title_bb.Max, text.c_str(), text.end_ptr(), nullptr, ImVec2(0.0f, 0.0f), &title_bb);
|
|
ImGui::PopFont();
|
|
|
|
const ImRect summary_bb(ImVec2(left, top), ImVec2(right, top + GetLineHeight(g_medium_font)));
|
|
UpdateGameSummary();
|
|
|
|
rc_client_user_game_summary_t display_summary = {};
|
|
const rc_client_user_game_summary_t* summary_ptr = &s_game_summary;
|
|
if (s_open_subset)
|
|
{
|
|
rc_client_get_user_subset_summary(s_client, s_open_subset->id, &display_summary);
|
|
summary_ptr = &display_summary;
|
|
}
|
|
|
|
if (summary_ptr->num_core_achievements > 0)
|
|
{
|
|
if (summary_ptr->num_unlocked_achievements == summary_ptr->num_core_achievements)
|
|
{
|
|
text.format(TRANSLATE_FS("Achievements", "You have unlocked all achievements and earned {} points!"),
|
|
summary_ptr->points_unlocked);
|
|
}
|
|
else
|
|
{
|
|
text.format(TRANSLATE_FS("Achievements", "You have unlocked {0} of {1} achievements, earning {2} of {3} possible points."),
|
|
summary_ptr->num_unlocked_achievements, summary_ptr->num_core_achievements, summary_ptr->points_unlocked,
|
|
summary_ptr->points_core);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
text.assign(TRANSLATE_SV("Achievements", "This game has no achievements."));
|
|
}
|
|
|
|
top += GetLineHeight(g_medium_font) + spacing;
|
|
|
|
ImGui::PushFont(g_medium_font.first, g_medium_font.second);
|
|
ImGui::RenderTextClipped(
|
|
summary_bb.Min, summary_bb.Max, text.c_str(), text.end_ptr(), nullptr, ImVec2(0.0f, 0.0f), &summary_bb);
|
|
ImGui::PopFont();
|
|
|
|
if (summary_ptr->num_core_achievements > 0)
|
|
{
|
|
const float progress_height = ImGuiFullscreen::LayoutScale(20.0f);
|
|
const ImRect progress_bb(ImVec2(left, top), ImVec2(right, top + progress_height));
|
|
const float fraction =
|
|
static_cast<float>(summary_ptr->num_unlocked_achievements) / static_cast<float>(summary_ptr->num_core_achievements);
|
|
dl->AddRectFilled(progress_bb.Min, progress_bb.Max, ImGui::GetColorU32(ImGuiFullscreen::UIPrimaryDarkColor));
|
|
dl->AddRectFilled(progress_bb.Min, ImVec2(progress_bb.Min.x + fraction * progress_bb.GetWidth(), progress_bb.Max.y),
|
|
ImGui::GetColorU32(ImGuiFullscreen::UISecondaryColor));
|
|
|
|
text.format("{}%", static_cast<int>(std::round(fraction * 100.0f)));
|
|
ImGui::PushFont(g_medium_font.first, g_medium_font.second);
|
|
text_size = ImGui::CalcTextSize(text.c_str(), text.end_ptr());
|
|
ImGui::PopFont();
|
|
const ImVec2 text_pos(progress_bb.Min.x + ((progress_bb.Max.x - progress_bb.Min.x) / 2.0f) - (text_size.x / 2.0f),
|
|
progress_bb.Min.y + ((progress_bb.Max.y - progress_bb.Min.y) / 2.0f) - (text_size.y / 2.0f));
|
|
dl->AddText(g_medium_font.first, g_medium_font.second, text_pos, ImGui::GetColorU32(ImGuiFullscreen::UIPrimaryTextColor),
|
|
text.c_str(), text.end_ptr());
|
|
top += progress_height + spacing;
|
|
}
|
|
}
|
|
}
|
|
ImGuiFullscreen::EndFullscreenWindow();
|
|
|
|
ImGui::SetNextWindowBgAlpha(alpha);
|
|
|
|
const float sidebar_width = has_multiple_subsets ? LayoutScale(200.0f) : 0.0f;
|
|
|
|
if (has_multiple_subsets && !s_open_subset)
|
|
{
|
|
OpenSubset(s_subset_list->subsets[0]);
|
|
}
|
|
|
|
if (ImGuiFullscreen::BeginFullscreenWindow(
|
|
ImVec2(0.0f, heading_height),
|
|
ImVec2(display_size.x, display_size.y - heading_height - LayoutScale(ImGuiFullscreen::LAYOUT_FOOTER_HEIGHT)),
|
|
"achievements", background, 0.0f, ImVec2(0.0f, 0.0f), 0))
|
|
{
|
|
if (has_multiple_subsets)
|
|
{
|
|
if (focus_sidebar)
|
|
{
|
|
ImGui::SetNextWindowFocus();
|
|
}
|
|
if (DrawSubsetSidebar(sidebar_width, sidebar_has_focus, focus_sidebar, false))
|
|
ImGui::SetNextWindowFocus();
|
|
}
|
|
|
|
const float content_width = has_multiple_subsets ? (display_size.x - sidebar_width) : display_size.x;
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(ImGuiFullscreen::LAYOUT_MENU_WINDOW_X_PADDING, 0.0f));
|
|
ImGui::PushStyleColor(ImGuiCol_ChildBg, background);
|
|
ImGui::PushStyleColor(ImGuiCol_Header, ImGui::GetColorU32(ImGuiFullscreen::UIPrimaryDarkColor));
|
|
ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImGui::GetColorU32(ImGuiFullscreen::UIPrimaryColor));
|
|
ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImGui::GetColorU32(ImGuiFullscreen::UISecondaryColor));
|
|
|
|
if (ImGui::BeginChild("achievements_content", ImVec2(content_width, 0.0f), ImGuiChildFlags_NavFlattened, 0))
|
|
{
|
|
if (has_multiple_subsets && !sidebar_has_focus &&
|
|
(ImGui::IsKeyPressed(ImGuiKey_GamepadDpadLeft, false) || ImGui::IsKeyPressed(ImGuiKey_LeftArrow, false)))
|
|
{
|
|
sidebar_has_focus = true;
|
|
focus_sidebar = true;
|
|
}
|
|
|
|
static const char* bucket_names[NUM_RC_CLIENT_ACHIEVEMENT_BUCKETS] = {
|
|
TRANSLATE_NOOP("Achievements", "Unknown"),
|
|
TRANSLATE_NOOP("Achievements", "Locked"),
|
|
TRANSLATE_NOOP("Achievements", "Unlocked"),
|
|
TRANSLATE_NOOP("Achievements", "Unsupported"),
|
|
TRANSLATE_NOOP("Achievements", "Unofficial"),
|
|
TRANSLATE_NOOP("Achievements", "Recently Unlocked"),
|
|
TRANSLATE_NOOP("Achievements", "Active Challenges"),
|
|
TRANSLATE_NOOP("Achievements", "Almost There"),
|
|
};
|
|
|
|
ImGuiFullscreen::BeginMenuButtons();
|
|
|
|
for (u32 bucket_type : {RC_CLIENT_ACHIEVEMENT_BUCKET_ACTIVE_CHALLENGE, RC_CLIENT_ACHIEVEMENT_BUCKET_RECENTLY_UNLOCKED,
|
|
RC_CLIENT_ACHIEVEMENT_BUCKET_UNLOCKED, RC_CLIENT_ACHIEVEMENT_BUCKET_ALMOST_THERE, RC_CLIENT_ACHIEVEMENT_BUCKET_LOCKED,
|
|
RC_CLIENT_ACHIEVEMENT_BUCKET_UNOFFICIAL, RC_CLIENT_ACHIEVEMENT_BUCKET_UNSUPPORTED})
|
|
{
|
|
for (u32 bucket_idx = 0; bucket_idx < s_achievement_list->num_buckets; bucket_idx++)
|
|
{
|
|
const rc_client_achievement_bucket_t& bucket = s_achievement_list->buckets[bucket_idx];
|
|
if (bucket.bucket_type != bucket_type)
|
|
continue;
|
|
|
|
if (s_open_subset && bucket.subset_id != 0 && bucket.subset_id != s_open_subset->id)
|
|
continue;
|
|
|
|
pxAssert(bucket.bucket_type < NUM_RC_CLIENT_ACHIEVEMENT_BUCKETS);
|
|
|
|
bool& bucket_collapsed = s_achievement_buckets_collapsed[std::make_pair(bucket.subset_id, bucket.bucket_type)];
|
|
const char* translated_bucket_name = Host::TranslateToCString("Achievements", bucket_names[bucket.bucket_type]);
|
|
|
|
bucket_collapsed ^=
|
|
ImGuiFullscreen::MenuHeadingButton(translated_bucket_name,
|
|
bucket_collapsed ? ICON_FA_CHEVRON_DOWN : ICON_FA_CHEVRON_UP);
|
|
|
|
if (!bucket_collapsed)
|
|
{
|
|
for (u32 i = 0; i < bucket.num_achievements; i++)
|
|
DrawAchievement(bucket.achievements[i]);
|
|
}
|
|
}
|
|
}
|
|
|
|
ImGuiFullscreen::EndMenuButtons();
|
|
}
|
|
ImGui::EndChild();
|
|
|
|
ImGui::PopStyleColor(4);
|
|
ImGui::PopStyleVar();
|
|
}
|
|
ImGuiFullscreen::EndFullscreenWindow();
|
|
|
|
if (has_multiple_subsets)
|
|
DrawSubsetSidebarFooter(sidebar_has_focus);
|
|
else
|
|
FullscreenUI::SetStandardSelectionFooterText(true);
|
|
}
|
|
|
|
void Achievements::DrawAchievement(const rc_client_achievement_t* cheevo)
|
|
{
|
|
using ImGuiFullscreen::g_large_font;
|
|
using ImGuiFullscreen::g_medium_font;
|
|
using ImGuiFullscreen::GetLineHeight;
|
|
using ImGuiFullscreen::LayoutScale;
|
|
using ImGuiFullscreen::LayoutUnscale;
|
|
|
|
static constexpr float alpha = 0.8f;
|
|
static constexpr float progress_height_unscaled = 20.0f;
|
|
static constexpr float progress_spacing_unscaled = 5.0f;
|
|
|
|
const float spacing = ImGuiFullscreen::LayoutScale(4.0f);
|
|
|
|
const bool is_unlocked = (cheevo->state == RC_CLIENT_ACHIEVEMENT_STATE_UNLOCKED);
|
|
const std::string_view measured_progress(cheevo->measured_progress);
|
|
const bool is_measured = !is_unlocked && !measured_progress.empty();
|
|
const float unlock_size = is_unlocked ? (spacing + ImGuiFullscreen::LAYOUT_MEDIUM_FONT_SIZE) : 0.0f;
|
|
const ImVec2 points_template_size(
|
|
g_medium_font.first->CalcTextSizeA(g_medium_font.second, FLT_MAX, 0.0f, TRANSLATE("Achievements", "XXX points")));
|
|
|
|
const size_t summary_length = std::strlen(cheevo->description);
|
|
const float summary_wrap_width = (ImGui::GetCurrentWindow()->WorkRect.GetWidth() - (ImGui::GetStyle().FramePadding.x * 2.0f) -
|
|
LayoutScale(ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT + 30.0f) - points_template_size.x);
|
|
const ImVec2 summary_text_size(g_medium_font.first->CalcTextSizeA(g_medium_font.second, FLT_MAX, summary_wrap_width, cheevo->description,
|
|
cheevo->description + summary_length));
|
|
|
|
// Messy, but need to undo LayoutScale in MenuButtonFrame()...
|
|
const float extra_summary_height = LayoutUnscale(std::max(summary_text_size.y - GetLineHeight(g_medium_font), 0.0f));
|
|
|
|
ImRect bb;
|
|
bool visible, hovered;
|
|
ImGuiFullscreen::MenuButtonFrame(TinyString::from_format("chv_{}", cheevo->id), true,
|
|
!is_measured ? ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT + extra_summary_height + unlock_size :
|
|
ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT + extra_summary_height + progress_height_unscaled + progress_spacing_unscaled,
|
|
&visible, &hovered, &bb.Min, &bb.Max, 0, alpha);
|
|
if (!visible)
|
|
return;
|
|
|
|
std::string* badge_path;
|
|
if (const auto badge_it = std::find_if(
|
|
s_achievement_badge_paths.begin(), s_achievement_badge_paths.end(), [cheevo](const auto& it) { return (it.first == cheevo); });
|
|
badge_it != s_achievement_badge_paths.end())
|
|
{
|
|
badge_path = &badge_it->second;
|
|
}
|
|
else
|
|
{
|
|
std::string new_badge_path = Achievements::GetAchievementBadgePath(cheevo, cheevo->state);
|
|
badge_path = &s_achievement_badge_paths.emplace_back(cheevo, std::move(new_badge_path)).second;
|
|
}
|
|
|
|
const ImVec2 image_size(LayoutScale(ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT, ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT));
|
|
|
|
if (!badge_path->empty())
|
|
{
|
|
GSTexture* badge = ImGuiFullscreen::GetCachedTextureAsync(badge_path->c_str());
|
|
if (badge)
|
|
{
|
|
ImGui::GetWindowDrawList()->AddImage(reinterpret_cast<ImTextureID>(badge->GetNativeHandle()),
|
|
bb.Min, bb.Min + image_size, ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f), IM_COL32(255, 255, 255, 255));
|
|
}
|
|
}
|
|
|
|
SmallString text;
|
|
|
|
const float midpoint = bb.Min.y + GetLineHeight(g_large_font) + spacing;
|
|
text.format((cheevo->points != 1) ? TRANSLATE_FS("Achievements", "{} points") : TRANSLATE_FS("Achievements", "{} point"), cheevo->points);
|
|
|
|
const ImVec2 points_size(g_medium_font.first->CalcTextSizeA(g_medium_font.second, FLT_MAX, 0.0f, text.c_str(), text.end_ptr()));
|
|
const float points_template_start = bb.Max.x - points_template_size.x;
|
|
const float points_start = points_template_start + ((points_template_size.x - points_size.x) * 0.5f);
|
|
|
|
const char* right_icon_text;
|
|
const char* badge_text;
|
|
const char* badge_icon;
|
|
ImVec4 badge_color;
|
|
switch (cheevo->type)
|
|
{
|
|
case RC_CLIENT_ACHIEVEMENT_TYPE_MISSABLE:
|
|
badge_text = TRANSLATE("Achievements", "Missable");
|
|
badge_icon = ICON_PF_ACHIEVEMENTS_MISSABLE;
|
|
badge_color = ImVec4(0.78f, 0.55f, 0.30f, 1.0f);
|
|
right_icon_text = is_unlocked ? ICON_FA_LOCK_OPEN : ICON_FA_LOCK;
|
|
break;
|
|
|
|
case RC_CLIENT_ACHIEVEMENT_TYPE_PROGRESSION:
|
|
badge_text = TRANSLATE("Achievements", "Progression");
|
|
badge_icon = ICON_PF_ACHIEVEMENTS_PROGRESSION;
|
|
badge_color = ImVec4(0.62f, 0.48f, 0.80f, 1.0f);
|
|
right_icon_text = is_unlocked ? ICON_FA_LOCK_OPEN : ICON_FA_LOCK;
|
|
break;
|
|
|
|
case RC_CLIENT_ACHIEVEMENT_TYPE_WIN:
|
|
badge_text = TRANSLATE("Achievements", "Win Condition");
|
|
badge_icon = ICON_PF_ACHIEVEMENTS_WIN;
|
|
badge_color = ImVec4(0.36f, 0.62f, 0.36f, 1.0f);
|
|
right_icon_text = is_unlocked ? ICON_FA_LOCK_OPEN : ICON_FA_LOCK;
|
|
break;
|
|
|
|
case RC_CLIENT_ACHIEVEMENT_TYPE_STANDARD:
|
|
default:
|
|
badge_text = nullptr;
|
|
badge_icon = nullptr;
|
|
right_icon_text = is_unlocked ? ICON_FA_LOCK_OPEN : ICON_FA_LOCK;
|
|
break;
|
|
}
|
|
|
|
const ImVec2 right_icon_size(g_large_font.first->CalcTextSizeA(g_large_font.second, FLT_MAX, 0.0f, right_icon_text));
|
|
|
|
const float text_start_x = bb.Min.x + image_size.x + LayoutScale(15.0f);
|
|
|
|
const float badge_padding = LayoutScale(6.0f);
|
|
const float icon_text_spacing = LayoutScale(4.0f);
|
|
const float badge_title_spacing = LayoutScale(6.0f);
|
|
ImVec2 badge_size = ImVec2(0.0f, 0.0f);
|
|
ImVec2 badge_icon_size = ImVec2(0.0f, 0.0f);
|
|
ImVec2 badge_text_size = ImVec2(0.0f, 0.0f);
|
|
if (badge_text && badge_icon)
|
|
{
|
|
badge_icon_size = g_medium_font.first->CalcTextSizeA(g_medium_font.second, FLT_MAX, 0.0f, badge_icon);
|
|
badge_text_size = g_medium_font.first->CalcTextSizeA(g_medium_font.second, FLT_MAX, 0.0f, badge_text);
|
|
badge_size = ImVec2(badge_icon_size.x + icon_text_spacing + badge_text_size.x + (badge_padding * 2.0f),
|
|
g_medium_font.second + badge_padding);
|
|
}
|
|
|
|
const ImVec2 title_size(g_large_font.first->CalcTextSizeA(g_large_font.second, FLT_MAX, 0.0f, cheevo->title));
|
|
const float title_text_end_x = text_start_x + title_size.x;
|
|
|
|
float title_max_x = points_start;
|
|
float badge_x = 0.0f;
|
|
if (badge_text && badge_icon)
|
|
{
|
|
const float effective_title_max_x = points_start - (badge_size.x + badge_title_spacing);
|
|
badge_x = std::min(title_text_end_x + badge_title_spacing, effective_title_max_x);
|
|
title_max_x = badge_x - badge_title_spacing;
|
|
}
|
|
|
|
const ImRect title_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(title_max_x, midpoint));
|
|
const ImRect summary_bb(ImVec2(text_start_x, midpoint), ImVec2(points_start, midpoint + GetLineHeight(g_medium_font) + extra_summary_height));
|
|
const ImRect points_bb(ImVec2(points_start, midpoint), bb.Max);
|
|
const ImRect lock_bb(
|
|
ImVec2(points_template_start + ((points_template_size.x - right_icon_size.x) * 0.5f), bb.Min.y), ImVec2(bb.Max.x, midpoint));
|
|
|
|
ImGui::PushFont(g_large_font.first, g_large_font.second);
|
|
ImGuiFullscreen::RenderTextClippedWithShadow(title_bb.Min, title_bb.Max, cheevo->title, nullptr, nullptr, ImVec2(0.0f, 0.0f), &title_bb);
|
|
ImGuiFullscreen::RenderTextClippedWithShadow(
|
|
lock_bb.Min, lock_bb.Max, right_icon_text, nullptr, &right_icon_size, ImVec2(0.0f, 0.0f), &lock_bb);
|
|
ImGui::PopFont();
|
|
|
|
if (badge_text && badge_icon)
|
|
{
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
const ImVec2 badge_min(badge_x, bb.Min.y + ((midpoint - bb.Min.y) / 2.0f) - (badge_size.y / 2.0f));
|
|
const ImVec2 badge_max(badge_min.x + badge_size.x, badge_min.y + badge_size.y);
|
|
|
|
dl->AddRectFilled(badge_min, badge_max, ImGui::GetColorU32(badge_color), LayoutScale(3.0f));
|
|
|
|
const float icon_y = badge_min.y + ((badge_size.y - badge_icon_size.y) * 0.5f);
|
|
const float text_y = badge_min.y + ((badge_size.y - badge_text_size.y) * 0.5f);
|
|
|
|
const ImVec2 icon_pos(badge_min.x + badge_padding, icon_y);
|
|
dl->AddText(g_medium_font.first, g_medium_font.second, icon_pos, IM_COL32(255, 255, 255, 255), badge_icon);
|
|
|
|
const ImVec2 text_pos(icon_pos.x + badge_icon_size.x + icon_text_spacing, text_y);
|
|
dl->AddText(g_medium_font.first, g_medium_font.second, text_pos, IM_COL32(255, 255, 255, 255), badge_text);
|
|
}
|
|
|
|
ImGui::PushFont(g_medium_font.first, g_medium_font.second);
|
|
if (cheevo->description && summary_length > 0)
|
|
{
|
|
ImGui::RenderTextWrapped(summary_bb.Min, cheevo->description, cheevo->description + summary_length, summary_wrap_width);
|
|
}
|
|
ImGuiFullscreen::RenderTextClippedWithShadow(
|
|
points_bb.Min, points_bb.Max, text.c_str(), text.end_ptr(), &points_size, ImVec2(0.0f, 0.0f), &points_bb);
|
|
|
|
if (is_unlocked)
|
|
{
|
|
text.format(TRANSLATE_FS("Achievements", "Unlocked: {}"), FullscreenUI::TimeToPrintableString(cheevo->unlock_time));
|
|
|
|
const ImRect unlock_bb(summary_bb.Min.x, summary_bb.Max.y + spacing, summary_bb.Max.x, bb.Max.y);
|
|
ImGuiFullscreen::RenderTextClippedWithShadow(
|
|
unlock_bb.Min, unlock_bb.Max, text.c_str(), text.end_ptr(), nullptr, ImVec2(0.0f, 0.0f), &unlock_bb);
|
|
}
|
|
else if (is_measured)
|
|
{
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
const float progress_height = LayoutScale(progress_height_unscaled);
|
|
const float progress_spacing = LayoutScale(progress_spacing_unscaled);
|
|
const float top = midpoint + GetLineHeight(g_medium_font) + progress_spacing;
|
|
const ImRect progress_bb(ImVec2(text_start_x, top), ImVec2(bb.Max.x, top + progress_height));
|
|
const float fraction = cheevo->measured_percent * 0.01f;
|
|
dl->AddRectFilled(progress_bb.Min, progress_bb.Max, ImGui::GetColorU32(ImGuiFullscreen::UIPrimaryDarkColor));
|
|
dl->AddRectFilled(progress_bb.Min, ImVec2(progress_bb.Min.x + fraction * progress_bb.GetWidth(), progress_bb.Max.y),
|
|
ImGui::GetColorU32(ImGuiFullscreen::UISecondaryColor));
|
|
|
|
const ImVec2 text_size = ImGui::CalcTextSize(measured_progress.data(), measured_progress.data() + measured_progress.size());
|
|
const ImVec2 text_pos(progress_bb.Min.x + ((progress_bb.Max.x - progress_bb.Min.x) / 2.0f) - (text_size.x / 2.0f),
|
|
progress_bb.Min.y + ((progress_bb.Max.y - progress_bb.Min.y) / 2.0f) - (text_size.y / 2.0f));
|
|
dl->AddText(g_medium_font.first, g_medium_font.second, text_pos, ImGui::GetColorU32(ImGuiFullscreen::UIPrimaryTextColor),
|
|
measured_progress.data(), measured_progress.data() + measured_progress.size());
|
|
}
|
|
|
|
ImGui::PopFont();
|
|
}
|
|
|
|
bool Achievements::PrepareLeaderboardsWindow()
|
|
{
|
|
auto lock = Achievements::GetLock();
|
|
rc_client_t* const client = s_client;
|
|
|
|
s_achievement_badge_paths = {};
|
|
CloseLeaderboard();
|
|
|
|
if (s_subset_list)
|
|
{
|
|
rc_client_destroy_subset_list(s_subset_list);
|
|
s_subset_list = nullptr;
|
|
}
|
|
CloseSubset();
|
|
s_subset_list = rc_client_create_subset_list(client);
|
|
if (!s_subset_list)
|
|
Console.Warning("Achievements: rc_client_create_subset_list() returned null");
|
|
s_leaderboard_buckets_collapsed.clear();
|
|
|
|
if (s_leaderboard_list)
|
|
rc_client_destroy_leaderboard_list(s_leaderboard_list);
|
|
s_leaderboard_list = rc_client_create_leaderboard_list(client, RC_CLIENT_LEADERBOARD_LIST_GROUPING_TRACKING);
|
|
if (!s_leaderboard_list)
|
|
{
|
|
Console.Error("Achievements: rc_client_create_leaderboard_list() returned null");
|
|
return false;
|
|
}
|
|
|
|
s_has_last_selected_leaderboard = false;
|
|
s_restore_leaderboard_focus = false;
|
|
s_last_selected_leaderboard_id = 0;
|
|
s_restore_leaderboard_scroll = false;
|
|
s_leaderboard_list_scroll = 0.0f;
|
|
|
|
if (s_subset_list)
|
|
{
|
|
for (u32 i = 0; i < s_subset_list->num_subsets; i++)
|
|
{
|
|
if (s_subset_list->subsets[i]->num_leaderboards > 0)
|
|
{
|
|
OpenSubset(s_subset_list->subsets[i]);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
void Achievements::DrawLeaderboardsWindow()
|
|
{
|
|
using ImGuiFullscreen::g_large_font;
|
|
using ImGuiFullscreen::g_medium_font;
|
|
using ImGuiFullscreen::GetLineHeight;
|
|
using ImGuiFullscreen::LayoutScale;
|
|
|
|
static constexpr float alpha = 0.8f;
|
|
static constexpr float heading_alpha = 0.95f;
|
|
static constexpr float heading_height_unscaled = 110.0f;
|
|
static constexpr float tab_height_unscaled = 50.0f;
|
|
|
|
auto lock = Achievements::GetLock();
|
|
|
|
// ensure image downloads still happen while we're paused
|
|
Achievements::IdleUpdate();
|
|
|
|
const bool is_leaderboard_open = (s_open_leaderboard != nullptr);
|
|
bool close_leaderboard_on_exit = false;
|
|
|
|
const bool has_multiple_subsets = (s_subset_list && std::count_if(s_subset_list->subsets,
|
|
s_subset_list->subsets + s_subset_list->num_subsets,
|
|
[](const rc_client_subset_t* subset) { return subset->num_leaderboards > 0; }) > 1);
|
|
static bool sidebar_has_focus = false;
|
|
static bool focus_sidebar = false;
|
|
if (!has_multiple_subsets || is_leaderboard_open)
|
|
sidebar_has_focus = false;
|
|
|
|
ImRect bb;
|
|
|
|
const ImVec4 background(0.13f, 0.13f, 0.13f, alpha);
|
|
const ImVec4 heading_background(0.13f, 0.13f, 0.13f, heading_alpha);
|
|
const ImVec2 display_size(ImGui::GetIO().DisplaySize);
|
|
const float padding = LayoutScale(10.0f);
|
|
const float spacing = LayoutScale(10.0f);
|
|
const float spacing_small = spacing / 2.0f;
|
|
float heading_height = LayoutScale(heading_height_unscaled);
|
|
if (is_leaderboard_open)
|
|
{
|
|
// tabs
|
|
heading_height += spacing_small + LayoutScale(tab_height_unscaled) + spacing;
|
|
|
|
// Add space for a legend - spacing + 1 line of text + spacing + line
|
|
heading_height += LayoutScale(ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY) + spacing;
|
|
}
|
|
|
|
const float rank_column_width = std::max(
|
|
g_large_font.first->CalcTextSizeA(g_large_font.second, std::numeric_limits<float>::max(), -1.0f, "99999").x,
|
|
g_large_font.first
|
|
->CalcTextSizeA(
|
|
g_large_font.second, std::numeric_limits<float>::max(), -1.0f, TRANSLATE("Achievements", "Rank"))
|
|
.x);
|
|
const float name_column_width =
|
|
g_large_font.first->CalcTextSizeA(g_large_font.second, std::numeric_limits<float>::max(), -1.0f, "WWWWWWWWWWWWWWWWWWWWWW").x;
|
|
const float time_column_width =
|
|
g_large_font.first->CalcTextSizeA(g_large_font.second, std::numeric_limits<float>::max(), -1.0f, "WWWWWWWWWWW").x;
|
|
const float column_spacing = spacing * 2.0f;
|
|
|
|
if (ImGuiFullscreen::BeginFullscreenWindow(ImVec2(), ImVec2(display_size.x, heading_height), "leaderboards_heading",
|
|
heading_background, 0.0f, ImVec2(),
|
|
ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoScrollWithMouse))
|
|
{
|
|
bool visible, hovered;
|
|
ImGuiFullscreen::MenuButtonFrame(
|
|
"leaderboards_heading", false, heading_height_unscaled, &visible, &hovered, &bb.Min, &bb.Max, 0, alpha);
|
|
|
|
if (visible)
|
|
{
|
|
const float image_height = LayoutScale(85.0f);
|
|
|
|
const ImVec2 icon_min(bb.Min + ImVec2(padding, padding));
|
|
const ImVec2 icon_max(icon_min + ImVec2(image_height, image_height));
|
|
|
|
if (!s_game_icon.empty())
|
|
{
|
|
GSTexture* badge = ImGuiFullscreen::GetCachedTextureAsync(s_game_icon.c_str());
|
|
if (badge)
|
|
{
|
|
ImGui::GetWindowDrawList()->AddImage(reinterpret_cast<ImTextureID>(badge->GetNativeHandle()),
|
|
icon_min, icon_max, ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f), IM_COL32(255, 255, 255, 255));
|
|
}
|
|
}
|
|
|
|
float left = bb.Min.x + padding + image_height + spacing;
|
|
float right = bb.Max.x - padding;
|
|
float top = bb.Min.y + padding;
|
|
SmallString text;
|
|
|
|
if (!is_leaderboard_open)
|
|
{
|
|
const bool wants_close = ImGuiFullscreen::WantsToCloseMenu();
|
|
if (ImGuiFullscreen::FloatingButton(ICON_FA_SQUARE_XMARK, 10.0f, 10.0f, -1.0f, -1.0f, 1.0f, 0.0f, true, g_large_font) ||
|
|
wants_close)
|
|
{
|
|
sidebar_has_focus = false;
|
|
FullscreenUI::ReturnToPreviousWindow();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
const bool wants_to_go_back = ImGuiFullscreen::FloatingButton(
|
|
ICON_FA_SQUARE_CARET_LEFT, 10.0f, 10.0f, -1.0f, -1.0f, 1.0f, 0.0f, true, g_large_font) ||
|
|
ImGuiFullscreen::WantsToCloseMenu();
|
|
if (wants_to_go_back)
|
|
{
|
|
close_leaderboard_on_exit = true;
|
|
if (s_has_last_selected_leaderboard)
|
|
{
|
|
s_restore_leaderboard_focus = true;
|
|
s_restore_leaderboard_scroll = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
const ImRect title_bb(ImVec2(left, top), ImVec2(right, top + GetLineHeight(g_large_font)));
|
|
text.assign(Achievements::GetGameTitle());
|
|
|
|
top += GetLineHeight(g_large_font) + spacing;
|
|
|
|
ImGui::PushFont(g_large_font.first, g_large_font.second);
|
|
ImGuiFullscreen::RenderTextClippedWithShadow(
|
|
title_bb.Min, title_bb.Max, text.c_str(), text.end_ptr(), nullptr, ImVec2(0.0f, 0.0f), &title_bb);
|
|
ImGui::PopFont();
|
|
|
|
if (is_leaderboard_open)
|
|
{
|
|
const ImRect subtitle_bb(ImVec2(left, top), ImVec2(right, top + GetLineHeight(g_large_font)));
|
|
text.assign(s_open_leaderboard->title);
|
|
|
|
top += GetLineHeight(g_large_font) + spacing_small;
|
|
|
|
ImGui::PushFont(g_large_font.first, g_large_font.second);
|
|
ImGuiFullscreen::RenderTextClippedWithShadow(
|
|
subtitle_bb.Min, subtitle_bb.Max, text.c_str(), text.end_ptr(), nullptr, ImVec2(0.0f, 0.0f), &subtitle_bb);
|
|
ImGui::PopFont();
|
|
|
|
text.assign(s_open_leaderboard->description);
|
|
}
|
|
else
|
|
{
|
|
u32 count = 0;
|
|
for (u32 i = 0; i < s_leaderboard_list->num_buckets; i++)
|
|
count += s_leaderboard_list->buckets[i].num_leaderboards;
|
|
text.format(TRANSLATE_FS("Achievements", "This game has {} leaderboards."), count);
|
|
}
|
|
|
|
const ImRect summary_bb(ImVec2(left, top), ImVec2(right, top + GetLineHeight(g_medium_font)));
|
|
top += GetLineHeight(g_medium_font) + spacing_small;
|
|
|
|
ImGui::PushFont(g_medium_font.first, g_medium_font.second);
|
|
ImGuiFullscreen::RenderTextClippedWithShadow(
|
|
summary_bb.Min, summary_bb.Max, text.c_str(), text.end_ptr(), nullptr, ImVec2(0.0f, 0.0f), &summary_bb);
|
|
|
|
if (!is_leaderboard_open && !Achievements::IsHardcoreModeActive())
|
|
{
|
|
const ImRect hardcore_warning_bb(ImVec2(left, top), ImVec2(right, top + GetLineHeight(g_medium_font)));
|
|
top += GetLineHeight(g_medium_font) + spacing_small;
|
|
|
|
ImGuiFullscreen::RenderTextClippedWithShadow(hardcore_warning_bb.Min, hardcore_warning_bb.Max,
|
|
TRANSLATE("Achievements", "Submitting scores is disabled because hardcore mode is off. Leaderboards are read-only."), nullptr,
|
|
nullptr, ImVec2(0.0f, 0.0f), &hardcore_warning_bb);
|
|
}
|
|
|
|
ImGui::PopFont();
|
|
|
|
if (is_leaderboard_open)
|
|
{
|
|
const float tab_width = (ImGui::GetWindowWidth() / ImGuiFullscreen::g_layout_scale) * 0.5f;
|
|
ImGui::SetCursorPos(ImVec2(0.0f, top + spacing_small));
|
|
|
|
if (ImGui::IsKeyPressed(ImGuiKey_NavGamepadTweakSlow, false) || ImGui::IsKeyPressed(ImGuiKey_NavGamepadTweakFast, false) ||
|
|
ImGui::IsKeyPressed(ImGuiKey_GamepadDpadLeft, false) || ImGui::IsKeyPressed(ImGuiKey_GamepadDpadRight, false) ||
|
|
ImGui::IsKeyPressed(ImGuiKey_LeftArrow, false) || ImGui::IsKeyPressed(ImGuiKey_RightArrow, false))
|
|
{
|
|
s_is_showing_all_leaderboard_entries = !s_is_showing_all_leaderboard_entries;
|
|
}
|
|
|
|
ImGuiFullscreen::BeginNavBar();
|
|
|
|
for (const bool show_all : {false, true})
|
|
{
|
|
const char* title = show_all ? TRANSLATE("Achievements", "Show Best") : TRANSLATE("Achievements", "Show Nearby");
|
|
if (ImGuiFullscreen::NavTab(title, s_is_showing_all_leaderboard_entries == show_all, true, tab_width,
|
|
tab_height_unscaled, heading_background))
|
|
{
|
|
s_is_showing_all_leaderboard_entries = show_all;
|
|
}
|
|
}
|
|
|
|
ImGuiFullscreen::EndNavBar();
|
|
|
|
const ImVec2 bg_pos = ImVec2(0.0f, ImGui::GetCurrentWindow()->DC.CursorPos.y + LayoutScale(tab_height_unscaled));
|
|
const ImVec2 bg_size =
|
|
ImVec2(ImGui::GetWindowWidth(), spacing + LayoutScale(ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY) + spacing);
|
|
ImGui::GetWindowDrawList()->AddRectFilled(bg_pos, bg_pos + bg_size, ImGui::GetColorU32(heading_background));
|
|
|
|
ImGui::SetCursorPos(ImVec2(0.0f, ImGui::GetCursorPosY() + LayoutScale(tab_height_unscaled) + spacing));
|
|
|
|
ImGuiFullscreen::MenuButtonFrame(
|
|
"legend", false, ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY, &visible, &hovered, &bb.Min, &bb.Max, 0, alpha);
|
|
|
|
const float midpoint = bb.Min.y + GetLineHeight(g_large_font) + LayoutScale(4.0f);
|
|
const float icon_column_width = bb.Max.y - bb.Min.y;
|
|
float column_left = bb.Min.x + LayoutScale(15.0f) + padding;
|
|
|
|
ImGui::PushFont(g_large_font.first, g_large_font.second);
|
|
|
|
const auto render_centered_heading = [&](const char* text, float width) {
|
|
const ImRect rect(ImVec2(column_left, bb.Min.y), ImVec2(column_left + width, midpoint));
|
|
ImGuiFullscreen::RenderTextClippedWithShadow(rect.Min, rect.Max, text, nullptr, nullptr, ImVec2(0.5f, 0.0f), &rect);
|
|
column_left += width + column_spacing;
|
|
};
|
|
|
|
render_centered_heading(TRANSLATE("Achievements", "Rank"), rank_column_width);
|
|
|
|
const float name_column_total_width = icon_column_width + column_spacing + name_column_width;
|
|
const ImRect user_bb(ImVec2(column_left + icon_column_width + column_spacing, bb.Min.y),
|
|
ImVec2(column_left + name_column_total_width, midpoint));
|
|
ImGuiFullscreen::RenderTextClippedWithShadow(
|
|
user_bb.Min, user_bb.Max, TRANSLATE("Achievements", "Name"), nullptr, nullptr, ImVec2(0.5f, 0.0f), &user_bb);
|
|
column_left += name_column_total_width + column_spacing;
|
|
|
|
static const char* value_headings[NUM_RC_CLIENT_LEADERBOARD_FORMATS] = {
|
|
TRANSLATE_NOOP("Achievements", "Time"),
|
|
TRANSLATE_NOOP("Achievements", "Score"),
|
|
TRANSLATE_NOOP("Achievements", "Value"),
|
|
};
|
|
|
|
render_centered_heading(
|
|
Host::TranslateToCString(
|
|
"Achievements", value_headings[std::min<u8>(s_open_leaderboard->format, NUM_RC_CLIENT_LEADERBOARD_FORMATS - 1)]),
|
|
time_column_width);
|
|
|
|
const float date_column_width = std::max(bb.Max.x - column_left, 0.0f);
|
|
const ImRect date_bb(ImVec2(column_left, bb.Min.y), ImVec2(column_left + date_column_width, midpoint));
|
|
ImGuiFullscreen::RenderTextClippedWithShadow(
|
|
date_bb.Min, date_bb.Max, TRANSLATE("Achievements", "Date Submitted"), nullptr, nullptr, ImVec2(0.5f, 0.0f), &date_bb);
|
|
column_left += date_column_width;
|
|
|
|
ImGui::PopFont();
|
|
|
|
const float line_thickness = LayoutScale(1.0f);
|
|
const float line_padding = LayoutScale(5.0f);
|
|
const ImVec2 line_start(bb.Min.x, bb.Min.y + GetLineHeight(g_large_font) + line_padding);
|
|
const ImVec2 line_end(bb.Max.x, line_start.y);
|
|
ImGui::GetWindowDrawList()->AddLine(line_start, line_end, ImGui::GetColorU32(ImGuiCol_TextDisabled), line_thickness);
|
|
}
|
|
}
|
|
}
|
|
ImGuiFullscreen::EndFullscreenWindow();
|
|
|
|
if (!is_leaderboard_open)
|
|
{
|
|
const float sidebar_width = has_multiple_subsets ? LayoutScale(200.0f) : 0.0f;
|
|
|
|
if (ImGuiFullscreen::BeginFullscreenWindow(
|
|
ImVec2(0.0f, heading_height),
|
|
ImVec2(display_size.x, display_size.y - heading_height - LayoutScale(ImGuiFullscreen::LAYOUT_FOOTER_HEIGHT)),
|
|
"leaderboards", background, 0.0f, ImVec2(0.0f, 0.0f), 0))
|
|
{
|
|
if (has_multiple_subsets)
|
|
{
|
|
if (focus_sidebar)
|
|
{
|
|
ImGui::SetNextWindowFocus();
|
|
}
|
|
if (DrawSubsetSidebar(sidebar_width, sidebar_has_focus, focus_sidebar, true))
|
|
ImGui::SetNextWindowFocus();
|
|
}
|
|
|
|
const float content_width = has_multiple_subsets ? (display_size.x - sidebar_width) : display_size.x;
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(ImGuiFullscreen::LAYOUT_MENU_WINDOW_X_PADDING, 0.0f));
|
|
ImGui::PushStyleColor(ImGuiCol_ChildBg, background);
|
|
ImGui::PushStyleColor(ImGuiCol_Header, ImGui::GetColorU32(ImGuiFullscreen::UIPrimaryDarkColor));
|
|
ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImGui::GetColorU32(ImGuiFullscreen::UIPrimaryColor));
|
|
ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImGui::GetColorU32(ImGuiFullscreen::UISecondaryColor));
|
|
|
|
if (ImGui::BeginChild("leaderboards_content", ImVec2(content_width, 0.0f), ImGuiChildFlags_NavFlattened, 0))
|
|
{
|
|
if (has_multiple_subsets && !sidebar_has_focus &&
|
|
(ImGui::IsKeyPressed(ImGuiKey_GamepadDpadLeft, false) || ImGui::IsKeyPressed(ImGuiKey_LeftArrow, false)))
|
|
{
|
|
sidebar_has_focus = true;
|
|
focus_sidebar = true;
|
|
}
|
|
|
|
if (s_restore_leaderboard_scroll)
|
|
{
|
|
ImGui::SetScrollY(s_leaderboard_list_scroll);
|
|
s_restore_leaderboard_scroll = false;
|
|
}
|
|
|
|
const bool had_pending_focus_request = s_restore_leaderboard_focus;
|
|
|
|
ImGuiFullscreen::BeginMenuButtons();
|
|
|
|
static const char* lboard_bucket_names[NUM_RC_CLIENT_LEADERBOARD_BUCKETS] = {
|
|
TRANSLATE_NOOP("Achievements", "Unknown"),
|
|
TRANSLATE_NOOP("Achievements", "Inactive"),
|
|
TRANSLATE_NOOP("Achievements", "Active"),
|
|
TRANSLATE_NOOP("Achievements", "Unsupported"),
|
|
TRANSLATE_NOOP("Achievements", "All"),
|
|
};
|
|
const bool show_bucket_headers = (s_leaderboard_list->num_buckets > 1);
|
|
|
|
for (u32 bucket_index = 0; bucket_index < s_leaderboard_list->num_buckets; bucket_index++)
|
|
{
|
|
const rc_client_leaderboard_bucket_t& bucket = s_leaderboard_list->buckets[bucket_index];
|
|
if (s_open_subset && bucket.subset_id != 0 && bucket.subset_id != s_open_subset->id)
|
|
continue;
|
|
if (show_bucket_headers)
|
|
{
|
|
pxAssert(bucket.bucket_type < NUM_RC_CLIENT_LEADERBOARD_BUCKETS);
|
|
const char* type_name = Host::TranslateToCString("Achievements", lboard_bucket_names[bucket.bucket_type]);
|
|
|
|
TinyString bucket_label;
|
|
if (bucket.subset_id != 0 && !s_open_subset && s_subset_list)
|
|
{
|
|
auto subset_iter = std::find_if(s_subset_list->subsets, s_subset_list->subsets + s_subset_list->num_subsets,
|
|
[&](const rc_client_subset_t* s) { return s->id == bucket.subset_id; });
|
|
if (subset_iter != s_subset_list->subsets + s_subset_list->num_subsets)
|
|
bucket_label.format("{} - {}", (*subset_iter)->title, type_name);
|
|
else
|
|
bucket_label.assign(type_name);
|
|
}
|
|
else
|
|
{
|
|
bucket_label.assign(type_name);
|
|
}
|
|
|
|
bool& bucket_collapsed = s_leaderboard_buckets_collapsed[std::make_pair(bucket.subset_id, bucket.bucket_type)];
|
|
if (ImGuiFullscreen::MenuHeadingButton(
|
|
bucket_label, bucket_collapsed ? ICON_FA_CHEVRON_DOWN : ICON_FA_CHEVRON_UP))
|
|
bucket_collapsed = !bucket_collapsed;
|
|
if (bucket_collapsed)
|
|
continue;
|
|
}
|
|
for (u32 i = 0; i < bucket.num_leaderboards; i++)
|
|
DrawLeaderboardListEntry(bucket.leaderboards[i]);
|
|
}
|
|
|
|
ImGuiFullscreen::EndMenuButtons();
|
|
|
|
if (had_pending_focus_request && s_restore_leaderboard_focus)
|
|
s_restore_leaderboard_focus = false;
|
|
}
|
|
ImGui::EndChild();
|
|
|
|
ImGui::PopStyleColor(4);
|
|
ImGui::PopStyleVar();
|
|
}
|
|
ImGuiFullscreen::EndFullscreenWindow();
|
|
|
|
if (has_multiple_subsets)
|
|
DrawSubsetSidebarFooter(sidebar_has_focus);
|
|
else
|
|
FullscreenUI::SetStandardSelectionFooterText(true);
|
|
}
|
|
else
|
|
{
|
|
if (ImGuiFullscreen::BeginFullscreenWindow(
|
|
ImVec2(0.0f, heading_height),
|
|
ImVec2(display_size.x, display_size.y - heading_height - LayoutScale(ImGuiFullscreen::LAYOUT_FOOTER_HEIGHT)),
|
|
"leaderboard", background, 0.0f, ImVec2(ImGuiFullscreen::LAYOUT_MENU_WINDOW_X_PADDING, 0.0f), 0))
|
|
{
|
|
ImGuiFullscreen::BeginMenuButtons();
|
|
|
|
if (!s_is_showing_all_leaderboard_entries)
|
|
{
|
|
if (s_leaderboard_nearby_entries)
|
|
{
|
|
for (u32 i = 0; i < s_leaderboard_nearby_entries->num_entries; i++)
|
|
{
|
|
DrawLeaderboardEntry(s_leaderboard_nearby_entries->entries[i],
|
|
static_cast<s32>(i) == s_leaderboard_nearby_entries->user_index, rank_column_width, name_column_width,
|
|
time_column_width, column_spacing);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
ImGui::PushFont(g_large_font.first, g_large_font.second);
|
|
|
|
const ImVec2 pos_min(0.0f, heading_height);
|
|
const ImVec2 pos_max(display_size.x, display_size.y);
|
|
ImGui::RenderTextClipped(pos_min, pos_max, TRANSLATE("Achievements", "Downloading leaderboard data, please wait..."),
|
|
nullptr, nullptr, ImVec2(0.5f, 0.5f));
|
|
|
|
ImGui::PopFont();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
for (const rc_client_leaderboard_entry_list_t* list : s_leaderboard_entry_lists)
|
|
{
|
|
for (u32 i = 0; i < list->num_entries; i++)
|
|
{
|
|
DrawLeaderboardEntry(list->entries[i], static_cast<s32>(i) == list->user_index, rank_column_width,
|
|
name_column_width, time_column_width, column_spacing);
|
|
}
|
|
}
|
|
|
|
// Fetch next chunk if the loading indicator becomes visible (i.e. we scrolled enough).
|
|
bool visible, hovered;
|
|
ImGuiFullscreen::MenuButtonFrame(TRANSLATE("Achievements", "Loading..."), false,
|
|
ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY, &visible, &hovered, &bb.Min, &bb.Max);
|
|
if (visible)
|
|
{
|
|
const float midpoint = bb.Min.y + GetLineHeight(g_large_font) + LayoutScale(4.0f);
|
|
const ImRect title_bb(bb.Min, ImVec2(bb.Max.x, midpoint));
|
|
|
|
ImGui::PushFont(g_large_font.first, g_large_font.second);
|
|
ImGui::RenderTextClipped(
|
|
title_bb.Min, title_bb.Max, TRANSLATE("Achievements", "Loading..."), nullptr, nullptr, ImVec2(0, 0), &title_bb);
|
|
ImGui::PopFont();
|
|
|
|
if (!s_leaderboard_fetch_handle)
|
|
FetchNextLeaderboardEntries();
|
|
}
|
|
}
|
|
|
|
ImGuiFullscreen::EndMenuButtons();
|
|
}
|
|
ImGuiFullscreen::EndFullscreenWindow();
|
|
|
|
if (ImGuiFullscreen::IsGamepadInputSource())
|
|
{
|
|
const bool circleOK = ImGui::GetIO().ConfigNavSwapGamepadButtons;
|
|
const auto glyphs = ImGuiFullscreen::GetGamepadGlyphs();
|
|
ImGuiFullscreen::SetFullscreenFooterText(std::array{
|
|
std::make_pair(glyphs.dpad_lr, TRANSLATE_SV("Achievements", "Switch Rankings")),
|
|
std::make_pair(glyphs.dpad_ud, TRANSLATE_SV("Achievements", "Change Selection")),
|
|
std::make_pair(glyphs.cancel(circleOK), TRANSLATE_SV("Achievements", "Back")),
|
|
});
|
|
}
|
|
else
|
|
{
|
|
ImGuiFullscreen::SetFullscreenFooterText(std::array{
|
|
std::make_pair(ICON_PF_ARROW_LEFT ICON_PF_ARROW_RIGHT, TRANSLATE_SV("Achievements", "Switch Rankings")),
|
|
std::make_pair(ICON_PF_ARROW_UP ICON_PF_ARROW_DOWN, TRANSLATE_SV("Achievements", "Change Selection")),
|
|
std::make_pair(ICON_PF_ESC, TRANSLATE_SV("Achievements", "Back")),
|
|
});
|
|
}
|
|
}
|
|
|
|
if (close_leaderboard_on_exit)
|
|
CloseLeaderboard();
|
|
}
|
|
|
|
void Achievements::DrawLeaderboardEntry(const rc_client_leaderboard_entry_t& entry, bool is_self, float rank_column_width,
|
|
float name_column_width, float time_column_width, float column_spacing)
|
|
{
|
|
using ImGuiFullscreen::g_large_font;
|
|
using ImGuiFullscreen::GetLineHeight;
|
|
using ImGuiFullscreen::LayoutScale;
|
|
|
|
static constexpr float alpha = 0.8f;
|
|
|
|
ImRect bb;
|
|
bool visible, hovered;
|
|
bool pressed = ImGuiFullscreen::MenuButtonFrame(
|
|
entry.user, true, ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY, &visible, &hovered, &bb.Min, &bb.Max, 0, alpha);
|
|
if (!visible)
|
|
return;
|
|
|
|
const float midpoint = bb.Min.y + GetLineHeight(g_large_font) + LayoutScale(4.0f);
|
|
const float column_padding = LayoutScale(15.0f);
|
|
const float icon_column_width = bb.Max.y - bb.Min.y;
|
|
float column_left = bb.Min.x + column_padding;
|
|
SmallString text;
|
|
|
|
text.format("{}", entry.rank);
|
|
|
|
ImGui::PushFont(g_large_font.first, g_large_font.second);
|
|
|
|
if (is_self)
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(255, 242, 0, 255));
|
|
|
|
const ImRect rank_bb(ImVec2(column_left, bb.Min.y), ImVec2(column_left + rank_column_width, midpoint));
|
|
ImGuiFullscreen::RenderTextClippedWithShadow(
|
|
rank_bb.Min, rank_bb.Max, text.c_str(), text.end_ptr(), nullptr, ImVec2(0.0f, 0.0f), &rank_bb);
|
|
column_left += rank_column_width + column_spacing;
|
|
|
|
const ImRect icon_bb(ImVec2(column_left, bb.Min.y), ImVec2(column_left + icon_column_width, bb.Min.y + icon_column_width));
|
|
GSTexture* icon_tex = nullptr;
|
|
if (auto it = std::find_if(s_leaderboard_user_icon_paths.begin(), s_leaderboard_user_icon_paths.end(),
|
|
[&entry](const auto& it) { return it.first == &entry; });
|
|
it != s_leaderboard_user_icon_paths.end())
|
|
{
|
|
if (!it->second.empty())
|
|
icon_tex = ImGuiFullscreen::GetCachedTextureAsync(it->second.c_str());
|
|
}
|
|
else
|
|
{
|
|
std::string path = Achievements::GetLeaderboardUserBadgePath(&entry);
|
|
if (!path.empty())
|
|
{
|
|
icon_tex = ImGuiFullscreen::GetCachedTextureAsync(path.c_str());
|
|
s_leaderboard_user_icon_paths.emplace_back(&entry, std::move(path));
|
|
}
|
|
}
|
|
if (icon_tex)
|
|
{
|
|
ImGui::GetWindowDrawList()->AddImage(reinterpret_cast<ImTextureID>(icon_tex->GetNativeHandle()),
|
|
icon_bb.Min, icon_bb.Min + ImVec2(icon_column_width, icon_column_width));
|
|
}
|
|
|
|
column_left += icon_column_width + column_spacing;
|
|
|
|
const ImRect user_bb(ImVec2(column_left, bb.Min.y), ImVec2(column_left + name_column_width, midpoint));
|
|
ImGuiFullscreen::RenderTextClippedWithShadow(user_bb.Min, user_bb.Max, entry.user, nullptr, nullptr, ImVec2(0.0f, 0.0f), &user_bb);
|
|
column_left += name_column_width + column_spacing;
|
|
|
|
const ImRect score_bb(ImVec2(column_left, bb.Min.y), ImVec2(column_left + time_column_width, midpoint));
|
|
ImGuiFullscreen::RenderTextClippedWithShadow(
|
|
score_bb.Min, score_bb.Max, entry.display, nullptr, nullptr, ImVec2(0.0f, 0.0f), &score_bb);
|
|
column_left += time_column_width + column_spacing;
|
|
|
|
const ImRect time_bb(ImVec2(column_left, bb.Min.y), ImVec2(bb.Max.x, midpoint));
|
|
const auto submit_time = FullscreenUI::TimeToPrintableString(entry.submitted);
|
|
ImGuiFullscreen::RenderTextClippedWithShadow(
|
|
time_bb.Min, time_bb.Max, submit_time.c_str(), submit_time.end_ptr(), nullptr, ImVec2(0.0f, 0.0f), &time_bb);
|
|
|
|
if (is_self)
|
|
ImGui::PopStyleColor();
|
|
|
|
ImGui::PopFont();
|
|
|
|
if (pressed)
|
|
{
|
|
// Anything?
|
|
}
|
|
}
|
|
void Achievements::DrawLeaderboardListEntry(const rc_client_leaderboard_t* lboard)
|
|
{
|
|
using ImGuiFullscreen::g_large_font;
|
|
using ImGuiFullscreen::g_medium_font;
|
|
using ImGuiFullscreen::GetLineHeight;
|
|
using ImGuiFullscreen::LayoutScale;
|
|
|
|
static constexpr float alpha = 0.8f;
|
|
|
|
TinyString id_str;
|
|
id_str.format("{}", lboard->id);
|
|
|
|
ImRect bb;
|
|
bool visible, hovered;
|
|
bool pressed = ImGuiFullscreen::MenuButtonFrame(
|
|
id_str, true, ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT, &visible, &hovered, &bb.Min, &bb.Max, 0, alpha);
|
|
if (!visible)
|
|
return;
|
|
|
|
if (s_restore_leaderboard_focus && s_has_last_selected_leaderboard && lboard->id == s_last_selected_leaderboard_id)
|
|
{
|
|
ImGui::SetItemDefaultFocus();
|
|
ImGui::SetScrollHereY(0.5f);
|
|
s_restore_leaderboard_focus = false;
|
|
}
|
|
|
|
const float midpoint = bb.Min.y + GetLineHeight(g_large_font) + LayoutScale(4.0f);
|
|
const float text_start_x = bb.Min.x + LayoutScale(15.0f);
|
|
const ImRect title_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint));
|
|
const ImRect summary_bb(ImVec2(text_start_x, midpoint), bb.Max);
|
|
|
|
ImGui::PushFont(g_large_font.first, g_large_font.second);
|
|
ImGuiFullscreen::RenderTextClippedWithShadow(title_bb.Min, title_bb.Max, lboard->title, nullptr, nullptr, ImVec2(0.0f, 0.0f), &title_bb);
|
|
ImGui::PopFont();
|
|
|
|
if (lboard->description && lboard->description[0] != '\0')
|
|
{
|
|
ImGui::PushFont(g_medium_font.first, g_medium_font.second);
|
|
ImGuiFullscreen::RenderTextClippedWithShadow(
|
|
summary_bb.Min, summary_bb.Max, lboard->description, nullptr, nullptr, ImVec2(0.0f, 0.0f), &summary_bb);
|
|
ImGui::PopFont();
|
|
}
|
|
|
|
if (pressed)
|
|
{
|
|
s_leaderboard_list_scroll = ImGui::GetScrollY();
|
|
OpenLeaderboard(lboard);
|
|
}
|
|
}
|
|
|
|
void Achievements::OpenLeaderboard(const rc_client_leaderboard_t* lboard)
|
|
{
|
|
Console.WriteLn("Achievements: Opening leaderboard '%s' (%u)", lboard->title, lboard->id);
|
|
|
|
CloseLeaderboard();
|
|
|
|
s_last_selected_leaderboard_id = lboard->id;
|
|
s_has_last_selected_leaderboard = true;
|
|
s_restore_leaderboard_focus = false;
|
|
|
|
s_open_leaderboard = lboard;
|
|
s_is_showing_all_leaderboard_entries = false;
|
|
s_leaderboard_fetch_handle = rc_client_begin_fetch_leaderboard_entries_around_user(
|
|
s_client, lboard->id, LEADERBOARD_NEARBY_ENTRIES_TO_FETCH, LeaderboardFetchNearbyCallback, nullptr);
|
|
}
|
|
|
|
void Achievements::LeaderboardFetchNearbyCallback(
|
|
int result, const char* error_message, rc_client_leaderboard_entry_list_t* list, rc_client_t* client, void* callback_userdata)
|
|
{
|
|
const auto lock = GetLock();
|
|
|
|
s_leaderboard_fetch_handle = nullptr;
|
|
|
|
if (result != RC_OK)
|
|
{
|
|
ImGuiFullscreen::AddNotification("leaderboard_dl_fail", Host::OSD_INFO_DURATION,
|
|
TRANSLATE("Achievements", "Leaderboard Download Failed"), error_message, s_game_icon);
|
|
CloseLeaderboard();
|
|
return;
|
|
}
|
|
|
|
if (s_leaderboard_nearby_entries)
|
|
rc_client_destroy_leaderboard_entry_list(s_leaderboard_nearby_entries);
|
|
s_leaderboard_nearby_entries = list;
|
|
}
|
|
|
|
void Achievements::LeaderboardFetchAllCallback(
|
|
int result, const char* error_message, rc_client_leaderboard_entry_list_t* list, rc_client_t* client, void* callback_userdata)
|
|
{
|
|
const auto lock = GetLock();
|
|
|
|
s_leaderboard_fetch_handle = nullptr;
|
|
|
|
if (result != RC_OK)
|
|
{
|
|
ImGuiFullscreen::AddNotification("leaderboard_dl_fail", Host::OSD_INFO_DURATION,
|
|
TRANSLATE("Achievements", "Leaderboard Download Failed"), error_message, s_game_icon);
|
|
CloseLeaderboard();
|
|
return;
|
|
}
|
|
|
|
s_leaderboard_entry_lists.push_back(list);
|
|
}
|
|
|
|
void Achievements::FetchNextLeaderboardEntries()
|
|
{
|
|
u32 start = 1;
|
|
for (rc_client_leaderboard_entry_list_t* list : s_leaderboard_entry_lists)
|
|
start += list->num_entries;
|
|
|
|
Console.WriteLn("Achievements: Fetching entries %u to %u", start, start + LEADERBOARD_ALL_FETCH_SIZE);
|
|
|
|
if (s_leaderboard_fetch_handle)
|
|
rc_client_abort_async(s_client, s_leaderboard_fetch_handle);
|
|
s_leaderboard_fetch_handle = rc_client_begin_fetch_leaderboard_entries(
|
|
s_client, s_open_leaderboard->id, start, LEADERBOARD_ALL_FETCH_SIZE, LeaderboardFetchAllCallback, nullptr);
|
|
}
|
|
|
|
void Achievements::CloseLeaderboard()
|
|
{
|
|
s_leaderboard_user_icon_paths.clear();
|
|
|
|
for (auto iter = s_leaderboard_entry_lists.rbegin(); iter != s_leaderboard_entry_lists.rend(); ++iter)
|
|
rc_client_destroy_leaderboard_entry_list(*iter);
|
|
s_leaderboard_entry_lists.clear();
|
|
|
|
if (s_leaderboard_nearby_entries)
|
|
{
|
|
rc_client_destroy_leaderboard_entry_list(s_leaderboard_nearby_entries);
|
|
s_leaderboard_nearby_entries = nullptr;
|
|
}
|
|
|
|
if (s_leaderboard_fetch_handle)
|
|
{
|
|
rc_client_abort_async(s_client, s_leaderboard_fetch_handle);
|
|
s_leaderboard_fetch_handle = nullptr;
|
|
}
|
|
|
|
s_open_leaderboard = nullptr;
|
|
}
|
|
|
|
std::string Achievements::GetSubsetBadgePath(const rc_client_subset_t* subset)
|
|
{
|
|
if (!subset || !subset->badge_name[0])
|
|
return {};
|
|
|
|
std::string path = Path::Combine(s_image_directory, fmt::format("subset_{}.png", subset->badge_name));
|
|
if (!path.empty() && subset->badge_url && !FileSystem::FileExists(path.c_str()))
|
|
DownloadImage(subset->badge_url, path);
|
|
|
|
return path;
|
|
}
|
|
|
|
void Achievements::OpenSubset(const rc_client_subset_t* subset)
|
|
{
|
|
Console.WriteLn("Achievements: Opening subset '%s' (%u)", subset->title, subset->id);
|
|
s_open_subset = subset;
|
|
}
|
|
|
|
void Achievements::CloseSubset()
|
|
{
|
|
s_open_subset = nullptr;
|
|
}
|
|
|
|
#ifdef ENABLE_RAINTEGRATION
|
|
|
|
#include "RA_Consoles.h"
|
|
|
|
namespace Achievements::RAIntegration
|
|
{
|
|
static void InitializeRAIntegration(void* main_window_handle);
|
|
|
|
static int RACallbackIsActive();
|
|
static void RACallbackCauseUnpause();
|
|
static void RACallbackCausePause();
|
|
static void RACallbackRebuildMenu();
|
|
static void RACallbackEstimateTitle(char* buf);
|
|
static void RACallbackResetEmulator();
|
|
static void RACallbackLoadROM(const char* unused);
|
|
static unsigned char RACallbackReadMemory(unsigned int address);
|
|
static unsigned int RACallbackReadBlock(unsigned int address, unsigned char* buffer, unsigned int bytes);
|
|
static void RACallbackWriteMemory(unsigned int address, unsigned char value);
|
|
|
|
static bool s_raintegration_initialized = false;
|
|
} // namespace Achievements::RAIntegration
|
|
|
|
bool Achievements::IsUsingRAIntegration()
|
|
{
|
|
return s_using_raintegration;
|
|
}
|
|
|
|
void Achievements::SwitchToRAIntegration()
|
|
{
|
|
s_using_raintegration = true;
|
|
}
|
|
|
|
void Achievements::RAIntegration::InitializeRAIntegration(void* main_window_handle)
|
|
{
|
|
RA_InitClient((HWND)main_window_handle, "PCSX2", BuildVersion::GitTag);
|
|
RA_SetUserAgentDetail(Host::GetHTTPUserAgent().c_str());
|
|
|
|
RA_InstallSharedFunctions(RACallbackIsActive, RACallbackCauseUnpause, RACallbackCausePause, RACallbackRebuildMenu,
|
|
RACallbackEstimateTitle, RACallbackResetEmulator, RACallbackLoadROM);
|
|
RA_SetConsoleID(PlayStation2);
|
|
|
|
// EE physical memory and scratchpad are currently exposed (matching direct rcheevos implementation).
|
|
RA_InstallMemoryBank(0, RACallbackReadMemory, RACallbackWriteMemory, GetExposedEEMemorySize());
|
|
RA_InstallMemoryBankBlockReader(0, RACallbackReadBlock);
|
|
|
|
// Fire off a login anyway. Saves going into the menu and doing it.
|
|
RA_AttemptLogin(0);
|
|
|
|
s_raintegration_initialized = true;
|
|
|
|
// this is pretty lame, but we may as well persist until we exit anyway
|
|
std::atexit(RA_Shutdown);
|
|
}
|
|
|
|
void Achievements::RAIntegration::MainWindowChanged(void* new_handle)
|
|
{
|
|
if (s_raintegration_initialized)
|
|
{
|
|
RA_UpdateHWnd((HWND)new_handle);
|
|
return;
|
|
}
|
|
|
|
InitializeRAIntegration(new_handle);
|
|
}
|
|
|
|
void Achievements::RAIntegration::GameChanged()
|
|
{
|
|
s_game_id = s_game_hash.empty() ? 0 : RA_IdentifyHash(s_game_hash.c_str());
|
|
RA_ActivateGame(s_game_id);
|
|
}
|
|
|
|
std::vector<std::tuple<int, std::string, bool>> Achievements::RAIntegration::GetMenuItems()
|
|
{
|
|
std::array<RA_MenuItem, 64> items;
|
|
const int num_items = RA_GetPopupMenuItems(items.data());
|
|
|
|
std::vector<std::tuple<int, std::string, bool>> ret;
|
|
ret.reserve(static_cast<u32>(num_items));
|
|
|
|
for (int i = 0; i < num_items; i++)
|
|
{
|
|
const RA_MenuItem& it = items[i];
|
|
if (!it.sLabel)
|
|
{
|
|
// separator
|
|
ret.emplace_back(0, std::string(), false);
|
|
}
|
|
else
|
|
{
|
|
// option, maybe checkable
|
|
ret.emplace_back(static_cast<int>(it.nID), StringUtil::WideStringToUTF8String(it.sLabel), it.bChecked);
|
|
}
|
|
}
|
|
|
|
return ret;
|
|
}
|
|
|
|
void Achievements::RAIntegration::ActivateMenuItem(int item)
|
|
{
|
|
RA_InvokeDialog(item);
|
|
}
|
|
|
|
int Achievements::RAIntegration::RACallbackIsActive()
|
|
{
|
|
return static_cast<int>(VMManager::HasValidVM());
|
|
}
|
|
|
|
void Achievements::RAIntegration::RACallbackCauseUnpause()
|
|
{
|
|
Host::RunOnCPUThread([]() {
|
|
if (VMManager::HasValidVM())
|
|
if (VMManager::HasValidVM())
|
|
VMManager::SetState(VMState::Running);
|
|
});
|
|
}
|
|
|
|
void Achievements::RAIntegration::RACallbackCausePause()
|
|
{
|
|
Host::RunOnCPUThread([]() {
|
|
if (VMManager::HasValidVM())
|
|
VMManager::SetState(VMState::Paused);
|
|
});
|
|
}
|
|
|
|
void Achievements::RAIntegration::RACallbackRebuildMenu()
|
|
{
|
|
// Sync hardcore state in case the user changed it via the RA menu.
|
|
const bool ra_hardcore = (RA_HardcoreModeIsActive() != 0);
|
|
if (ra_hardcore != s_hardcore_mode)
|
|
{
|
|
s_hardcore_mode = ra_hardcore;
|
|
Host::OnAchievementsHardcoreModeChanged(ra_hardcore);
|
|
}
|
|
}
|
|
|
|
void Achievements::RAIntegration::RACallbackEstimateTitle(char* buf)
|
|
{
|
|
std::string title(fmt::format("{0} ({1}) [{2:08X}]", VMManager::GetTitle(false), VMManager::GetDiscSerial(), VMManager::GetDiscCRC()));
|
|
StringUtil::Strlcpy(buf, title, 256);
|
|
}
|
|
|
|
void Achievements::RAIntegration::RACallbackResetEmulator()
|
|
{
|
|
if (VMManager::HasValidVM())
|
|
VMManager::Reset();
|
|
}
|
|
|
|
void Achievements::RAIntegration::RACallbackLoadROM(const char* unused)
|
|
{
|
|
// unused
|
|
UNREFERENCED_PARAMETER(unused);
|
|
}
|
|
|
|
unsigned char Achievements::RAIntegration::RACallbackReadMemory(unsigned int address)
|
|
{
|
|
if ((static_cast<u64>(address) + sizeof(unsigned char)) > GetExposedEEMemorySize())
|
|
{
|
|
DevCon.Warning("[Achievements] Ignoring out of bounds memory peek at %08X.", address);
|
|
return 0u;
|
|
}
|
|
|
|
unsigned char value;
|
|
const u8* ptr = (address < Ps2MemSize::ExposedRam) ? &eeMem->Main[address] : &eeMem->Scratch[address - Ps2MemSize::ExposedRam];
|
|
std::memcpy(&value, ptr, sizeof(value));
|
|
return value;
|
|
}
|
|
|
|
unsigned int Achievements::RAIntegration::RACallbackReadBlock(unsigned int address, unsigned char* buffer, unsigned int bytes)
|
|
{
|
|
if ((address >= GetExposedEEMemorySize())) [[unlikely]]
|
|
{
|
|
DevCon.Warning("[Achievements] Ignoring out of bounds block memory read for %u bytes at %08X.", bytes, address);
|
|
return 0u;
|
|
}
|
|
|
|
if (address < Ps2MemSize::ExposedRam && (address + bytes) > Ps2MemSize::ExposedRam) [[unlikely]]
|
|
{
|
|
// Split across RAM+Scratch.
|
|
const unsigned int bytes_from_ram = Ps2MemSize::ExposedRam - address;
|
|
const unsigned int bytes_from_scratch = bytes - bytes_from_ram;
|
|
return (RACallbackReadBlock(address, buffer, bytes_from_ram) +
|
|
RACallbackReadBlock(address + bytes_from_ram, buffer + bytes_from_ram, bytes_from_scratch));
|
|
}
|
|
|
|
const unsigned int read_byte_count = std::min<unsigned int>(GetExposedEEMemorySize() - address, bytes);
|
|
const u8* ptr = (address < Ps2MemSize::ExposedRam) ? &eeMem->Main[address] : &eeMem->Scratch[address - Ps2MemSize::ExposedRam];
|
|
std::memcpy(buffer, ptr, read_byte_count);
|
|
return read_byte_count;
|
|
}
|
|
|
|
void Achievements::RAIntegration::RACallbackWriteMemory(unsigned int address, unsigned char value)
|
|
{
|
|
if ((static_cast<u64>(address) + sizeof(value)) > GetExposedEEMemorySize()) [[unlikely]]
|
|
{
|
|
DevCon.Warning("[Achievements] Ignoring out of bounds memory poke at %08X (value %08X).", address, value);
|
|
return;
|
|
}
|
|
|
|
u8* ptr = (address < Ps2MemSize::ExposedRam) ? &eeMem->Main[address] : &eeMem->Scratch[address - Ps2MemSize::ExposedRam];
|
|
std::memcpy(ptr, &value, sizeof(value));
|
|
}
|
|
|
|
#else
|
|
|
|
bool Achievements::IsUsingRAIntegration()
|
|
{
|
|
return false;
|
|
}
|
|
|
|
bool Achievements::GetCurrentUserStats(UserStats* stats)
|
|
{
|
|
auto lock = GetLock();
|
|
if (!s_client)
|
|
return false;
|
|
|
|
const rc_client_user_t* user = rc_client_get_user_info(s_client);
|
|
if (!user)
|
|
return false;
|
|
|
|
stats->username = user->username ? user->username : "";
|
|
stats->display_name = user->display_name ? user->display_name : "";
|
|
stats->points = user->score;
|
|
stats->softcore_points = user->score_softcore;
|
|
stats->unread_messages = user->num_unread_messages;
|
|
stats->avatar_path = GetUserBadgePath(user->username ? user->username : "");
|
|
return true;
|
|
}
|
|
|
|
bool Achievements::GetCurrentGameStats(GameStats* stats)
|
|
{
|
|
auto lock = GetLock();
|
|
if (!s_client)
|
|
return false;
|
|
|
|
const rc_client_game_t* game = rc_client_get_game_info(s_client);
|
|
if (!game)
|
|
return false;
|
|
|
|
rc_client_user_game_summary_t summary;
|
|
std::memset(&summary, 0, sizeof(summary));
|
|
rc_client_get_user_game_summary(s_client, &summary);
|
|
|
|
stats->title = game->title ? game->title : "";
|
|
stats->icon_url = game->badge_url ? game->badge_url : "";
|
|
stats->icon_path = s_game_icon;
|
|
stats->rich_presence = s_rich_presence_string;
|
|
stats->game_id = game->id;
|
|
stats->unlocked_achievements = summary.num_unlocked_achievements;
|
|
stats->total_achievements = summary.num_core_achievements;
|
|
stats->unlocked_points = summary.points_unlocked;
|
|
stats->total_points = summary.points_core;
|
|
stats->has_achievements = rc_client_has_achievements(s_client) != 0;
|
|
stats->has_leaderboards = rc_client_has_leaderboards(s_client) != 0;
|
|
stats->has_rich_presence = rc_client_has_rich_presence(s_client) != 0;
|
|
return true;
|
|
}
|
|
|
|
bool Achievements::GetCurrentAchievementList(std::vector<AchievementInfo>* achievements)
|
|
{
|
|
auto lock = GetLock();
|
|
if (!s_client || !achievements)
|
|
return false;
|
|
|
|
rc_client_achievement_list_t* list = rc_client_create_achievement_list(
|
|
s_client, RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE_AND_UNOFFICIAL,
|
|
RC_CLIENT_ACHIEVEMENT_LIST_GROUPING_LOCK_STATE);
|
|
if (!list)
|
|
return false;
|
|
|
|
static constexpr u32 bucket_order[] = {
|
|
RC_CLIENT_ACHIEVEMENT_BUCKET_ACTIVE_CHALLENGE,
|
|
RC_CLIENT_ACHIEVEMENT_BUCKET_RECENTLY_UNLOCKED,
|
|
RC_CLIENT_ACHIEVEMENT_BUCKET_UNLOCKED,
|
|
RC_CLIENT_ACHIEVEMENT_BUCKET_ALMOST_THERE,
|
|
RC_CLIENT_ACHIEVEMENT_BUCKET_LOCKED,
|
|
RC_CLIENT_ACHIEVEMENT_BUCKET_UNOFFICIAL,
|
|
RC_CLIENT_ACHIEVEMENT_BUCKET_UNSUPPORTED,
|
|
};
|
|
|
|
achievements->clear();
|
|
for (u32 bucket_type : bucket_order)
|
|
{
|
|
for (u32 b = 0; b < list->num_buckets; b++)
|
|
{
|
|
const rc_client_achievement_bucket_t& bucket = list->buckets[b];
|
|
if (bucket.bucket_type != bucket_type)
|
|
continue;
|
|
for (u32 a = 0; a < bucket.num_achievements; a++)
|
|
{
|
|
const rc_client_achievement_t* ach = bucket.achievements[a];
|
|
if (!ach)
|
|
continue;
|
|
|
|
AchievementInfo info;
|
|
info.id = ach->id;
|
|
info.title = ach->title ? ach->title : "";
|
|
info.description = ach->description ? ach->description : "";
|
|
info.badge_path = GetAchievementBadgePath(ach, ach->state);
|
|
if (ach->measured_progress[0])
|
|
info.measured_progress = ach->measured_progress;
|
|
info.points = ach->points;
|
|
info.unlock_time = static_cast<u32>(ach->unlock_time);
|
|
info.state = ach->state;
|
|
info.category = ach->category;
|
|
info.bucket = bucket.bucket_type;
|
|
info.unlocked = ach->unlocked;
|
|
info.measured_percent = ach->measured_percent;
|
|
info.rarity = ach->rarity;
|
|
info.rarity_hardcore = ach->rarity_hardcore;
|
|
achievements->push_back(std::move(info));
|
|
}
|
|
}
|
|
}
|
|
|
|
rc_client_destroy_achievement_list(list);
|
|
return true;
|
|
}
|
|
|
|
#endif // ENABLE_RAINTEGRATION
|