Files
AnyPS5/core/libs/tests/GuestNpTrophyStats.cpp
T
milescovielloandClaude Opus 5.5 ca34fdb351 feat(libSceNpTrophy2,libSceNpUniversalDataSystem): unlock progressive trophies through _UpdateTrophyProgress
Titles report a progressive trophy's progress with _UpdateTrophyProgress
events. Their udsNN.ucp rules feed $._trophy_progress into an int32
statistic with the max aggregation, filtered on $._trophy_id, and the
trophy's unlockCondition names that statistic. Those statistics and
rules were skipped and the events were dropped, so such trophies never
reported progress or unlocked (21 of Borderlands 4's 64).

- Read max statistics (largest value posted, from initialValue) and
  int32 statistics (0 to 2147483647).
- Read a rule condition with one == property equal to an unsigned
  integer, and apply the rule only to events whose property matches.
- Post _UpdateTrophyProgress through the statistic rules.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:24:43 +01:00

390 lines
21 KiB
C++

#include "prx/libSceNpUniversalDataSystem/include/Api.hpp"
#include "prx/libSceNpTrophy2/include/Api.hpp"
#include "prx/libc/include/general/VabiMacros.hpp"
#include "prx/libSceNpTrophy2/include/NpTrophy2Types.hpp"
#include "prx/libSceNpTrophy2/include/TrophyStore.hpp"
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <random>
#include <string>
#include <utility>
#include <vector>
#ifdef _WIN32
#include <io.h>
#else
#include <unistd.h>
#endif
struct NpUniversalDataSystemEvent;
struct NpUniversalDataSystemEventPropertyObject;
static constexpr int InitialUser = 0x10000000;
static const char TropConf[] = R"({"defaultLanguage":"en-US","trophies":[
{"id":"0","grade":"P"},
{"id":"1","grade":"G","platinumTrophyId":"0","unlockCondition":{"udsStatId":"10","comparator":"ge","targetValue":"10","progressive":false}},
{"id":"2","grade":"S","platinumTrophyId":"0","unlockCondition":{"udsStatId":"20","comparator":"gt","targetValue":"100"}},
{"id":"3","grade":"B","platinumTrophyId":"0","unlockCondition":{"udsStatId":"30","comparator":"le","targetValue":"5"}},
{"id":"4","grade":"B","platinumTrophyId":"0","unlockCondition":{"udsStatId":"30","comparator":"lt","targetValue":"3"}},
{"id":"5","grade":"B","platinumTrophyId":"0","unlockCondition":{"udsStatId":"40","comparator":"ge","targetValue":"5","progressive":true}},
{"id":"6","grade":"B","groupId":"1","hidden":true,"hasReward":true},
{"id":"7","grade":"B","groupId":"1"}
]})";
static const char TropMeta[] = R"({"metadata":{"titleMetadata":{"name":"Synthetic Game"},
"trophyMetadata":[{"id":"0","name":"All of them"},{"id":"1","name":"Ten","detail":"Reach ten"},{"id":"5","name":"Five steps"},
{"id":"6","name":"Secret","detail":"A hidden one","reward":"A hat"}],
"groupMetadata":[{"id":"1","name":"Expansion"}]}})";
static const char StatsDefinition[] = R"({"statDefinitionArray":[
{"statId":10,"dataType":"uint64","aggregation":"latest"},{"statId":20,"dataType":"uint64","aggregation":"latest"},
{"statId":30,"dataType":"uint32","aggregation":"latest"},{"statId":40,"dataType":"uint32","aggregation":"latest"}]})";
static const char ProgressConf[] = R"({"defaultLanguage":"en-US","trophies":[
{"id":"0","grade":"P"},
{"id":"1","grade":"B","platinumTrophyId":"0","unlockCondition":{"udsStatId":"112","comparator":"ge","targetValue":"10","progressive":true}},
{"id":"2","grade":"B","platinumTrophyId":"0","unlockCondition":{"udsStatId":"113","comparator":"ge","targetValue":"3","progressive":true}}
]})";
static const char ProgressDefinition[] = R"({"statDefinitionArray":[
{"statId":112,"dataType":"int32","aggregation":"max","minValue":"0","initialValue":"0"},
{"statId":113,"dataType":"int32","aggregation":"max","minValue":"0","initialValue":"0"}]})";
static const char ProgressExtraction[] = R"({"statsExtractionRuleArray":[
{"condition":{"eventName":"_UpdateTrophyProgress","property":{"path":"$._trophy_id","comparator":"==","value":"1"}},"action":{"input":"$._trophy_progress","output":{"statId":112}}},
{"condition":{"eventName":"_UpdateTrophyProgress","property":{"path":"$._trophy_id","comparator":"==","value":"2"}},"action":{"input":"$._trophy_progress","output":{"statId":113}}}]})";
static const char SumDefinition[] = R"({"statDefinitionArray":[{"statId":10,"dataType":"uint64","aggregation":"sum"}]})";
static const char StatsExtraction[] = R"({"statsExtractionRuleArray":[
{"condition":{"eventName":"COLLECT"},"action":{"input":"$.counter","output":{"statId":10}}},
{"condition":{"eventName":"COLLECT"},"action":{"input":"$.total","output":{"statId":20}}},
{"condition":{"eventName":"TIME"},"action":{"input":"$.seconds","output":{"statId":30}}},
{"condition":{"eventName":"STEPS"},"action":{"input":"$.player.steps","output":{"statId":40}}}]})";
static void Require(bool value, const char* message) {
if (value) return;
std::fprintf(stderr, "%s\n", message);
std::abort();
}
static void PutBigEndian(std::string& data, std::size_t offset, std::uint64_t value, std::size_t size) {
for (std::size_t i = 0; i < size; ++i) data[offset + i] = static_cast<char>((value >> (8 * (size - 1 - i))) & 0xFF);
}
static std::string Ucp(const std::vector<std::pair<std::string, std::string>>& files) {
std::size_t offset = 0x60 + files.size() * 0x40;
std::size_t size = offset;
for (const auto& file : files) size += file.second.size();
std::string data(size, '\0');
PutBigEndian(data, 0, 0xB228C60A, 4);
PutBigEndian(data, 4, 1, 4);
PutBigEndian(data, 8, size, 8);
PutBigEndian(data, 0x10, files.size(), 4);
PutBigEndian(data, 0x14, 0x40, 4);
std::size_t entry = 0x60;
for (const auto& [name, contents] : files) {
data.replace(entry, name.size(), name);
PutBigEndian(data, entry + 0x20, offset, 8);
PutBigEndian(data, entry + 0x28, contents.size(), 8);
data.replace(offset, contents.size(), contents);
offset += contents.size();
entry += 0x40;
}
return data;
}
static void Write(const std::filesystem::path& path, const std::string& text) {
std::filesystem::create_directories(path.parent_path());
std::FILE* file = std::fopen(path.string().c_str(), "wb");
Require(file != nullptr, "a test file was not created");
const bool written = std::fwrite(text.data(), 1, text.size(), file) == text.size();
Require(std::fclose(file) == 0 && written, "a test file was not written");
}
static std::string Read(const char* path) {
std::string text;
std::FILE* file = std::fopen(path, "rb");
if (file == nullptr) return text;
char buffer[256];
for (std::size_t count; (count = std::fread(buffer, 1, sizeof(buffer), file)) != 0;) text.append(buffer, count);
std::fclose(file);
return text;
}
static bool Exists(const char* path) {
std::FILE* file = std::fopen(path, "rb");
if (file == nullptr) return false;
std::fclose(file);
return true;
}
class StderrCapture {
public:
StderrCapture() {
std::fflush(stderr);
file = std::tmpfile();
Require(file != nullptr, "cannot open a temporary file for stderr");
#ifdef _WIN32
saved = _dup(_fileno(stderr));
Require(saved >= 0 && _dup2(_fileno(file), _fileno(stderr)) == 0, "cannot capture stderr");
#else
saved = dup(fileno(stderr));
Require(saved >= 0 && dup2(fileno(file), fileno(stderr)) == fileno(stderr), "cannot capture stderr");
#endif
}
std::string Finish() {
std::fflush(stderr);
#ifdef _WIN32
_dup2(saved, _fileno(stderr));
_close(saved);
#else
dup2(saved, fileno(stderr));
close(saved);
#endif
std::rewind(file);
std::string text;
char buffer[4096];
for (std::size_t count; (count = std::fread(buffer, 1, sizeof(buffer), file)) != 0;) text.append(buffer, count);
std::fclose(file);
std::fwrite(text.data(), 1, text.size(), stderr);
return text;
}
private:
std::FILE* file = nullptr;
int saved = -1;
};
struct UdsSession {
int context = 0;
int handle = 0;
};
static UdsSession OpenUds(std::uint32_t serviceLabel) {
UdsSession uds;
Require(sceNpUniversalDataSystemCreateContext(&uds.context, InitialUser, serviceLabel, 0) == 0, "UDS context was not created");
Require(sceNpUniversalDataSystemCreateHandle(&uds.handle) == 0, "UDS handle was not created");
Require(sceNpUniversalDataSystemRegisterContext(uds.context, uds.handle, 0) == 0, "UDS context was not registered");
return uds;
}
template <typename TSet>
static void Post(const UdsSession& uds, const char* name, TSet set) {
NpUniversalDataSystemEventPropertyObject* properties = nullptr;
Require(sceNpUniversalDataSystemCreateEventPropertyObject(&properties) == 0, "property object was not created");
set(properties);
NpUniversalDataSystemEvent* event = nullptr;
Require(sceNpUniversalDataSystemCreateEvent(name, properties, &event, nullptr) == 0, "event was not created");
Require(sceNpUniversalDataSystemPostEvent(uds.context, uds.handle, event, 0) == 0, "event was not posted");
Require(sceNpUniversalDataSystemDestroyEvent(event) == 0, "event was not destroyed");
Require(sceNpUniversalDataSystemDestroyEventPropertyObject(properties) == 0, "property object was not destroyed");
}
static int TrophyContext(std::uint32_t serviceLabel) {
int context = 0;
Require(sceNpTrophy2CreateContext(&context, InitialUser, serviceLabel, 0) == 0, "trophy context was not created");
return context;
}
static NpTrophy2Data Trophy(int context, int trophyId, NpTrophy2Details* details = nullptr) {
NpTrophy2Data data{};
Require(sceNpTrophy2GetTrophyInfo(context, 1, trophyId, details, &data) == 0, "trophy info was not returned");
Require(data.trophy_id == trophyId, "trophy info is for another trophy");
return data;
}
static NpTrophy2GameData Game(int context, NpTrophy2GameDetails* details = nullptr) {
NpTrophy2GameData data{};
Require(sceNpTrophy2GetGameInfo(context, 1, details, &data) == 0, "game info was not returned");
return data;
}
struct Unlocks {
std::vector<std::pair<std::uint32_t, std::int32_t>> trophies;
};
static void Remember(void* context, const NpTrophy2Unlock& unlock) {
Require(unlock.titleId == "PPSA00000" && unlock.userId == InitialUser && unlock.tick != 0, "an unlock does not carry its title, user and time");
static_cast<Unlocks*>(context)->trophies.emplace_back(unlock.serviceLabel, unlock.trophyId);
}
static void KeepStatsAcrossRuns(int context) {
NpTrophy2Details details{};
const auto steps = Trophy(context, 5, &details);
Require(details.target.type == 1 && details.target.value == 5 && !steps.unlocked && steps.progress.type == 1 && steps.progress.value == 2,
"the progressive trophy does not report the stat kept from the last run");
}
static void UnlockThroughStats(int context, Unlocks& unlocks) {
const auto uds = OpenUds(0);
Post(uds, "COLLECT", [](NpUniversalDataSystemEventPropertyObject* properties) {
Require(sceNpUniversalDataSystemEventPropertyObjectSetUInt64(properties, "counter", 9) == 0, "counter was not set");
Require(sceNpUniversalDataSystemEventPropertyObjectSetUInt64(properties, "total", 100) == 0, "total was not set");
});
Require(!Trophy(context, 1).unlocked && !Trophy(context, 2).unlocked && unlocks.trophies.empty(), "ge or gt unlocked below its target");
Post(uds, "COLLECT", [](NpUniversalDataSystemEventPropertyObject* properties) {
Require(sceNpUniversalDataSystemEventPropertyObjectSetUInt32(properties, "counter", 10) == 0, "counter was not set");
});
Require(Trophy(context, 1).unlocked && !Trophy(context, 2).unlocked, "ge did not unlock at its target");
Post(uds, "COLLECT", [](NpUniversalDataSystemEventPropertyObject* properties) {
Require(sceNpUniversalDataSystemEventPropertyObjectSetInt32(properties, "counter", 3) == 0, "counter was not set");
Require(sceNpUniversalDataSystemEventPropertyObjectSetInt64(properties, "total", 101) == 0, "total was not set");
});
Require(Trophy(context, 2).unlocked, "gt did not unlock past its target");
Post(uds, "TIME", [](NpUniversalDataSystemEventPropertyObject* properties) {
Require(sceNpUniversalDataSystemEventPropertyObjectSetUInt32(properties, "seconds", 6) == 0, "seconds was not set");
});
Require(!Trophy(context, 3).unlocked && !Trophy(context, 4).unlocked, "le or lt unlocked above its target");
Post(uds, "TIME", [](NpUniversalDataSystemEventPropertyObject* properties) {
Require(sceNpUniversalDataSystemEventPropertyObjectSetUInt32(properties, "seconds", 5) == 0, "seconds was not set");
});
Require(Trophy(context, 3).unlocked && !Trophy(context, 4).unlocked, "le did not unlock at its target");
Post(uds, "TIME", [](NpUniversalDataSystemEventPropertyObject* properties) {
Require(sceNpUniversalDataSystemEventPropertyObjectSetInt32(properties, "seconds", -1) == 0, "seconds was not set");
});
Post(uds, "TIME", [](NpUniversalDataSystemEventPropertyObject* properties) {
Require(sceNpUniversalDataSystemEventPropertyObjectSetFloat32(properties, "seconds", 1.0f) == 0, "seconds was not set");
});
Require(!Trophy(context, 4).unlocked, "a negative or fractional value reached lt");
Post(uds, "TIME", [](NpUniversalDataSystemEventPropertyObject* properties) {
Require(sceNpUniversalDataSystemEventPropertyObjectSetUInt32(properties, "seconds", 2) == 0, "seconds was not set");
});
Require(Trophy(context, 4).unlocked, "lt did not unlock below its target");
Post(uds, "NOTHING", [](NpUniversalDataSystemEventPropertyObject* properties) {
Require(sceNpUniversalDataSystemEventPropertyObjectSetUInt32(properties, "steps", 50) == 0, "steps was not set");
});
const auto stepped = [&uds](std::uint32_t steps) {
Post(uds, "STEPS", [steps](NpUniversalDataSystemEventPropertyObject* properties) {
NpUniversalDataSystemEventPropertyObject* player = nullptr;
Require(sceNpUniversalDataSystemEventPropertyObjectSetObject(properties, "player", nullptr, &player) == 0, "player was not set");
Require(sceNpUniversalDataSystemEventPropertyObjectSetUInt32(player, "steps", steps) == 0, "steps was not set");
});
};
stepped(4);
const auto partway = Trophy(context, 5);
Require(!partway.unlocked && partway.progress.type == 1 && partway.progress.value == 4 && !Trophy(context, 0).unlocked, "the progressive trophy does not report its progress");
stepped(5);
const auto done = Trophy(context, 5);
Require(done.unlocked && done.progress.value == 5, "the progressive trophy did not unlock at its target");
Require(Trophy(context, 0).unlocked, "the platinum did not unlock with the last trophy that names it");
const std::vector<std::pair<std::uint32_t, std::int32_t>> expected{{0, 1}, {0, 2}, {0, 3}, {0, 4}, {0, 5}, {0, 0}};
Require(unlocks.trophies == expected, "the listener did not see each stat unlock and the platinum once, in order");
const auto game = Game(context);
Require(game.unlocked_trophies == 6 && game.unlocked_platinum == 1 && game.unlocked_gold == 1 && game.unlocked_silver == 1 && game.unlocked_bronze == 3 &&
game.progress_percentage == (6 + 2 + 3) * 100 / (6 + 2 + 5),
"the game does not count the unlocks by grade");
Require(Read("_trophy/268435456_0.stats.txt") == "10 3\n20 101\n30 2\n40 5\n", "the stats were not stored next to the unlocks");
const auto record = Read("_trophy/268435456_0.txt");
for (const char* line : {"0 ", "1 ", "2 ", "3 ", "4 ", "5 "}) Require(record.find(std::string("\n") + line) != std::string::npos || record.starts_with(line), "an unlock was not stored");
}
static void IgnoreMissingAndMalformedFiles(Unlocks& unlocks) {
const auto missing = TrophyContext(1);
NpTrophy2GameDetails game{};
Game(missing, &game);
Require(std::strcmp(game.title, "Game") == 0, "a title without trophy files does not report placeholders");
Post(OpenUds(1), "COLLECT", [](NpUniversalDataSystemEventPropertyObject* properties) {
Require(sceNpUniversalDataSystemEventPropertyObjectSetUInt32(properties, "counter", 10) == 0, "counter was not set");
});
Require(!Exists("_trophy/268435456_1.stats.txt"), "a title without stat rules stored a stat");
const auto brokenTrophies = TrophyContext(2);
Game(brokenTrophies, &game);
Require(std::strcmp(game.title, "Game") == 0, "a malformed trophy package was used");
Post(OpenUds(2), "COLLECT", [](NpUniversalDataSystemEventPropertyObject* properties) {
Require(sceNpUniversalDataSystemEventPropertyObjectSetUInt32(properties, "counter", 10) == 0, "counter was not set");
});
Require(Read("_trophy/268435456_2.stats.txt") == "10 10\n" && !Exists("_trophy/268435456_2.txt"), "a malformed trophy package disabled the stat rules, or stats unlocked a trophy without one");
const auto brokenRules = TrophyContext(3);
Game(brokenRules, &game);
Require(std::strcmp(game.title, "Synthetic Game") == 0, "malformed stat rules disabled the trophy package");
Post(OpenUds(3), "COLLECT", [](NpUniversalDataSystemEventPropertyObject* properties) {
Require(sceNpUniversalDataSystemEventPropertyObjectSetUInt32(properties, "counter", 10) == 0, "counter was not set");
});
Require(!Exists("_trophy/268435456_3.stats.txt") && !Trophy(brokenRules, 1).unlocked, "malformed stat rules were used");
Require(unlocks.trophies.size() == 6, "a missing or malformed file unlocked a trophy");
}
static void IgnoreOtherAggregations(Unlocks& unlocks) {
StderrCapture capture;
const auto context = TrophyContext(5);
Post(OpenUds(5), "COLLECT", [](NpUniversalDataSystemEventPropertyObject* properties) {
Require(sceNpUniversalDataSystemEventPropertyObjectSetUInt32(properties, "counter", 10) == 0, "counter was not set");
});
const auto log = capture.Finish();
Require(log.find("[trophy] /app0/sce_sys/uds/uds05.ucp: stat 10 has aggregation \"sum\"; only \"latest\" and \"max\" are supported") != std::string::npos,
"a stat with a sum aggregation was not reported");
Require(!Trophy(context, 1).unlocked && !Exists("_trophy/268435456_5.stats.txt") && unlocks.trophies.size() == 6, "a stat with a sum aggregation was stored or unlocked a trophy");
}
static void RecordTrophyProgress(Unlocks& unlocks) {
const auto context = TrophyContext(6);
const auto uds = OpenUds(6);
const auto progress = [&uds](std::int32_t trophy, std::int32_t value) {
Post(uds, "_UpdateTrophyProgress", [trophy, value](NpUniversalDataSystemEventPropertyObject* properties) {
Require(sceNpUniversalDataSystemEventPropertyObjectSetInt32(properties, "_trophy_id", trophy) == 0, "_trophy_id was not set");
Require(sceNpUniversalDataSystemEventPropertyObjectSetInt32(properties, "_trophy_progress", value) == 0, "_trophy_progress was not set");
});
};
progress(1, 4);
const auto partway = Trophy(context, 1);
Require(!partway.unlocked && partway.progress.type == 1 && partway.progress.value == 4 && Trophy(context, 2).progress.value == 0, "trophy progress was not recorded for its own trophy");
progress(1, 2);
Require(Trophy(context, 1).progress.value == 4, "lower trophy progress replaced higher progress");
progress(2, 3);
Require(Trophy(context, 2).unlocked && !Trophy(context, 1).unlocked && !Trophy(context, 0).unlocked, "trophy progress at its target did not unlock only that trophy");
progress(1, 12);
const auto done = Trophy(context, 1);
Require(done.unlocked && done.progress.value == 10 && Trophy(context, 0).unlocked, "trophy progress past its target did not unlock the trophy and the platinum");
Require(Read("_trophy/268435456_6.stats.txt") == "112 12\n113 3\n", "trophy progress was not stored");
const std::vector<std::pair<std::uint32_t, std::int32_t>> expected{{6, 2}, {6, 1}, {6, 0}};
Require(unlocks.trophies.size() == 9 && std::vector<std::pair<std::uint32_t, std::int32_t>>(unlocks.trophies.end() - 3, unlocks.trophies.end()) == expected,
"the listener did not see the progress unlocks and the platinum once, in order");
}
int main() {
const auto previous = std::filesystem::current_path();
const auto root = std::filesystem::temp_directory_path() / ("anyps5-trophy-stats-" + std::to_string(std::random_device{}()));
std::filesystem::create_directories(root);
std::filesystem::current_path(root);
Write("app0/sce_sys/param.json", R"({"titleId":"PPSA00000","localizedParameters":{"en-US":{"titleName":"Example"}}})");
const auto trophies = Ucp({{"tropconf.json", TropConf}, {"tropmeta_en-US.json", TropMeta}});
const auto rules = Ucp({{"stats_definition.json", StatsDefinition}, {"stats_extraction.json", StatsExtraction}});
Write("app0/sce_sys/trophy2/trophy00.ucp", trophies);
Write("app0/sce_sys/uds/uds00.ucp", rules);
Write("app0/sce_sys/trophy2/trophy02.ucp", "not a package");
Write("app0/sce_sys/uds/uds02.ucp", rules);
Write("app0/sce_sys/trophy2/trophy03.ucp", trophies);
Write("app0/sce_sys/uds/uds03.ucp", rules.substr(0, rules.size() - 1));
Write("app0/sce_sys/trophy2/trophy05.ucp", trophies);
Write("app0/sce_sys/uds/uds05.ucp", Ucp({{"stats_definition.json", SumDefinition}, {"stats_extraction.json", StatsExtraction}}));
Write("app0/sce_sys/trophy2/trophy06.ucp", Ucp({{"tropconf.json", ProgressConf}, {"tropmeta_en-US.json", TropMeta}}));
Write("app0/sce_sys/uds/uds06.ucp", Ucp({{"stats_definition.json", ProgressDefinition}, {"stats_extraction.json", ProgressExtraction}}));
Write("_trophy/268435456_0.stats.txt", "40 2\n");
Unlocks unlocks;
const auto listener = NpTrophy2AddUnlockListener_nid_no_patch(Remember, &unlocks);
const auto context = TrophyContext(0);
KeepStatsAcrossRuns(context);
UnlockThroughStats(context, unlocks);
IgnoreMissingAndMalformedFiles(unlocks);
IgnoreOtherAggregations(unlocks);
RecordTrophyProgress(unlocks);
NpTrophy2RemoveUnlockListener_nid_no_patch(listener);
std::filesystem::current_path(previous);
std::filesystem::remove_all(root);
std::puts("Trophy stat tests passed");
return 0;
}