From cb175278b61c0845247be8afbc448374df84cda3 Mon Sep 17 00:00:00 2001
From: FlexBy420 <68403300+FlexBy420@users.noreply.github.com>
Date: Tue, 18 Aug 2026 06:54:51 +0200
Subject: [PATCH] RPCN: Sync trophies (#18760)
Add synching local trophies with RPCN server trophies, allows users to
essentially cloud save their trophies when they are connected to RPCN.
This PR also aims to add support for global earned % trophies simillar
to steam for RPCN part of website.
No local trophies and not connected to RPCN
After booting the game with RPCN enabled
RPCN Side PR
https://github.com/RipleyTom/rpcn/pull/140
---
rpcs3/Emu/Cell/Modules/sceNpTrophy.cpp | 53 +++++-
rpcs3/Emu/NP/np_handler.cpp | 26 +++
rpcs3/Emu/NP/np_handler.h | 5 +
rpcs3/Emu/NP/rpcn_client.cpp | 65 ++++++-
rpcs3/Emu/NP/rpcn_client.h | 2 +
rpcs3/Emu/NP/rpcn_types.h | 2 +
.../Trophies/overlay_trophy_list_dialog.cpp | 159 +++++++++++++++++-
.../Trophies/overlay_trophy_list_dialog.h | 9 +
rpcs3/Emu/localized_string_id.h | 4 +
rpcs3/rpcs3qt/localized_emu.h | 4 +
10 files changed, 324 insertions(+), 5 deletions(-)
diff --git a/rpcs3/Emu/Cell/Modules/sceNpTrophy.cpp b/rpcs3/Emu/Cell/Modules/sceNpTrophy.cpp
index dc6add3eb..5f659a4d5 100644
--- a/rpcs3/Emu/Cell/Modules/sceNpTrophy.cpp
+++ b/rpcs3/Emu/Cell/Modules/sceNpTrophy.cpp
@@ -14,6 +14,7 @@
#include "sceNp.h"
#include "sceNpTrophy.h"
#include "cellSysutil.h"
+#include "Emu/NP/np_handler.h"
#include "Utilities/StrUtil.h"
@@ -39,6 +40,7 @@ struct trophy_context_t
SAVESTATE_INIT_POS(42);
std::string trp_name;
+ SceNpCommunicationId comm_id{}; // set at CreateContext, not serialized
std::unique_ptr tropusr;
bool read_only = false;
@@ -506,6 +508,7 @@ error_code sceNpTrophyCreateContext(vm::ptr context, vm::cptrtrp_name = name;
+ ctxt->comm_id = *commId; // stored for RPCN trophy sync/unlock
ctxt->read_only = !!(options & SCE_NP_TROPHY_OPTIONS_CREATE_CONTEXT_READ_ONLY);
*context = idm::last_id();
@@ -714,7 +717,46 @@ error_code sceNpTrophyRegisterContext(ppu_thread& ppu, u32 context, u32 handle,
ensure(tropusr->Load(trophyUsrPath, trophyConfPath).success);
- lock2.unlock();
+ if (g_cfg.net.psn_status == np_psn_status::psn_rpcn)
+ {
+ const SceNpCommunicationId ctx_comm_id = ctxt->comm_id;
+ const u32 trophy_count = tropusr->GetTrophiesCount();
+
+ std::vector> local_unlocked;
+ local_unlocked.reserve(trophy_count);
+ for (u32 i = 0; i < trophy_count; i++)
+ {
+ if (tropusr->GetTrophyUnlockState(static_cast(i)))
+ {
+ local_unlocked.emplace_back(
+ static_cast(i),
+ static_cast(tropusr->GetTrophyTimestamp(static_cast(i))));
+ }
+ }
+
+ // Release lock before the blocking network call
+ lock2.unlock();
+
+ auto& np = g_fxo->get();
+ std::vector> srv_trophies = np.rpcn_trophy_sync(ctx_comm_id, local_unlocked);
+
+ bool changed = false;
+ for (const auto& [tid, ts] : srv_trophies)
+ {
+ if (tid >= 0 && tid < static_cast(trophy_count) && ts >= 0 && !tropusr->GetTrophyUnlockState(tid))
+ {
+ static_cast(tropusr->UnlockTrophy(tid, static_cast(ts), static_cast(ts)));
+ changed = true;
+ }
+ }
+
+ if (changed && !tropusr->Save(trophyUsrPath))
+ sceNpTrophy.error("sceNpTrophyRegisterContext(): Failed to save trophy data after RPCN sync");
+ }
+ else
+ {
+ lock2.unlock();
+ }
lv2_obj::sleep(ppu);
{
@@ -1110,6 +1152,15 @@ error_code sceNpTrophyUnlockTrophy(ppu_thread& ppu, u32 context, u32 handle, s32
}
}
+ if (g_cfg.net.psn_status == np_psn_status::psn_rpcn)
+ {
+ auto& np = g_fxo->get();
+ np.rpcn_trophy_unlock(ctxt->comm_id, trophyId, static_cast(tick->tick));
+
+ if (unlocked_platinum_id != SCE_NP_TROPHY_INVALID_TROPHY_ID)
+ np.rpcn_trophy_unlock(ctxt->comm_id, static_cast(unlocked_platinum_id), static_cast(tick->tick));
+ }
+
return CELL_OK;
}
diff --git a/rpcs3/Emu/NP/np_handler.cpp b/rpcs3/Emu/NP/np_handler.cpp
index 34300ae36..c7346061a 100644
--- a/rpcs3/Emu/NP/np_handler.cpp
+++ b/rpcs3/Emu/NP/np_handler.cpp
@@ -1638,6 +1638,32 @@ namespace np
}
}
+ void np_handler::rpcn_trophy_unlock(const SceNpCommunicationId& communication_id, s32 trophy_id, s64 timestamp)
+ {
+ if (!is_psn_active || g_cfg.net.psn_status != np_psn_status::psn_rpcn)
+ return;
+
+ std::lock_guard lock(mutex_rpcn);
+ if (!rpcn || !rpcn->is_authentified())
+ return;
+
+ rpcn->unlock_trophy(communication_id, trophy_id, timestamp);
+ }
+
+ std::vector> np_handler::rpcn_trophy_sync(
+ const SceNpCommunicationId& communication_id,
+ const std::vector>& local_unlocked)
+ {
+ if (!is_psn_active || g_cfg.net.psn_status != np_psn_status::psn_rpcn)
+ return {};
+
+ std::lock_guard lock(mutex_rpcn);
+ if (!rpcn || !rpcn->is_authentified())
+ return {};
+
+ return rpcn->sync_trophies(communication_id, local_unlocked);
+ }
+
template
error_code np_handler::get_friend_presence_by_index(u32 index, SceNpUserInfo* user, T* pres)
{
diff --git a/rpcs3/Emu/NP/np_handler.h b/rpcs3/Emu/NP/np_handler.h
index af30eb3c5..86eaca3bb 100644
--- a/rpcs3/Emu/NP/np_handler.h
+++ b/rpcs3/Emu/NP/np_handler.h
@@ -249,6 +249,11 @@ namespace np
std::pair> get_friend_by_index(u32 index);
void set_presence(std::optional status, std::optional> data);
+ // RPCN trophy support
+ void rpcn_trophy_unlock(const SceNpCommunicationId& communication_id, s32 trophy_id, s64 timestamp);
+ std::vector> rpcn_trophy_sync(const SceNpCommunicationId& communication_id,
+ const std::vector>& local_unlocked);
+
template
error_code get_friend_presence_by_index(u32 index, SceNpUserInfo* user, T* pres);
diff --git a/rpcs3/Emu/NP/rpcn_client.cpp b/rpcs3/Emu/NP/rpcn_client.cpp
index 410713209..7de63afc0 100644
--- a/rpcs3/Emu/NP/rpcn_client.cpp
+++ b/rpcs3/Emu/NP/rpcn_client.cpp
@@ -161,6 +161,8 @@ void fmt_class_string::format(std::string& out, u64 arg)
case rpcn::CommandType::GetRoomInfoGUI: return "GetRoomInfoGUI";
case rpcn::CommandType::QuickMatchGUI: return "QuickMatchGUI";
case rpcn::CommandType::SearchJoinRoomGUI: return "SearchJoinRoomGUI";
+ case rpcn::CommandType::UnlockTrophy: return "UnlockTrophy";
+ case rpcn::CommandType::SyncTrophies: return "SyncTrophies";
}
return unknown;
@@ -257,7 +259,7 @@ namespace rpcn
rpcn_log.notice("online: %s, pr_com_id: %s, pr_title: %s, pr_status: %s, pr_comment: %s, pr_data: %s", online ? "true" : "false", pr_com_id.data, pr_title, pr_status, pr_comment, fmt::buf_to_hexstring(pr_data.data(), pr_data.size()));
}
- constexpr u32 RPCN_PROTOCOL_VERSION = 30;
+ constexpr u32 RPCN_PROTOCOL_VERSION = 31;
constexpr usz RPCN_HEADER_SIZE = 15;
const char* error_to_explanation(rpcn::ErrorType error)
@@ -663,7 +665,8 @@ namespace rpcn
command == CommandType::AddBlock || command == CommandType::RemoveBlock ||
command == CommandType::SendMessage || command == CommandType::SendToken ||
command == CommandType::SendResetToken || command == CommandType::ResetPassword ||
- command == CommandType::GetNetworkTime || command == CommandType::SetPresence || command == CommandType::Terminate)
+ command == CommandType::GetNetworkTime || command == CommandType::SetPresence || command == CommandType::Terminate ||
+ command == CommandType::SyncTrophies)
{
std::lock_guard lock(mutex_replies_sync);
replies_sync.insert(std::make_pair(packet_id, std::make_pair(command, std::move(data))));
@@ -2605,6 +2608,64 @@ namespace rpcn
return forge_request_with_com_id(serialized, pr_com_id, CommandType::SetPresence, rpcn_request_counter.fetch_add(1));
}
+ bool rpcn_client::unlock_trophy(const SceNpCommunicationId& communication_id, s32 trophy_id, s64 timestamp)
+ {
+ std::vector data(COMMUNICATION_ID_SIZE + sizeof(s32) + sizeof(s64));
+ rpcn_client::write_communication_id(communication_id, data);
+ reinterpret_cast&>(data[COMMUNICATION_ID_SIZE]) = trophy_id;
+ reinterpret_cast&>(data[COMMUNICATION_ID_SIZE + sizeof(s32)]) = timestamp;
+ return forge_send(CommandType::UnlockTrophy, rpcn_request_counter.fetch_add(1), data);
+ }
+
+ std::vector> rpcn_client::sync_trophies(
+ const SceNpCommunicationId& communication_id,
+ const std::vector>& local_unlocked)
+ {
+ const u32 count = static_cast(local_unlocked.size());
+
+ std::vector data(COMMUNICATION_ID_SIZE + sizeof(u32) + count * (sizeof(s32) + sizeof(s64))), reply_data;
+
+ rpcn_client::write_communication_id(communication_id, data);
+ reinterpret_cast&>(data[COMMUNICATION_ID_SIZE]) = count;
+
+ usz offset = COMMUNICATION_ID_SIZE + sizeof(u32);
+ for (const auto& [tid, ts] : local_unlocked)
+ {
+ reinterpret_cast&>(data[offset]) = tid;
+ offset += sizeof(s32);
+ reinterpret_cast&>(data[offset]) = ts;
+ offset += sizeof(s64);
+ }
+
+ if (!forge_send_reply(CommandType::SyncTrophies, rpcn_request_counter.fetch_add(1), data, reply_data))
+ return {};
+
+ vec_stream reply(reply_data);
+ const auto error = static_cast(reply.get());
+ if (error != rpcn::ErrorType::NoError)
+ {
+ rpcn_log.error("sync_trophies: server returned error %s", fmt::format("%s", error));
+ return {};
+ }
+
+ const u32 server_count = reply.get();
+ std::vector> result;
+ result.reserve(server_count);
+ for (u32 i = 0; i < server_count; i++)
+ {
+ const s32 tid = reply.get();
+ const s64 ts = reply.get();
+ result.emplace_back(tid, ts);
+ }
+
+ if (reply.is_error())
+ {
+ error_and_disconnect("Malformed reply to SyncTrophies command");
+ return {};
+ }
+ return result;
+ }
+
bool rpcn_client::createjoin_room_gui(u32 req_id, const SceNpCommunicationId& communication_id, const SceNpMatchingAttr* attr_list)
{
np2_structs::CreateRoomGUIRequest pb_req;
diff --git a/rpcs3/Emu/NP/rpcn_client.h b/rpcs3/Emu/NP/rpcn_client.h
index f95fb282b..cb545ade9 100644
--- a/rpcs3/Emu/NP/rpcn_client.h
+++ b/rpcs3/Emu/NP/rpcn_client.h
@@ -370,6 +370,8 @@ namespace rpcn
bool tus_get_friends_data_status(u32 req_id, SceNpCommunicationId& communication_id, SceNpTusSlotId slotId, bool includeSelf, s32 sortType, s32 arrayNum);
bool tus_delete_multislot_data(u32 req_id, SceNpCommunicationId& communication_id, const SceNpOnlineId& targetNpId, vm::cptr slotIdArray, s32 arrayNum, bool vuser);
bool send_presence(const SceNpCommunicationId& pr_com_id, const std::string& pr_title, const std::string& pr_status, const std::string& pr_comment, const std::vector& pr_data);
+ bool unlock_trophy(const SceNpCommunicationId& communication_id, s32 trophy_id, s64 timestamp);
+ std::vector> sync_trophies(const SceNpCommunicationId& communication_id, const std::vector>& local_unlocked);
bool createjoin_room_gui(u32 req_id, const SceNpCommunicationId& communication_id, const SceNpMatchingAttr* attr_list);
bool join_room_gui(u32 req_id, const SceNpRoomId& room_id);
bool leave_room_gui(u32 req_id, const SceNpRoomId& room_id);
diff --git a/rpcs3/Emu/NP/rpcn_types.h b/rpcs3/Emu/NP/rpcn_types.h
index 28500462e..0a4a281e3 100644
--- a/rpcs3/Emu/NP/rpcn_types.h
+++ b/rpcs3/Emu/NP/rpcn_types.h
@@ -69,6 +69,8 @@ namespace rpcn
QuickMatchGUI,
SearchJoinRoomGUI,
GetRoomMemberDataExternalList,
+ UnlockTrophy,
+ SyncTrophies,
};
enum class NotificationType : u16
diff --git a/rpcs3/Emu/RSX/Overlays/Trophies/overlay_trophy_list_dialog.cpp b/rpcs3/Emu/RSX/Overlays/Trophies/overlay_trophy_list_dialog.cpp
index 9fc90fcbe..32d8ae172 100644
--- a/rpcs3/Emu/RSX/Overlays/Trophies/overlay_trophy_list_dialog.cpp
+++ b/rpcs3/Emu/RSX/Overlays/Trophies/overlay_trophy_list_dialog.cpp
@@ -2,6 +2,7 @@
#include "../overlay_manager.h"
#include "overlay_trophy_list_dialog.h"
#include "Emu/Cell/Modules/sceNpTrophy.h"
+#include "Emu/NP/rpcn_config.h"
#include "Emu/System.h"
#include "Emu/VFS.h"
@@ -107,7 +108,7 @@ namespace rsx
m_sort_button->set_text(localized_string_id::HOME_MENU_TROPHY_SORT_GAME_DEFAULT);
m_sort_button->set_image_resource(resource_config::standard_image_resource::triangle);
m_sort_button->set_size(120, 30);
- m_sort_button->set_pos(560, trophy_list_y + trophy_list_h + 20);
+ m_sort_button->set_pos(460, trophy_list_y + trophy_list_h + 20);
m_sort_button->set_font("Arial", 16);
m_show_hidden_trophies_button = std::make_unique();
@@ -117,6 +118,13 @@ namespace rsx
m_show_hidden_trophies_button->set_pos(180, trophy_list_y + trophy_list_h + 20);
m_show_hidden_trophies_button->set_font("Arial", 16);
+ m_sync_trophies_button = std::make_unique();
+ m_sync_trophies_button->set_text(localized_string_id::HOME_MENU_TROPHY_SYNC_TROPHIES);
+ m_sync_trophies_button->set_image_resource(resource_config::standard_image_resource::select);
+ m_sync_trophies_button->set_size(120, 30);
+ m_sync_trophies_button->set_pos(700, trophy_list_y + trophy_list_h + 20);
+ m_sync_trophies_button->set_font("Arial", 16);
+
fade_animation.duration_sec = 0.15f;
return_code = selection_code::canceled;
@@ -159,6 +167,13 @@ namespace rsx
m_list_dirty = true;
break;
}
+ case pad_button::select:
+ // Only start a new sync if not already in progress
+ if (m_sync_status.load() != 1)
+ {
+ sync_trophies_async();
+ }
+ break;
case pad_button::dpad_up:
case pad_button::ls_up:
m_list->select_previous();
@@ -209,6 +224,15 @@ namespace rsx
m_show_hidden_trophies_last = m_show_hidden_trophies;
}
+ // Update sync button label based on current sync state
+ switch (m_sync_status.load())
+ {
+ case 1: m_sync_trophies_button->set_text(localized_string_id::HOME_MENU_TROPHY_SYNCING_TROPHIES); break;
+ case 2: m_sync_trophies_button->set_text(localized_string_id::HOME_MENU_TROPHY_SYNC_SUCCESS); break;
+ case 3: m_sync_trophies_button->set_text(localized_string_id::HOME_MENU_TROPHY_SYNC_FAILED); break;
+ default: m_sync_trophies_button->set_text(localized_string_id::HOME_MENU_TROPHY_SYNC_TROPHIES); break;
+ }
+
if (m_sort_mode_last != m_sort_mode)
{
localized_string_id sort_label_id;
@@ -236,6 +260,7 @@ namespace rsx
result.add(m_description->get_compiled());
result.add(m_sort_button->get_compiled());
result.add(m_show_hidden_trophies_button->get_compiled());
+ result.add(m_sync_trophies_button->get_compiled());
fade_animation.apply(result);
@@ -245,7 +270,8 @@ namespace rsx
void trophy_list_dialog::show(const std::string& trop_name)
{
visible = false;
-
+
+ m_trop_name = trop_name;
m_trophy_data = load_trophies(trop_name);
ensure(m_trophy_data && m_trophy_data->trop_usr);
@@ -269,6 +295,126 @@ namespace rsx
}
}
+ bool trophy_list_dialog::rpcn_configured()
+ {
+ cfg_rpcn cfg;
+ cfg.load();
+ return !cfg.get_npid().empty() && !cfg.get_password().empty();
+ }
+
+ void trophy_list_dialog::sync_trophies_async()
+ {
+ if (!rpcn_configured())
+ {
+ rsx_log.warning("Trophy sync requested but RPCN is not configured.");
+ m_sync_status = 3; // error
+ return;
+ }
+
+ if (!m_trophy_data || !m_trophy_data->trop_usr)
+ {
+ rsx_log.error("Trophy sync requested but trophy data is not loaded.");
+ m_sync_status = 3;
+ return;
+ }
+
+ m_sync_status = 1; // syncing
+
+ const std::string trop_name = m_trop_name;
+ atomic_t* sync_status = &m_sync_status;
+ atomic_t* list_dirty = &m_list_dirty;
+
+ SceNpCommunicationId comm_id{};
+ {
+ if (trop_name.size() >= COMMUNICATION_ID_SIZE)
+ {
+ const auto& n = trop_name;
+ std::memcpy(comm_id.data, n.c_str(), COMMUNICATION_ID_COMID_COMPONENT_SIZE);
+ comm_id.data[COMMUNICATION_ID_COMID_COMPONENT_SIZE] = '\0';
+ comm_id.num = static_cast(std::atoi(n.c_str() + COMMUNICATION_ID_COMID_COMPONENT_SIZE + 1));
+ }
+ else
+ {
+ rsx_log.error("Trophy sync: unexpected trop_name format: %s", trop_name);
+ m_sync_status = 3;
+ return;
+ }
+ }
+
+ const u32 trophy_count = m_trophy_data->trop_usr->GetTrophiesCount();
+ std::vector> local_unlocked;
+ local_unlocked.reserve(trophy_count);
+ for (u32 i = 0; i < trophy_count; i++)
+ {
+ if (m_trophy_data->trop_usr->GetTrophyUnlockState(static_cast(i)))
+ {
+ local_unlocked.emplace_back(
+ static_cast(i),
+ static_cast(m_trophy_data->trop_usr->GetTrophyTimestamp(static_cast(i))));
+ }
+ }
+
+ const std::string tropusr_vfs_path = "/dev_hdd0/home/" + Emu.GetUsr() + "/trophy/" + trop_name + "/TROPUSR.DAT";
+ const std::string tropconf_vfs_path = "/dev_hdd0/home/" + Emu.GetUsr() + "/trophy/" + trop_name + "/TROPCONF.SFM";
+
+ std::thread([=, local_unlocked = std::move(local_unlocked)]() mutable
+ {
+ g_cfg_rpcn.load();
+
+ auto rpcn = rpcn::rpcn_client::get_instance(0);
+
+ if (auto res = rpcn->wait_for_connection(); res != rpcn::rpcn_state::failure_no_failure)
+ {
+ rsx_log.error("Trophy sync: failed to connect to RPCN: %s", rpcn::rpcn_state_to_string(res));
+ *sync_status = 3;
+ return;
+ }
+
+ if (auto res = rpcn->wait_for_authentified(); res != rpcn::rpcn_state::failure_no_failure)
+ {
+ rsx_log.error("Trophy sync: failed to authenticate with RPCN: %s", rpcn::rpcn_state_to_string(res));
+ *sync_status = 3;
+ return;
+ }
+
+ std::vector> srv_trophies = rpcn->sync_trophies(comm_id, local_unlocked);
+
+ if (!srv_trophies.empty())
+ {
+ auto tropusr = std::make_unique();
+ if (tropusr->Load(tropusr_vfs_path, tropconf_vfs_path).success)
+ {
+ const u32 count = tropusr->GetTrophiesCount();
+ bool changed = false;
+ for (const auto& [tid, ts] : srv_trophies)
+ {
+ if (tid >= 0 && tid < static_cast(count) && ts >= 0 && !tropusr->GetTrophyUnlockState(tid))
+ {
+ (void)tropusr->UnlockTrophy(tid, static_cast(ts), static_cast(ts));
+ changed = true;
+ }
+ }
+
+ if (changed)
+ {
+ if (!tropusr->Save(tropusr_vfs_path))
+ rsx_log.error("Trophy sync: failed to save TROPUSR after sync for %s", trop_name);
+
+ *list_dirty = true;
+ }
+ }
+ else
+ {
+ rsx_log.error("Trophy sync: failed to reload TROPUSR for %s", trop_name);
+ *sync_status = 3;
+ return;
+ }
+ }
+
+ *sync_status = 2; // success
+ }).detach();
+ }
+
std::unique_ptr trophy_list_dialog::load_trophies(const std::string& trop_name) const
{
// Populate GameTrophiesData
@@ -344,6 +490,15 @@ namespace rsx
{
ensure(m_trophy_data);
+ if (!m_trop_name.empty())
+ {
+ const std::string tropusr_path = "/dev_hdd0/home/" + Emu.GetUsr() + "/trophy/" + m_trop_name + "/TROPUSR.DAT";
+ const std::string tropconf_path = "/dev_hdd0/home/" + Emu.GetUsr() + "/trophy/" + m_trop_name + "/TROPCONF.SFM";
+ auto fresh_usr = std::make_unique();
+ if (fresh_usr->Load(tropusr_path, tropconf_path).success)
+ m_trophy_data->trop_usr = std::move(fresh_usr);
+ }
+
rsx_log.trace("Reloading Trophy List Overlay with %s %s", m_trophy_data->game_name, m_trophy_data->path);
s32 selected_index = m_list ? m_list->get_selected_index() : 0;
diff --git a/rpcs3/Emu/RSX/Overlays/Trophies/overlay_trophy_list_dialog.h b/rpcs3/Emu/RSX/Overlays/Trophies/overlay_trophy_list_dialog.h
index c6ec34750..a5b3b61d7 100644
--- a/rpcs3/Emu/RSX/Overlays/Trophies/overlay_trophy_list_dialog.h
+++ b/rpcs3/Emu/RSX/Overlays/Trophies/overlay_trophy_list_dialog.h
@@ -4,6 +4,7 @@
#include "../overlay_list_view.hpp"
#include "Loader/TROPUSR.h"
+#include "Emu/NP/rpcn_client.h"
class TROPUSRLoader;
@@ -33,22 +34,28 @@ namespace rsx
private:
std::unique_ptr load_trophies(const std::string& trop_name) const;
void reload();
+ void sync_trophies_async();
std::unique_ptr m_dim_background;
std::unique_ptr m_list;
std::unique_ptr