Files
AnyPS5/core/libs/tests/GuestNpTrophyStats.cpp
Yunus Shaikh e9611f13ae test(libSceNpUniversalDataSystem): check that a stat with a sum aggregation unlocks nothing
A title whose uds file defines a stat with an aggregation other than
latest gets one log line naming the stat. Posting an event for it
through the exports stores no stat and unlocks no trophy.
2026-10-17 23:29:17 +00:00

366 lines
20 KiB
C++

#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;
extern "C" {
int APS5_VABI sceNpUniversalDataSystemCreateContext(int*, int, uint32_t, uint64_t);
int APS5_VABI sceNpUniversalDataSystemRegisterContext(int, int, uint64_t);
int APS5_VABI sceNpUniversalDataSystemCreateHandle(int*);
int APS5_VABI sceNpUniversalDataSystemCreateEvent(const char*, const NpUniversalDataSystemEventPropertyObject*, NpUniversalDataSystemEvent**, NpUniversalDataSystemEventPropertyObject**);
int APS5_VABI sceNpUniversalDataSystemDestroyEvent(NpUniversalDataSystemEvent*);
int APS5_VABI sceNpUniversalDataSystemPostEvent(int, int, const void*, uint64_t);
int APS5_VABI sceNpUniversalDataSystemCreateEventPropertyObject(NpUniversalDataSystemEventPropertyObject**);
int APS5_VABI sceNpUniversalDataSystemDestroyEventPropertyObject(NpUniversalDataSystemEventPropertyObject*);
int APS5_VABI sceNpUniversalDataSystemEventPropertyObjectSetInt32(NpUniversalDataSystemEventPropertyObject*, const char*, int32_t);
int APS5_VABI sceNpUniversalDataSystemEventPropertyObjectSetUInt32(NpUniversalDataSystemEventPropertyObject*, const char*, uint32_t);
int APS5_VABI sceNpUniversalDataSystemEventPropertyObjectSetInt64(NpUniversalDataSystemEventPropertyObject*, const char*, int64_t);
int APS5_VABI sceNpUniversalDataSystemEventPropertyObjectSetUInt64(NpUniversalDataSystemEventPropertyObject*, const char*, uint64_t);
int APS5_VABI sceNpUniversalDataSystemEventPropertyObjectSetFloat32(NpUniversalDataSystemEventPropertyObject*, const char*, float);
int APS5_VABI sceNpUniversalDataSystemEventPropertyObjectSetObject(NpUniversalDataSystemEventPropertyObject*, const char*, const NpUniversalDataSystemEventPropertyObject*, NpUniversalDataSystemEventPropertyObject**);
int APS5_VABI sceNpTrophy2CreateContext(int*, int, uint32_t, uint64_t);
int APS5_VABI sceNpTrophy2GetGameInfo(int, int, NpTrophy2GameDetails*, NpTrophy2GameData*);
int APS5_VABI sceNpTrophy2GetTrophyInfo(int, int, int, NpTrophy2Details*, NpTrophy2Data*);
}
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 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 Uds {
int context = 0;
int handle = 0;
};
static Uds OpenUds(std::uint32_t serviceLabel) {
Uds 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 Uds& 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\" is 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");
}
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("_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);
NpTrophy2RemoveUnlockListener_nid_no_patch(listener);
std::filesystem::current_path(previous);
std::filesystem::remove_all(root);
std::puts("Trophy stat tests passed");
return 0;
}