Achievements: Add offline tracking of progress and hash library

This commit is contained in:
Stenzek 2025-01-25 18:26:05 +10:00
parent d4b0b7fc29
commit 6512ed8a8c
No known key found for this signature in database
2 changed files with 753 additions and 11 deletions

View File

@ -17,12 +17,14 @@
#include "scmversion/scmversion.h"
#include "common/assert.h"
#include "common/binary_reader_writer.h"
#include "common/error.h"
#include "common/file_system.h"
#include "common/heap_array.h"
#include "common/log.h"
#include "common/md5_digest.h"
#include "common/path.h"
#include "common/ryml_helpers.h"
#include "common/scoped_guard.h"
#include "common/sha256_digest.h"
#include "common/small_string.h"
@ -44,6 +46,7 @@
#include "imgui_internal.h"
#include "rc_api_runtime.h"
#include "rc_client.h"
#include "rc_consoles.h"
#include <algorithm>
#include <atomic>
@ -142,12 +145,12 @@ static bool TryLoggingInWithToken();
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 FinishLogin(const rc_client_t* client);
static void ShowLoginNotification();
static void IdentifyGame(const std::string& path, CDImage* image);
static void BeginLoadGame();
static void BeginChangeDisc();
static void UpdateGameSummary();
static void UpdateGameSummary(bool update_progress_database, bool force_update_progress_database);
static std::string GetLocalImagePath(const std::string_view image_name, int type);
static void DownloadImage(std::string url, std::string cache_path);
static void UpdateGlyphRanges();
@ -203,6 +206,28 @@ static void DrawLeaderboardEntry(const rc_client_leaderboard_entry_t& entry, boo
float name_column_width, float time_column_width, float column_spacing);
#endif
static std::string GetHashDatabasePath();
static std::string GetProgressDatabasePath();
static void PreloadHashDatabase();
static bool LoadHashDatabase(const std::string& path, Error* error);
static bool CreateHashDatabaseFromSeedDatabase(const std::string& path, Error* error);
static void BeginRefreshHashDatabase();
static void FinishRefreshHashDatabase();
static void CancelHashDatabaseRequests();
static void FetchHashLibraryCallback(int result, const char* error_message, rc_client_hash_library_t* list,
rc_client_t* client, void* callback_userdata);
static void FetchAllProgressCallback(int result, const char* error_message, rc_client_all_progress_list_t* list,
rc_client_t* client, void* callback_userdata);
static void BuildHashDatabase(const rc_client_hash_library_t* hashlib, const rc_client_all_progress_list_t* allprog);
static bool SortAndSaveHashDatabase(Error* error);
static FileSystem::ManagedCFilePtr OpenProgressDatabase(bool for_write, bool truncate, Error* error);
static void BuildProgressDatabase(const rc_client_all_progress_list_t* allprog);
static void UpdateProgressDatabase(bool force);
static void ClearProgressDatabase();
struct State
{
rc_client_t* client = nullptr;
@ -248,6 +273,14 @@ struct State
std::vector<std::pair<const rc_client_leaderboard_entry_t*, std::string>> leaderboard_user_icon_paths;
rc_client_leaderboard_entry_list_t* leaderboard_nearby_entries;
bool is_showing_all_leaderboard_entries = false;
bool hashdb_loaded = false;
std::vector<HashDatabaseEntry> hashdb_entries;
rc_client_async_handle_t* fetch_hash_library_request = nullptr;
rc_client_hash_library_t* fetch_hash_library_result = nullptr;
rc_client_async_handle_t* fetch_all_progress_request = nullptr;
rc_client_all_progress_list_t* fetch_all_progress_result = nullptr;
};
ALIGN_TO_CACHE_LINE static State s_state;
@ -758,6 +791,7 @@ bool Achievements::Shutdown(bool allow_cancel)
ClearGameHash();
DisableHardcoreMode();
UpdateGlyphRanges();
CancelHashDatabaseRequests();
if (s_state.load_game_request)
{
@ -972,9 +1006,12 @@ void Achievements::ClientEventHandler(const rc_client_event_t* event, rc_client_
}
}
void Achievements::UpdateGameSummary()
void Achievements::UpdateGameSummary(bool update_progress_database, bool force_update_progress_database)
{
rc_client_get_user_game_summary(s_state.client, &s_state.game_summary);
if (update_progress_database)
UpdateProgressDatabase(force_update_progress_database);
}
void Achievements::UpdateRichPresence(std::unique_lock<std::recursive_mutex>& lock)
@ -1239,7 +1276,9 @@ void Achievements::ClientLoadGameCallback(int result, const char* error_message,
!FileSystem::FileExists(s_state.game_icon.c_str()))
DownloadImage(s_state.game_icon_url, s_state.game_icon);
UpdateGameSummary();
// update progress database on first load, in case it was played on another PC
UpdateGameSummary(true, true);
if (display_summary)
DisplayAchievementSummary();
@ -1343,7 +1382,7 @@ void Achievements::HandleResetEvent(const rc_client_event_t* event)
rc_client_reset(s_state.client);
if (HasActiveGame())
UpdateGameSummary();
UpdateGameSummary(false, false);
}
void Achievements::HandleUnlockEvent(const rc_client_event_t* event)
@ -1352,7 +1391,7 @@ void Achievements::HandleUnlockEvent(const rc_client_event_t* event)
DebugAssert(cheevo);
INFO_LOG("Achievement {} ({}) for game {} unlocked", cheevo->title, cheevo->id, s_state.game_id);
UpdateGameSummary();
UpdateGameSummary(true, false);
if (g_settings.achievements_notifications)
{
@ -1382,7 +1421,7 @@ void Achievements::HandleUnlockEvent(const rc_client_event_t* event)
void Achievements::HandleGameCompleteEvent(const rc_client_event_t* event)
{
INFO_LOG("Game {} complete", s_state.game_id);
UpdateGameSummary();
UpdateGameSummary(false, false);
if (g_settings.achievements_notifications)
{
@ -1752,7 +1791,7 @@ void Achievements::SetHardcoreMode(bool enabled, bool force_display_message)
DebugAssert((rc_client_get_hardcore_enabled(s_state.client) != 0) == enabled);
if (HasActiveGame())
{
UpdateGameSummary();
UpdateGameSummary(true, true);
DisplayAchievementSummary();
}
@ -2025,7 +2064,7 @@ void Achievements::ClientLoginWithPasswordCallback(int result, const char* error
Host::SetBaseStringSettingValue("Cheevos", "LoginTimestamp", fmt::format("{}", std::time(nullptr)).c_str());
Host::CommitBaseSettingChanges();
ShowLoginSuccess(client);
FinishLogin(client);
}
void Achievements::ClientLoginWithTokenCallback(int result, const char* error_message, rc_client_t* client,
@ -2062,18 +2101,20 @@ void Achievements::ClientLoginWithTokenCallback(int result, const char* error_me
return;
}
ShowLoginSuccess(client);
FinishLogin(client);
if (System::IsValid())
BeginLoadGame();
}
void Achievements::ShowLoginSuccess(const rc_client_t* client)
void Achievements::FinishLogin(const rc_client_t* client)
{
const rc_client_user_t* user = rc_client_get_user_info(client);
if (!user)
return;
PreloadHashDatabase();
Host::OnAchievementsLoginSuccess(user->username, user->score, user->score_softcore, user->num_unread_messages);
if (System::IsValid())
@ -2162,10 +2203,14 @@ void Achievements::Logout()
UpdateGlyphRanges();
}
CancelHashDatabaseRequests();
INFO_LOG("Logging out...");
rc_client_logout(s_state.client);
}
ClearProgressDatabase();
INFO_LOG("Clearing credentials...");
Host::DeleteBaseSettingValue("Cheevos", "Username");
Host::DeleteBaseSettingValue("Cheevos", "Token");
@ -3688,6 +3733,672 @@ TinyString Achievements::DecryptLoginToken(std::string_view encrypted_token, std
return ret;
}
std::string Achievements::GetHashDatabasePath()
{
return Path::Combine(EmuFolders::Cache, "achievement_gamedb.cache");
}
std::string Achievements::GetProgressDatabasePath()
{
return Path::Combine(EmuFolders::Cache, "achievement_progress.cache");
}
void Achievements::BeginRefreshHashDatabase()
{
INFO_LOG("Starting hash database refresh...");
// kick off both requests
CancelHashDatabaseRequests();
s_state.fetch_hash_library_request =
rc_client_begin_fetch_hash_library(s_state.client, RC_CONSOLE_PLAYSTATION, FetchHashLibraryCallback, nullptr);
s_state.fetch_all_progress_request =
rc_client_begin_fetch_all_progress_list(s_state.client, RC_CONSOLE_PLAYSTATION, FetchAllProgressCallback, nullptr);
if (!s_state.fetch_hash_library_request || !s_state.fetch_hash_library_request)
{
ERROR_LOG("Failed to create hash database refresh requests.");
CancelHashDatabaseRequests();
}
}
void Achievements::FetchHashLibraryCallback(int result, const char* error_message, rc_client_hash_library_t* list,
rc_client_t* client, void* callback_userdata)
{
s_state.fetch_hash_library_request = nullptr;
if (result != RC_OK)
{
ERROR_LOG("Fetch hash library failed: {}: {}", rc_error_str(result), error_message);
CancelHashDatabaseRequests();
return;
}
s_state.fetch_hash_library_result = list;
FinishRefreshHashDatabase();
}
void Achievements::FetchAllProgressCallback(int result, const char* error_message, rc_client_all_progress_list_t* list,
rc_client_t* client, void* callback_userdata)
{
s_state.fetch_all_progress_request = nullptr;
if (result != RC_OK)
{
ERROR_LOG("Fetch all progress failed: {}: {}", rc_error_str(result), error_message);
CancelHashDatabaseRequests();
return;
}
s_state.fetch_all_progress_result = list;
FinishRefreshHashDatabase();
}
void Achievements::CancelHashDatabaseRequests()
{
if (s_state.fetch_all_progress_result)
{
rc_client_destroy_all_progress_list(s_state.fetch_all_progress_result);
s_state.fetch_all_progress_result = nullptr;
}
if (s_state.fetch_all_progress_request)
{
rc_client_abort_async(s_state.client, s_state.fetch_all_progress_request);
s_state.fetch_all_progress_request = nullptr;
}
if (s_state.fetch_hash_library_result)
{
rc_client_destroy_hash_library(s_state.fetch_hash_library_result);
s_state.fetch_hash_library_result = nullptr;
}
if (s_state.fetch_hash_library_request)
{
rc_client_abort_async(s_state.client, s_state.fetch_hash_library_request);
s_state.fetch_hash_library_request = nullptr;
}
}
void Achievements::FinishRefreshHashDatabase()
{
if (!s_state.fetch_hash_library_result || !s_state.fetch_all_progress_result)
{
// not done yet
return;
}
// build mapping of hashes to game ids and achievement counts
BuildHashDatabase(s_state.fetch_hash_library_result, s_state.fetch_all_progress_result);
// update the progress tracking while we're at it
BuildProgressDatabase(s_state.fetch_all_progress_result);
// tidy up
rc_client_destroy_all_progress_list(s_state.fetch_all_progress_result);
s_state.fetch_all_progress_result = nullptr;
rc_client_destroy_hash_library(s_state.fetch_hash_library_result);
s_state.fetch_hash_library_result = nullptr;
}
void Achievements::BuildHashDatabase(const rc_client_hash_library_t* hashlib,
const rc_client_all_progress_list_t* allprog)
{
std::vector<HashDatabaseEntry> dbentries;
dbentries.reserve(hashlib->num_entries);
for (const rc_client_hash_library_entry_t& entry :
std::span<const rc_client_hash_library_entry_t>(hashlib->entries, hashlib->num_entries))
{
HashDatabaseEntry dbentry;
dbentry.game_id = entry.game_id;
dbentry.num_achievements = 0;
if (StringUtil::DecodeHex(dbentry.hash, entry.hash) != GAME_HASH_LENGTH)
{
WARNING_LOG("Invalid hash '{}' in game ID {}", entry.hash, entry.game_id);
continue;
}
// Just in case...
if (std::any_of(dbentries.begin(), dbentries.end(),
[&dbentry](const HashDatabaseEntry& e) { return (e.hash == dbentry.hash); }))
{
WARNING_LOG("Duplicate hash {}", entry.hash);
continue;
}
dbentries.push_back(dbentry);
}
// fill in achievement counts
for (const rc_client_all_progress_list_entry_t& entry :
std::span<const rc_client_all_progress_list_entry_t>(allprog->entries, allprog->num_entries))
{
// can have multiple hashes with the same game id, update count on all of them
bool found_one = false;
for (HashDatabaseEntry& dbentry : dbentries)
{
if (dbentry.game_id == entry.game_id)
{
dbentry.num_achievements = entry.num_achievements;
found_one = true;
}
}
if (!found_one)
WARNING_LOG("All progress contained game ID {} without hash", entry.game_id);
}
s_state.hashdb_entries = std::move(dbentries);
s_state.hashdb_loaded = true;
Error error;
if (!SortAndSaveHashDatabase(&error))
ERROR_LOG("Failed to sort/save hash database from server: {}", error.GetDescription());
}
bool Achievements::CreateHashDatabaseFromSeedDatabase(const std::string& path, Error* error)
{
std::optional<std::string> yaml_data = Host::ReadResourceFileToString("achievement_hashlib.yaml", false, error);
const ryml::Tree yaml =
ryml::parse_in_place(to_csubstr(path), c4::substr(reinterpret_cast<char*>(yaml_data->data()), yaml_data->size()));
const ryml::ConstNodeRef root = yaml.rootref();
if (root.empty())
{
Error::SetStringView(error, "Seed database is empty.");
return false;
}
std::vector<HashDatabaseEntry> dbentries;
if (const ryml::ConstNodeRef hashes = root.find_child(to_csubstr("hashes")); hashes.valid())
{
dbentries.reserve(hashes.num_children());
for (const ryml::ConstNodeRef& current : hashes.cchildren())
{
const std::string_view hash = to_stringview(current.key());
const std::optional<u32> game_id = StringUtil::FromChars<u32>(to_stringview(current.val()));
if (!game_id.has_value())
{
WARNING_LOG("Invalid game ID {} in hash {}", to_stringview(current.val()), hash);
continue;
}
HashDatabaseEntry dbentry;
dbentry.game_id = game_id.value();
dbentry.num_achievements = 0;
if (StringUtil::DecodeHex(dbentry.hash, hash) != GAME_HASH_LENGTH)
{
WARNING_LOG("Invalid hash '{}' in game ID {}", hash, game_id.value());
continue;
}
dbentries.push_back(dbentry);
}
}
if (const ryml::ConstNodeRef achievements = root.find_child(to_csubstr("achievements")); achievements.valid())
{
for (const ryml::ConstNodeRef& current : achievements.cchildren())
{
const std::optional<u32> game_id = StringUtil::FromChars<u32>(to_stringview(current.key()));
const std::optional<u32> num_achievements = StringUtil::FromChars<u32>(to_stringview(current.val()));
if (!game_id.has_value() || !num_achievements.has_value())
{
WARNING_LOG("Invalid achievements entry in game ID {}", to_stringview(current.key()));
continue;
}
// can have multiple hashes with the same game id, update count on all of them
bool found_one = false;
for (HashDatabaseEntry& dbentry : dbentries)
{
if (dbentry.game_id == game_id.value())
{
dbentry.num_achievements = num_achievements.value();
found_one = true;
}
}
if (!found_one)
WARNING_LOG("Seed database contained game ID {} without hash", game_id.value());
}
}
if (dbentries.empty())
{
Error::SetStringView(error, "Parsed seed database was empty");
return false;
}
s_state.hashdb_entries = std::move(dbentries);
s_state.hashdb_loaded = true;
Error save_error;
if (!SortAndSaveHashDatabase(&save_error))
ERROR_LOG("Failed to sort/save hash database from server: {}", save_error.GetDescription());
return true;
}
bool Achievements::SortAndSaveHashDatabase(Error* error)
{
// sort hashes for quick lookup
s_state.hashdb_entries.shrink_to_fit();
std::sort(s_state.hashdb_entries.begin(), s_state.hashdb_entries.end(),
[](const HashDatabaseEntry& lhs, const HashDatabaseEntry& rhs) {
return std::memcmp(lhs.hash.data(), rhs.hash.data(), GAME_HASH_LENGTH) < 0;
});
FileSystem::AtomicRenamedFile fp = FileSystem::CreateAtomicRenamedFile(GetHashDatabasePath().c_str(), error);
if (!fp)
{
Error::AddPrefix(error, "Failed to open cache for writing: ");
return false;
}
BinaryFileWriter writer(fp.get());
writer.WriteU32(static_cast<u32>(s_state.hashdb_entries.size()));
for (const HashDatabaseEntry& entry : s_state.hashdb_entries)
{
writer.Write(entry.hash.data(), GAME_HASH_LENGTH);
writer.WriteU32(entry.game_id);
writer.WriteU32(entry.num_achievements);
}
if (!writer.Flush(error) || !FileSystem::CommitAtomicRenamedFile(fp, error))
{
Error::AddPrefix(error, "Failed to write cache: ");
return false;
}
INFO_LOG("Wrote {} games to hash database", s_state.hashdb_entries.size());
return true;
}
bool Achievements::LoadHashDatabase(const std::string& path, Error* error)
{
FileSystem::ManagedCFilePtr fp = FileSystem::OpenManagedCFile(path.c_str(), "rb", error);
if (!fp)
{
Error::AddPrefix(error, "Failed to open cache for reading: ");
return false;
}
BinaryFileReader reader(fp.get());
const u32 count = reader.ReadU32();
// simple sanity check on file size
constexpr size_t entry_size = (GAME_HASH_LENGTH + sizeof(u32) + sizeof(u32));
if (static_cast<s64>((count * entry_size) + sizeof(u32)) > FileSystem::FSize64(fp.get()))
{
Error::SetStringFmt(error, "Invalid entry count: {}", count);
return false;
}
s_state.hashdb_entries.resize(count);
for (HashDatabaseEntry& entry : s_state.hashdb_entries)
{
reader.Read(entry.hash.data(), entry.hash.size());
reader.ReadU32(&entry.game_id);
reader.ReadU32(&entry.num_achievements);
}
if (reader.HasError())
{
Error::SetStringView(error, "Error while reading cache");
s_state.hashdb_entries = {};
return false;
}
VERBOSE_LOG("Loaded {} entries from cached hash database", s_state.hashdb_entries.size());
return true;
}
const Achievements::HashDatabaseEntry* Achievements::LookupGameHash(const GameHash& hash)
{
if (!s_state.hashdb_loaded) [[unlikely]]
{
// loaded by another thread?
std::unique_lock lock(s_state.mutex);
if (!s_state.hashdb_loaded)
{
Error error;
std::string path = GetHashDatabasePath();
const bool hashdb_exists = FileSystem::FileExists(path.c_str());
if (!hashdb_exists || !LoadHashDatabase(path, &error))
{
if (hashdb_exists)
WARNING_LOG("Failed to load hash database: {}", error.GetDescription());
if (!CreateHashDatabaseFromSeedDatabase(path, &error))
ERROR_LOG("Failed to create hash database from seed database: {}", error.GetDescription());
}
}
s_state.hashdb_loaded = true;
}
const auto iter = std::lower_bound(s_state.hashdb_entries.begin(), s_state.hashdb_entries.end(), hash,
[](const HashDatabaseEntry& entry, const GameHash& search) {
return (std::memcmp(entry.hash.data(), search.data(), GAME_HASH_LENGTH) < 0);
});
return (iter != s_state.hashdb_entries.end() && std::memcmp(iter->hash.data(), hash.data(), GAME_HASH_LENGTH) == 0) ?
&(*iter) :
nullptr;
}
void Achievements::PreloadHashDatabase()
{
const std::string hash_database_path = GetHashDatabasePath();
const std::string progress_database_path = GetProgressDatabasePath();
bool has_hash_database = (s_state.hashdb_loaded && !s_state.hashdb_entries.empty());
const bool has_progress_database = FileSystem::FileExists(progress_database_path.c_str());
// if we don't have a progress database, just redownload everything, it's probably our first login
if (!has_hash_database && has_progress_database && FileSystem::FileExists(hash_database_path.c_str()))
{
// try loading binary cache
VERBOSE_LOG("Trying to load hash database from {}", hash_database_path);
Error error;
has_hash_database = LoadHashDatabase(hash_database_path, &error);
if (!has_hash_database)
ERROR_LOG("Failed to load hash database: {}", error.GetDescription());
}
// don't try to load the hash database from the game list now
s_state.hashdb_loaded = true;
// got everything?
if (has_hash_database && has_progress_database)
return;
// kick off a new download, game list will be notified when it's done
BeginRefreshHashDatabase();
}
FileSystem::ManagedCFilePtr Achievements::OpenProgressDatabase(bool for_write, bool truncate, Error* error)
{
const std::string path = GetProgressDatabasePath();
const FileSystem::FileShareMode share_mode =
for_write ? FileSystem::FileShareMode::DenyReadWrite : FileSystem::FileShareMode::DenyWrite;
#ifdef _WIN32
const char* mode = for_write ? (truncate ? "w+b" : "r+b") : "rb";
#else
// Always open read/write on Linux, since we need it for flock().
const char* mode = truncate ? "w+b" : "r+b";
#endif
FileSystem::ManagedCFilePtr fp = FileSystem::OpenManagedSharedCFile(path.c_str(), mode, share_mode, error);
if (fp)
return fp;
// Doesn't exist? Create it.
if (errno == ENOENT)
{
if (!for_write)
return nullptr;
mode = "w+b";
fp = FileSystem::OpenManagedSharedCFile(path.c_str(), mode, share_mode, error);
if (fp)
return fp;
}
// If there's a sharing violation, try again for 100ms.
if (errno != EACCES)
return nullptr;
Timer timer;
while (timer.GetTimeMilliseconds() <= 100.0f)
{
fp = FileSystem::OpenManagedSharedCFile(path.c_str(), mode, share_mode, error);
if (fp)
return fp;
if (errno != EACCES)
return nullptr;
}
Error::SetStringView(error, "Timed out while trying to open progress database.");
return nullptr;
}
void Achievements::BuildProgressDatabase(const rc_client_all_progress_list_t* allprog)
{
// no point storing it in memory, just write directly to the file
Error error;
FileSystem::ManagedCFilePtr fp = OpenProgressDatabase(true, true, &error);
if (!fp)
{
ERROR_LOG("Failed to build progress database: {}", error.GetDescription());
return;
}
#ifdef HAS_POSIX_FILE_LOCK
FileSystem::POSIXLock lock(fp.get());
#endif
// save a rewrite at the beginning
u32 games_with_unlocks = 0;
for (u32 i = 0; i < allprog->num_entries; i++)
{
games_with_unlocks += BoolToUInt32(
(allprog->entries[i].num_unlocked_achievements + allprog->entries[i].num_unlocked_achievements_hardcore) > 0);
}
BinaryFileWriter writer(fp.get());
writer.WriteU32(games_with_unlocks);
if (games_with_unlocks > 0)
{
for (const rc_client_all_progress_list_entry_t& entry :
std::span<const rc_client_all_progress_list_entry_t>(allprog->entries, allprog->num_entries))
{
if ((entry.num_unlocked_achievements + entry.num_unlocked_achievements_hardcore) == 0)
continue;
writer.WriteU32(entry.game_id);
writer.WriteU16(Truncate16(entry.num_unlocked_achievements));
writer.WriteU16(Truncate16(entry.num_unlocked_achievements_hardcore));
}
}
if (!writer.Flush(&error))
ERROR_LOG("Failed to write progress database: {}", error.GetDescription());
// TODO: Notify game list
}
void Achievements::UpdateProgressDatabase(bool force)
{
// don't write updates in spectator mode
if (rc_client_get_spectator_mode_enabled(s_state.client))
return;
// TODO: Update in game list
// done asynchronously so we don't hitch on disk I/O
System::QueueAsyncTask([game_id = s_state.game_id,
achievements_unlocked = s_state.game_summary.num_unlocked_achievements,
hardcore = IsHardcoreModeActive(), force]() {
// no point storing it in memory, just write directly to the file
Error error;
FileSystem::ManagedCFilePtr fp = OpenProgressDatabase(true, false, &error);
const s64 size = fp ? FileSystem::FSize64(fp.get(), &error) : -1;
if (!fp || size < 0)
{
ERROR_LOG("Failed to update progress database: {}", error.GetDescription());
return;
}
#ifdef HAS_POSIX_FILE_LOCK
FileSystem::POSIXLock lock(fp.get());
#endif
BinaryFileReader reader(fp.get());
const u32 game_count = (size > 0) ? reader.ReadU32() : 0;
// entry exists?
s64 found_offset = -1;
for (u32 i = 0; i < game_count; i++)
{
const u32 check_game_id = reader.ReadU32();
if (check_game_id == game_id)
{
// do we even need to change it?
const u16 current_achievements_unlocked = reader.ReadU16();
const u16 current_achievements_unlocked_hardcore = reader.ReadU16();
const u16 current_unlocked = hardcore ? current_achievements_unlocked_hardcore : current_achievements_unlocked;
// if we're not forced, then take the greater count
if (force ? (current_unlocked <= achievements_unlocked) : (current_unlocked == achievements_unlocked))
{
VERBOSE_LOG("No update to progress database needed for game {}", game_id);
return;
}
found_offset = FileSystem::FTell64(fp.get());
break;
}
if (!FileSystem::FSeek64(fp.get(), sizeof(u16) + sizeof(u16), SEEK_CUR, &error)) [[unlikely]]
{
ERROR_LOG("Failed to seek in progress database: {}", error.GetDescription());
return;
}
}
// make sure we had no read errors, don't want to make corrupted files
if (reader.HasError())
{
ERROR_LOG("Failed to read in progress database: {}", error.GetDescription());
return;
}
BinaryFileWriter writer(fp.get());
// append/update the entry
if (found_offset > 0)
{
INFO_LOG("Updating game {} with {} unlocked{}", game_id, achievements_unlocked, hardcore ? " (hardcore)" : "");
// need to seek when switching read->write
const s32 hardcore_offset = hardcore ? sizeof(u16) : 0;
if (!FileSystem::FSeek64(fp.get(), found_offset + hardcore_offset, SEEK_SET, &error))
{
ERROR_LOG("Failed to write seek in progress database: {}", error.GetDescription());
return;
}
writer.WriteU16(Truncate16(achievements_unlocked));
}
else
{
// don't write zeros to the file. we could still end up with zeros here after reset, but that's rare
if (achievements_unlocked == 0)
return;
INFO_LOG("Appending game {} with {} unlocked", game_id, achievements_unlocked, hardcore ? " (hardcore)" : "");
if (size == 0)
{
// if the file is empty, need to write the header
writer.WriteU32(1);
writer.WriteU32(game_id);
}
else
{
// update the count
if (!FileSystem::FSeek64(fp.get(), 0, SEEK_SET, &error) || !writer.WriteU32(game_count + 1) ||
!FileSystem::FSeek64(fp.get(), SEEK_END, 0, &error))
{
ERROR_LOG("Failed to write seek/update header in progress database: {}", error.GetDescription());
return;
}
}
writer.WriteU16(Truncate16(hardcore ? 0 : achievements_unlocked));
writer.WriteU16(Truncate16(hardcore ? achievements_unlocked : 0));
}
if (!writer.Flush(&error))
{
ERROR_LOG("Failed to write count in progress database: {}", error.GetDescription());
return;
}
});
}
void Achievements::ClearProgressDatabase()
{
std::string path = GetProgressDatabasePath();
if (FileSystem::FileExists(path.c_str()))
{
INFO_LOG("Deleting progress database {}", path);
Error error;
if (!FileSystem::DeleteFile(path.c_str(), &error))
ERROR_LOG("Failed to delete progress database: {}", error.GetDescription());
}
}
Achievements::ProgressDatabase::ProgressDatabase() = default;
Achievements::ProgressDatabase::~ProgressDatabase() = default;
bool Achievements::ProgressDatabase::Load(Error* error)
{
FileSystem::ManagedCFilePtr fp = OpenProgressDatabase(false, false, error);
if (!fp)
return false;
#ifdef HAS_POSIX_FILE_LOCK
FileSystem::POSIXLock lock(fp.get());
#endif
BinaryFileReader reader(fp.get());
const u32 count = reader.ReadU32();
// simple sanity check on file size
constexpr size_t entry_size = (sizeof(u32) + sizeof(u16) + sizeof(u16));
if (static_cast<s64>((count * entry_size) + sizeof(u32)) > FileSystem::FSize64(fp.get()))
{
Error::SetStringFmt(error, "Invalid entry count: {}", count);
return false;
}
m_entries.reserve(count);
for (u32 i = 0; i < count; i++)
{
const Entry entry = {.game_id = reader.ReadU32(),
.num_achievements_unlocked = reader.ReadU16(),
.num_hc_achievements_unlocked = reader.ReadU16()};
// Just in case...
if (std::any_of(m_entries.begin(), m_entries.end(),
[id = entry.game_id](const Entry& e) { return (e.game_id == id); }))
{
WARNING_LOG("Duplicate game ID {}", entry.game_id);
continue;
}
m_entries.push_back(entry);
}
// sort for quick lookup
m_entries.shrink_to_fit();
std::sort(m_entries.begin(), m_entries.end(),
[](const Entry& lhs, const Entry& rhs) { return (lhs.game_id < rhs.game_id); });
return true;
}
const Achievements::ProgressDatabase::Entry* Achievements::ProgressDatabase::LookupGame(u32 game_id) const
{
const auto iter = std::lower_bound(m_entries.begin(), m_entries.end(), game_id,
[](const Entry& entry, u32 search) { return (entry.game_id < search); });
return (iter != m_entries.end() && iter->game_id == game_id) ? &(*iter) : nullptr;
}
#ifdef ENABLE_RAINTEGRATION
#include "RA_Consoles.h"

View File

@ -30,6 +30,34 @@ enum class LoginRequestReason
static constexpr size_t GAME_HASH_LENGTH = 16;
using GameHash = std::array<u8, GAME_HASH_LENGTH>;
struct HashDatabaseEntry
{
GameHash hash;
u32 game_id;
u32 num_achievements;
};
class ProgressDatabase
{
public:
struct Entry
{
u32 game_id;
u16 num_achievements_unlocked;
u16 num_hc_achievements_unlocked;
};
ProgressDatabase();
~ProgressDatabase();
bool Load(Error* error);
const Entry* LookupGame(u32 game_id) const;
private:
std::vector<Entry> m_entries;
};
/// Acquires the achievements lock. Must be held when accessing any achievement state from another thread.
std::unique_lock<std::recursive_mutex> GetLock();
@ -38,6 +66,9 @@ std::optional<GameHash> GetGameHash(CDImage* image, u32* bytes_hashed = nullptr)
std::optional<GameHash> GetGameHash(const std::string_view executable_name, std::span<const u8> executable_data,
u32* bytes_hashed = nullptr);
/// Returns the number of achievements for a given hash.
const HashDatabaseEntry* LookupGameHash(const GameHash& hash);
/// Initializes the RetroAchievments client.
bool Initialize();