#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 #include #include #include #include #include #include #include #include #include #ifdef _WIN32 #include #else #include #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((value >> (8 * (size - 1 - i))) & 0xFF); } static std::string Ucp(const std::vector>& 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 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> 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(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> 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> expected{{6, 2}, {6, 1}, {6, 0}}; Require(unlocks.trophies.size() == 9 && std::vector>(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; }