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
<img width="1183" height="717" alt="obraz"
src="https://github.com/user-attachments/assets/f0b3add1-506d-43e5-8dae-cd34e041e2bd"
/>
After booting the game with RPCN enabled
<img width="1184" height="713" alt="obraz"
src="https://github.com/user-attachments/assets/d5992ddc-7fad-4b38-b4f9-006a832ffed5"
/>

RPCN Side PR
https://github.com/RipleyTom/rpcn/pull/140
This commit is contained in:
FlexBy420
2026-08-18 06:54:51 +02:00
committed by GitHub
parent 82164a54c1
commit cb175278b6
10 changed files with 324 additions and 5 deletions
+52 -1
View File
@@ -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<TROPUSRLoader> tropusr;
bool read_only = false;
@@ -506,6 +508,7 @@ error_code sceNpTrophyCreateContext(vm::ptr<u32> context, vm::cptr<SceNpCommunic
// set trophy context parameters (could be passed to constructor through make_ptr call)
ctxt->trp_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<std::pair<s32, s64>> local_unlocked;
local_unlocked.reserve(trophy_count);
for (u32 i = 0; i < trophy_count; i++)
{
if (tropusr->GetTrophyUnlockState(static_cast<s32>(i)))
{
local_unlocked.emplace_back(
static_cast<s32>(i),
static_cast<s64>(tropusr->GetTrophyTimestamp(static_cast<s32>(i))));
}
}
// Release lock before the blocking network call
lock2.unlock();
auto& np = g_fxo->get<np::np_handler>();
std::vector<std::pair<s32, s64>> 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<s32>(trophy_count) && ts >= 0 && !tropusr->GetTrophyUnlockState(tid))
{
static_cast<void>(tropusr->UnlockTrophy(tid, static_cast<u64>(ts), static_cast<u64>(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::np_handler>();
np.rpcn_trophy_unlock(ctxt->comm_id, trophyId, static_cast<s64>(tick->tick));
if (unlocked_platinum_id != SCE_NP_TROPHY_INVALID_TROPHY_ID)
np.rpcn_trophy_unlock(ctxt->comm_id, static_cast<s32>(unlocked_platinum_id), static_cast<s64>(tick->tick));
}
return CELL_OK;
}
+26
View File
@@ -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<std::pair<s32, s64>> np_handler::rpcn_trophy_sync(
const SceNpCommunicationId& communication_id,
const std::vector<std::pair<s32, s64>>& 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 <typename T>
error_code np_handler::get_friend_presence_by_index(u32 index, SceNpUserInfo* user, T* pres)
{
+5
View File
@@ -249,6 +249,11 @@ namespace np
std::pair<error_code, std::optional<SceNpId>> get_friend_by_index(u32 index);
void set_presence(std::optional<std::string> status, std::optional<std::vector<u8>> data);
// RPCN trophy support
void rpcn_trophy_unlock(const SceNpCommunicationId& communication_id, s32 trophy_id, s64 timestamp);
std::vector<std::pair<s32, s64>> rpcn_trophy_sync(const SceNpCommunicationId& communication_id,
const std::vector<std::pair<s32, s64>>& local_unlocked);
template <typename T>
error_code get_friend_presence_by_index(u32 index, SceNpUserInfo* user, T* pres);
+63 -2
View File
@@ -161,6 +161,8 @@ void fmt_class_string<rpcn::CommandType>::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<u8> data(COMMUNICATION_ID_SIZE + sizeof(s32) + sizeof(s64));
rpcn_client::write_communication_id(communication_id, data);
reinterpret_cast<le_t<s32>&>(data[COMMUNICATION_ID_SIZE]) = trophy_id;
reinterpret_cast<le_t<s64>&>(data[COMMUNICATION_ID_SIZE + sizeof(s32)]) = timestamp;
return forge_send(CommandType::UnlockTrophy, rpcn_request_counter.fetch_add(1), data);
}
std::vector<std::pair<s32, s64>> rpcn_client::sync_trophies(
const SceNpCommunicationId& communication_id,
const std::vector<std::pair<s32, s64>>& local_unlocked)
{
const u32 count = static_cast<u32>(local_unlocked.size());
std::vector<u8> data(COMMUNICATION_ID_SIZE + sizeof(u32) + count * (sizeof(s32) + sizeof(s64))), reply_data;
rpcn_client::write_communication_id(communication_id, data);
reinterpret_cast<le_t<u32>&>(data[COMMUNICATION_ID_SIZE]) = count;
usz offset = COMMUNICATION_ID_SIZE + sizeof(u32);
for (const auto& [tid, ts] : local_unlocked)
{
reinterpret_cast<le_t<s32>&>(data[offset]) = tid;
offset += sizeof(s32);
reinterpret_cast<le_t<s64>&>(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<ErrorType>(reply.get<u8>());
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<u32>();
std::vector<std::pair<s32, s64>> result;
result.reserve(server_count);
for (u32 i = 0; i < server_count; i++)
{
const s32 tid = reply.get<s32>();
const s64 ts = reply.get<s64>();
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;
+2
View File
@@ -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<SceNpTusSlotId> 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<u8>& pr_data);
bool unlock_trophy(const SceNpCommunicationId& communication_id, s32 trophy_id, s64 timestamp);
std::vector<std::pair<s32, s64>> sync_trophies(const SceNpCommunicationId& communication_id, const std::vector<std::pair<s32, s64>>& 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);
+2
View File
@@ -69,6 +69,8 @@ namespace rpcn
QuickMatchGUI,
SearchJoinRoomGUI,
GetRoomMemberDataExternalList,
UnlockTrophy,
SyncTrophies,
};
enum class NotificationType : u16
@@ -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<image_button>();
@@ -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<image_button>();
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<u8>* sync_status = &m_sync_status;
atomic_t<bool>* 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<u8>(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<std::pair<s32, s64>> local_unlocked;
local_unlocked.reserve(trophy_count);
for (u32 i = 0; i < trophy_count; i++)
{
if (m_trophy_data->trop_usr->GetTrophyUnlockState(static_cast<s32>(i)))
{
local_unlocked.emplace_back(
static_cast<s32>(i),
static_cast<s64>(m_trophy_data->trop_usr->GetTrophyTimestamp(static_cast<s32>(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<std::pair<s32, s64>> srv_trophies = rpcn->sync_trophies(comm_id, local_unlocked);
if (!srv_trophies.empty())
{
auto tropusr = std::make_unique<TROPUSRLoader>();
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<s32>(count) && ts >= 0 && !tropusr->GetTrophyUnlockState(tid))
{
(void)tropusr->UnlockTrophy(tid, static_cast<u64>(ts), static_cast<u64>(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_data> 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<TROPUSRLoader>();
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;
@@ -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<trophy_data> load_trophies(const std::string& trop_name) const;
void reload();
void sync_trophies_async();
std::unique_ptr<overlay_element> m_dim_background;
std::unique_ptr<list_view> m_list;
std::unique_ptr<label> m_description;
std::unique_ptr<image_button> m_sort_button;
std::unique_ptr<image_button> m_show_hidden_trophies_button;
std::unique_ptr<image_button> m_sync_trophies_button;
animation_color_interpolate fade_animation;
std::unique_ptr<trophy_data> m_trophy_data;
std::string m_trop_name;
atomic_t<bool> m_list_dirty { true };
bool m_show_hidden_trophies = false;
bool m_show_hidden_trophies_last = false;
trophy_sort_mode m_sort_mode = trophy_sort_mode::game_default;
trophy_sort_mode m_sort_mode_last = trophy_sort_mode::game_default;
// Sync state: 0 = idle, 1 = syncing, 2 = success, 3 = error
atomic_t<u8> m_sync_status { 0 };
public:
trophy_list_dialog();
@@ -58,6 +65,8 @@ namespace rsx
compiled_resource get_compiled() override;
void show(const std::string& trop_name);
static bool rpcn_configured();
};
}
}
+4
View File
@@ -307,6 +307,10 @@ enum class localized_string_id
HOME_MENU_TROPHY_GRADE_SILVER,
HOME_MENU_TROPHY_GRADE_GOLD,
HOME_MENU_TROPHY_GRADE_PLATINUM,
HOME_MENU_TROPHY_SYNC_TROPHIES,
HOME_MENU_TROPHY_SYNCING_TROPHIES,
HOME_MENU_TROPHY_SYNC_SUCCESS,
HOME_MENU_TROPHY_SYNC_FAILED,
HOME_MENU_TROPHY_SORT_GAME_DEFAULT,
HOME_MENU_TROPHY_SORT_NOT_EARNED,
HOME_MENU_TROPHY_SORT_EARNED_DATE,
+4
View File
@@ -327,6 +327,10 @@ private:
case localized_string_id::HOME_MENU_TROPHY_GRADE_SILVER: return tr("Silver", "Trophy type");
case localized_string_id::HOME_MENU_TROPHY_GRADE_GOLD: return tr("Gold", "Trophy type");
case localized_string_id::HOME_MENU_TROPHY_GRADE_PLATINUM: return tr("Platinum", "Trophy type");
case localized_string_id::HOME_MENU_TROPHY_SYNC_TROPHIES: return tr("Sync trophies");
case localized_string_id::HOME_MENU_TROPHY_SYNCING_TROPHIES: return tr("Syncing...");
case localized_string_id::HOME_MENU_TROPHY_SYNC_SUCCESS: return tr("Synced!");
case localized_string_id::HOME_MENU_TROPHY_SYNC_FAILED: return tr("Sync failed");
case localized_string_id::HOME_MENU_TROPHY_SORT_GAME_DEFAULT: return tr("Sort: Game Default");
case localized_string_id::HOME_MENU_TROPHY_SORT_NOT_EARNED: return tr("Sort: Not Earned");
case localized_string_id::HOME_MENU_TROPHY_SORT_EARNED_DATE: return tr("Sort: Earned Date");