mirror of
https://github.com/boykopovar/AnyPS5.git
synced 2026-10-27 00:28:14 +00:00
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]>
This commit is contained in:
1 parent
0119103fa9
commit
ca34fdb351
11 files changed
+174
-35
No files matched your search
@@ -89,17 +89,27 @@ struct TrophyPackage {
|
||||
|
||||
TrophyPackage ParseTrophyPackage(std::string_view ucp, std::string_view language);
|
||||
|
||||
enum class StatType { UInt32, UInt64 };
|
||||
enum class StatType { UInt32, UInt64, Int32 };
|
||||
|
||||
enum class StatAggregation { Latest, Max };
|
||||
|
||||
struct StatDefinition {
|
||||
StatType type = StatType::UInt64;
|
||||
StatAggregation aggregation = StatAggregation::Latest;
|
||||
std::uint64_t minimum = 0;
|
||||
std::uint64_t maximum = UINT64_MAX;
|
||||
std::uint64_t initial = 0;
|
||||
};
|
||||
|
||||
struct StatFilter {
|
||||
std::string path;
|
||||
std::uint64_t value = 0;
|
||||
};
|
||||
|
||||
struct StatRule {
|
||||
std::string input;
|
||||
std::uint64_t statId = 0;
|
||||
std::optional<StatFilter> filter;
|
||||
};
|
||||
|
||||
struct StatRules {
|
||||
|
||||
@@ -67,6 +67,7 @@ public:
|
||||
explicit StatStore(std::filesystem::path directory);
|
||||
|
||||
void Set(std::int32_t userId, std::uint32_t serviceLabel, std::uint64_t statId, std::uint64_t value);
|
||||
std::uint64_t Raise(std::int32_t userId, std::uint32_t serviceLabel, std::uint64_t statId, std::uint64_t value);
|
||||
std::optional<std::uint64_t> Value(std::int32_t userId, std::uint32_t serviceLabel, std::uint64_t statId);
|
||||
std::map<std::uint64_t, std::uint64_t> Values(std::int32_t userId, std::uint32_t serviceLabel);
|
||||
std::filesystem::path RecordPath(std::int32_t userId, std::uint32_t serviceLabel) const;
|
||||
|
||||
@@ -139,6 +139,10 @@ StatChanges ApplyStatRules(const StatRules& rules, const NpTrophy2Event& event,
|
||||
const auto matching = rules.events.find(event.name);
|
||||
if (matching == rules.events.end()) return changes;
|
||||
for (const auto& rule : matching->second) {
|
||||
if (rule.filter) {
|
||||
const auto filtered = std::find_if(event.properties.begin(), event.properties.end(), [&rule](const NpTrophy2EventProperty& candidate) { return candidate.path == rule.filter->path; });
|
||||
if (filtered == event.properties.end() || filtered->value != rule.filter->value) continue;
|
||||
}
|
||||
const auto& definition = rules.stats.at(rule.statId);
|
||||
const auto property = std::find_if(event.properties.begin(), event.properties.end(), [&rule](const NpTrophy2EventProperty& candidate) { return candidate.path == rule.input; });
|
||||
const auto where = event.name + " $." + rule.input + " for stat " + std::to_string(rule.statId);
|
||||
@@ -148,6 +152,8 @@ StatChanges ApplyStatRules(const StatRules& rules, const NpTrophy2Event& event,
|
||||
changes.problems.push_back({where, property->text + " is not an unsigned integer"});
|
||||
} else if (*property->value < definition.minimum || *property->value > definition.maximum) {
|
||||
changes.problems.push_back({where, property->text + " is not from " + std::to_string(definition.minimum) + " to " + std::to_string(definition.maximum)});
|
||||
} else if (definition.aggregation == StatAggregation::Max) {
|
||||
changes.updates.push_back({rule.statId, stats.Raise(userId, serviceLabel, rule.statId, std::max(*property->value, definition.initial)), rule.input});
|
||||
} else {
|
||||
stats.Set(userId, serviceLabel, rule.statId, *property->value);
|
||||
changes.updates.push_back({rule.statId, *property->value, rule.input});
|
||||
|
||||
@@ -363,26 +363,34 @@ void readStat(const Json& definition, std::size_t index, StatRules& rules) {
|
||||
}
|
||||
const auto name = "stat " + std::to_string(*statId);
|
||||
const auto* aggregation = definition.Find("aggregation");
|
||||
if (aggregation == nullptr || aggregation->kind != Json::Kind::String || aggregation->text != "latest") {
|
||||
rules.skipped.push_back(name + " has aggregation " + (aggregation != nullptr ? describe(*aggregation) : std::string("none")) + "; only \"latest\" is supported");
|
||||
const auto named = [](const Json* value, std::string_view text) { return value != nullptr && value->kind == Json::Kind::String && value->text == text; };
|
||||
StatDefinition stat;
|
||||
if (named(aggregation, "latest")) {
|
||||
stat.aggregation = StatAggregation::Latest;
|
||||
} else if (named(aggregation, "max")) {
|
||||
stat.aggregation = StatAggregation::Max;
|
||||
} else {
|
||||
rules.skipped.push_back(name + " has aggregation " + (aggregation != nullptr ? describe(*aggregation) : std::string("none")) + "; only \"latest\" and \"max\" are supported");
|
||||
return;
|
||||
}
|
||||
const auto* type = definition.Find("dataType");
|
||||
StatDefinition stat;
|
||||
if (type != nullptr && type->kind == Json::Kind::String && type->text == "uint32") {
|
||||
if (named(type, "uint32")) {
|
||||
stat.type = StatType::UInt32;
|
||||
stat.maximum = UINT32_MAX;
|
||||
} else if (type != nullptr && type->kind == Json::Kind::String && type->text == "uint64") {
|
||||
} else if (named(type, "uint64")) {
|
||||
stat.type = StatType::UInt64;
|
||||
stat.maximum = UINT64_MAX;
|
||||
} else if (named(type, "int32")) {
|
||||
stat.type = StatType::Int32;
|
||||
stat.maximum = INT32_MAX;
|
||||
} else {
|
||||
rules.skipped.push_back(name + " has dataType " + (type != nullptr ? describe(*type) : std::string("none")) + "; only \"uint32\" and \"uint64\" are supported");
|
||||
rules.skipped.push_back(name + " has dataType " + (type != nullptr ? describe(*type) : std::string("none")) + "; only \"uint32\", \"uint64\" and \"int32\" are supported");
|
||||
return;
|
||||
}
|
||||
if (const auto* minimum = definition.Find("minValue")) {
|
||||
const auto value = minimum->Unsigned();
|
||||
if (!value || *value > stat.maximum) {
|
||||
rules.skipped.push_back(name + " has minValue " + describe(*minimum) + ", which its " + type->text + " type cannot hold");
|
||||
rules.skipped.push_back(name + " has minValue " + describe(*minimum) + (stat.type == StatType::Int32 ? ", which is not from 0 to its int32 limit" : ", which its " + type->text + " type cannot hold"));
|
||||
return;
|
||||
}
|
||||
stat.minimum = *value;
|
||||
@@ -395,6 +403,15 @@ void readStat(const Json& definition, std::size_t index, StatRules& rules) {
|
||||
}
|
||||
stat.maximum = *value;
|
||||
}
|
||||
stat.initial = stat.minimum;
|
||||
if (const auto* initial = definition.Find("initialValue"); initial != nullptr && stat.aggregation == StatAggregation::Max) {
|
||||
const auto value = initial->Unsigned();
|
||||
if (!value || *value < stat.minimum || *value > stat.maximum) {
|
||||
rules.skipped.push_back(name + " has initialValue " + describe(*initial) + ", which is not from its minValue to its maxValue");
|
||||
return;
|
||||
}
|
||||
stat.initial = *value;
|
||||
}
|
||||
if (!rules.stats.emplace(*statId, stat).second) rules.skipped.push_back(name + " is defined twice; the first definition is used");
|
||||
}
|
||||
|
||||
@@ -411,9 +428,20 @@ void readRule(const Json& rule, std::size_t index, StatRules& rules) {
|
||||
rules.skipped.push_back(where + " has no eventName");
|
||||
return;
|
||||
}
|
||||
std::optional<StatFilter> filter;
|
||||
if (condition->members.size() != 1) {
|
||||
rules.skipped.push_back(where + " filters " + event->text + " by more than its eventName; only eventName conditions are supported");
|
||||
return;
|
||||
const auto* property = condition->Find("property");
|
||||
const auto* path = property != nullptr && property->kind == Json::Kind::Object ? property->Find("path") : nullptr;
|
||||
const auto* comparator = property != nullptr && property->kind == Json::Kind::Object ? property->Find("comparator") : nullptr;
|
||||
const auto* value = property != nullptr && property->kind == Json::Kind::Object ? property->Find("value") : nullptr;
|
||||
const auto filterPath = path != nullptr && path->kind == Json::Kind::String ? propertyPath(path->text) : std::nullopt;
|
||||
const auto filterValue = value != nullptr ? value->Unsigned() : std::nullopt;
|
||||
const bool equal = comparator != nullptr && comparator->kind == Json::Kind::String && comparator->text == "==";
|
||||
if (condition->members.size() != 2 || property == nullptr || property->kind != Json::Kind::Object || property->members.size() != 3 || !filterPath || !equal || !filterValue) {
|
||||
rules.skipped.push_back(where + " filters " + event->text + " by more than its eventName; only an eventName with one \"$.name\" property equal to an unsigned integer is supported");
|
||||
return;
|
||||
}
|
||||
filter = StatFilter{*filterPath, *filterValue};
|
||||
}
|
||||
const auto* output = action->Find("output");
|
||||
const auto* id = output != nullptr ? output->Find("statId") : nullptr;
|
||||
@@ -432,7 +460,7 @@ void readRule(const Json& rule, std::size_t index, StatRules& rules) {
|
||||
rules.skipped.push_back(where + " for " + event->text + " reads " + (input != nullptr ? describe(*input) : std::string("no input")) + "; only \"$.name\" property paths are supported");
|
||||
return;
|
||||
}
|
||||
rules.events[event->text].push_back({*path, *statId});
|
||||
rules.events[event->text].push_back({*path, *statId, std::move(filter)});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -177,6 +177,18 @@ void StatStore::Set(std::int32_t userId, std::uint32_t serviceLabel, std::uint64
|
||||
stats = std::move(updated);
|
||||
}
|
||||
|
||||
std::uint64_t StatStore::Raise(std::int32_t userId, std::uint32_t serviceLabel, std::uint64_t statId, std::uint64_t value) {
|
||||
std::lock_guard lock(mutex);
|
||||
const Key key{userId, serviceLabel};
|
||||
auto& stats = record(key);
|
||||
if (const auto found = stats.find(statId); found != stats.end() && found->second >= value) return found->second;
|
||||
auto updated = stats;
|
||||
updated[statId] = value;
|
||||
writeRecord(directory, RecordPath(userId, serviceLabel), updated, "stat record");
|
||||
stats = std::move(updated);
|
||||
return value;
|
||||
}
|
||||
|
||||
std::optional<std::uint64_t> StatStore::Value(std::int32_t userId, std::uint32_t serviceLabel, std::uint64_t statId) {
|
||||
std::lock_guard lock(mutex);
|
||||
const auto& stats = record({userId, serviceLabel});
|
||||
|
||||
@@ -94,7 +94,11 @@ const char StatsDefinition[] = R"({"statDefinitionArray":[
|
||||
{"statId":50,"dataType":"uint64","aggregation":"sum"},
|
||||
{"statId":60,"dataType":"int32","aggregation":"latest"},
|
||||
{"statId":70,"dataType":"uint32","aggregation":"latest","maxValue":"5000000000"},
|
||||
{"dataType":"uint32","aggregation":"latest"}
|
||||
{"dataType":"uint32","aggregation":"latest"},
|
||||
{"statId":80,"dataType":"int32","aggregation":"max","minValue":"0","initialValue":"0"},
|
||||
{"statId":90,"dataType":"uint32","aggregation":"max","initialValue":"3"},
|
||||
{"statId":100,"dataType":"int32","aggregation":"max","initialValue":"-1"},
|
||||
{"statId":110,"dataType":"int32","aggregation":"latest","minValue":"-5"}
|
||||
]})";
|
||||
|
||||
const char StatsExtraction[] = R"({"statsExtractionRuleArray":[
|
||||
@@ -106,7 +110,12 @@ const char StatsExtraction[] = R"({"statsExtractionRuleArray":[
|
||||
{"condition":{"eventName":"FILTERED","property":{"path":"$.level"}},"action":{"input":"$.counter","output":{"statId":10}}},
|
||||
{"condition":{"eventName":"COUNT"},"action":{"output":{"statId":10}}},
|
||||
{"condition":{"eventName":"BRACKETS"},"action":{"input":"$['counter']","output":{"statId":10}}},
|
||||
{"bad":1}
|
||||
{"bad":1},
|
||||
{"condition":{"eventName":"PROGRESS","property":{"path":"$._trophy_id","comparator":"==","value":"5"}},"action":{"input":"$._trophy_progress","output":{"statId":80}}},
|
||||
{"condition":{"eventName":"PROGRESS","property":{"path":"$._trophy_id","comparator":"==","value":6}},"action":{"input":"$._trophy_progress","output":{"statId":90}}},
|
||||
{"condition":{"eventName":"RANGED","property":{"path":"$._trophy_id","comparator":">=","value":"5"}},"action":{"input":"$._trophy_progress","output":{"statId":80}}},
|
||||
{"condition":{"eventName":"NAMED","property":{"path":"$.mode","comparator":"==","value":"delta"}},"action":{"input":"$._trophy_progress","output":{"statId":80}}},
|
||||
{"condition":{"eventName":"EXTRA","property":{"path":"$._trophy_id","comparator":"==","value":"5"},"other":1},"action":{"input":"$._trophy_progress","output":{"statId":80}}}
|
||||
]})";
|
||||
|
||||
std::string trophyPackage() {
|
||||
@@ -310,10 +319,13 @@ void testProgressAndPlatinum() {
|
||||
|
||||
void testStatRules() {
|
||||
const auto rules = NpTrophy2::ParseStatRules(statPackage());
|
||||
check(rules.stats.size() == 4 && rules.stats.at(10).type == NpTrophy2::StatType::UInt64 && rules.stats.at(30).type == NpTrophy2::StatType::UInt32 && rules.stats.at(30).maximum == UINT32_MAX &&
|
||||
rules.stats.at(40).maximum == 50 && rules.stats.at(10).minimum == 0,
|
||||
check(rules.stats.size() == 7 && rules.stats.at(10).type == NpTrophy2::StatType::UInt64 && rules.stats.at(30).type == NpTrophy2::StatType::UInt32 && rules.stats.at(30).maximum == UINT32_MAX &&
|
||||
rules.stats.at(40).maximum == 50 && rules.stats.at(10).minimum == 0 && rules.stats.at(10).aggregation == NpTrophy2::StatAggregation::Latest,
|
||||
"stat definitions were not read");
|
||||
check(rules.events.size() == 3 && rules.events.at("COLLECT").size() == 2 && rules.events.at("COLLECT")[1].input == "total" && rules.events.at("COLLECT")[1].statId == 20 &&
|
||||
check(rules.stats.at(60).type == NpTrophy2::StatType::Int32 && rules.stats.at(60).maximum == INT32_MAX && rules.stats.at(80).aggregation == NpTrophy2::StatAggregation::Max &&
|
||||
rules.stats.at(80).type == NpTrophy2::StatType::Int32 && rules.stats.at(80).initial == 0 && rules.stats.at(90).aggregation == NpTrophy2::StatAggregation::Max && rules.stats.at(90).initial == 3,
|
||||
"int32 and max stat definitions were not read");
|
||||
check(rules.events.size() == 4 && rules.events.at("COLLECT").size() == 2 && rules.events.at("COLLECT")[1].input == "total" && rules.events.at("COLLECT")[1].statId == 20 &&
|
||||
rules.events.at("STEPS")[0].input == "player.steps" && rules.events.at("TIME")[0].statId == 30,
|
||||
"extraction rules were not read");
|
||||
std::set<std::string> skipped(rules.skipped.begin(), rules.skipped.end());
|
||||
@@ -323,11 +335,17 @@ void testStatRules() {
|
||||
}
|
||||
return false;
|
||||
};
|
||||
check(rules.skipped.size() == 9, "unsupported definitions and rules were not all reported");
|
||||
check(reported("stat 50 has aggregation \"sum\"") && reported("stat 60 has dataType \"int32\"") && reported("stat 70 has maxValue \"5000000000\"") && reported("statDefinitionArray[7] has no unsigned statId"),
|
||||
check(rules.skipped.size() == 13, "unsupported definitions and rules were not all reported");
|
||||
check(reported("stat 50 has aggregation \"sum\"; only \"latest\" and \"max\" are supported") && reported("stat 70 has maxValue \"5000000000\"") && reported("statDefinitionArray[7] has no unsigned statId") &&
|
||||
reported("stat 100 has initialValue \"-1\"") && reported("stat 110 has minValue \"-5\", which is not from 0 to its int32 limit"),
|
||||
"unsupported definitions were not reported");
|
||||
check(reported("for SUM writes stat 50") && reported("filters FILTERED") && reported("for COUNT reads no input") && reported("for BRACKETS reads \"$['counter']\"") && reported("[8] has no condition"),
|
||||
check(reported("for SUM writes stat 50") && reported("filters FILTERED") && reported("for COUNT reads no input") && reported("for BRACKETS reads \"$['counter']\"") && reported("[8] has no condition") &&
|
||||
reported("filters RANGED") && reported("filters NAMED") && reported("filters EXTRA"),
|
||||
"unsupported rules were not reported");
|
||||
const auto& progress = rules.events.at("PROGRESS");
|
||||
check(progress.size() == 2 && progress[0].filter && progress[0].filter->path == "_trophy_id" && progress[0].filter->value == 5 && progress[0].input == "_trophy_progress" && progress[0].statId == 80 &&
|
||||
progress[1].filter && progress[1].filter->value == 6 && progress[1].statId == 90 && !rules.events.at("COLLECT")[0].filter,
|
||||
"property filters were not read");
|
||||
for (const char* input : {"$", "$.", "$..a", "$.a.", "$.a-b", "counter", "$.a b"}) {
|
||||
const std::string extraction = std::string(R"({"statsExtractionRuleArray":[{"condition":{"eventName":"E"},"action":{"input":")") + input + R"(","output":{"statId":10}}}]})";
|
||||
check(NpTrophy2::ParseStatRules(ucp({{"stats_definition.json", StatsDefinition}, {"stats_extraction.json", extraction}})).events.empty(), "an unsupported input path was accepted");
|
||||
@@ -412,6 +430,37 @@ void testApplyStatRules() {
|
||||
check(latest.updates.size() == 2 && stats.Value(InitialUser, 0, 10) == std::optional<std::uint64_t>(2), "a stat did not keep the latest value");
|
||||
}
|
||||
|
||||
void testProgressRules() {
|
||||
const auto rules = NpTrophy2::ParseStatRules(statPackage());
|
||||
NpTrophy2::StatStore stats("progress");
|
||||
const auto posted = [&](std::vector<NpTrophy2EventProperty> properties) { return NpTrophy2::ApplyStatRules(rules, event("PROGRESS", std::move(properties)), stats, InitialUser, 0); };
|
||||
const auto progress = [&](std::uint64_t trophy, std::uint64_t value) { return posted({{"_trophy_id", trophy, std::to_string(trophy)}, {"_trophy_progress", value, std::to_string(value)}}); };
|
||||
const auto first = progress(5, 4);
|
||||
check(first.problems.empty() && first.updates.size() == 1 && first.updates[0].statId == 80 && first.updates[0].value == 4 && stats.Value(InitialUser, 0, 80) == std::optional<std::uint64_t>(4) &&
|
||||
!stats.Value(InitialUser, 0, 90),
|
||||
"a filtered rule did not take its trophy's progress, or another trophy's rule did");
|
||||
const auto lower = progress(5, 2);
|
||||
check(lower.updates.size() == 1 && lower.updates[0].value == 4 && stats.Value(InitialUser, 0, 80) == std::optional<std::uint64_t>(4), "a max stat went down");
|
||||
const auto higher = progress(5, 7);
|
||||
check(higher.updates.size() == 1 && higher.updates[0].value == 7 && stats.Value(InitialUser, 0, 80) == std::optional<std::uint64_t>(7), "a max stat did not rise");
|
||||
const auto floor = progress(6, 1);
|
||||
check(floor.updates.size() == 1 && floor.updates[0].statId == 90 && floor.updates[0].value == 3 && stats.Value(InitialUser, 0, 90) == std::optional<std::uint64_t>(3), "a max stat started below its initialValue");
|
||||
const auto signedMax = progress(5, 2147483648ull);
|
||||
check(signedMax.updates.empty() && signedMax.problems.size() == 1 && signedMax.problems[0].reason == "2147483648 is not from 0 to 2147483647", "an int32 stat took a value it cannot hold");
|
||||
const auto other = progress(7, 9);
|
||||
const auto unnamed = posted({{"_trophy_progress", 9, "9"}});
|
||||
const auto negative = posted({{"_trophy_id", std::nullopt, "-5"}, {"_trophy_progress", 9, "9"}});
|
||||
check(other.updates.empty() && other.problems.empty() && unnamed.updates.empty() && unnamed.problems.empty() && negative.updates.empty() && negative.problems.empty() &&
|
||||
stats.Value(InitialUser, 0, 80) == std::optional<std::uint64_t>(7) && stats.Value(InitialUser, 0, 90) == std::optional<std::uint64_t>(3),
|
||||
"a filtered rule took an event for another trophy, or without its trophy id");
|
||||
const auto missing = posted({{"_trophy_id", 5, "5"}});
|
||||
check(missing.updates.empty() && missing.problems.size() == 1 && missing.problems[0].rule == "PROGRESS $._trophy_progress for stat 80", "a matching event without its input was not reported");
|
||||
NpTrophy2::StatStore restarted("progress");
|
||||
check(restarted.Raise(InitialUser, 0, 80, 6) == 7 && restarted.Raise(InitialUser, 0, 80, 8) == 8 && restarted.Raise(InitialUser, 0, 120, 1) == 1, "a raise did not keep the larger value");
|
||||
NpTrophy2::StatStore reread("progress");
|
||||
check(reread.Value(InitialUser, 0, 80) == std::optional<std::uint64_t>(8) && reread.Value(InitialUser, 0, 120) == std::optional<std::uint64_t>(1), "a raised stat was not stored");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
int main() {
|
||||
@@ -427,6 +476,7 @@ int main() {
|
||||
testConditions();
|
||||
testStatStore();
|
||||
testApplyStatRules();
|
||||
testProgressRules();
|
||||
std::filesystem::current_path(previous);
|
||||
std::filesystem::remove_all(root);
|
||||
std::puts("Trophy package tests passed");
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
@@ -22,13 +20,6 @@ Uds::Owned<NpUniversalDataSystemEvent>& events() {
|
||||
return registry;
|
||||
}
|
||||
|
||||
bool firstProgress(std::int32_t trophyId) {
|
||||
static std::mutex mutex;
|
||||
static std::set<std::int32_t> logged;
|
||||
std::lock_guard lock(mutex);
|
||||
return logged.insert(trophyId).second;
|
||||
}
|
||||
|
||||
void ignore(const NpUniversalDataSystemEvent& event, int context, const std::string& reason) {
|
||||
std::fprintf(stderr, "[trophy] ignored %s posted to context %d: %s\n", Uds::Describe(event).c_str(), context, reason.c_str());
|
||||
}
|
||||
@@ -39,14 +30,12 @@ void post(const NpUniversalDataSystemEvent& event, int context) {
|
||||
const auto trophy = Uds::RecognizeTrophyEvent(event);
|
||||
switch (trophy.kind) {
|
||||
case Uds::TrophyEventKind::None:
|
||||
case Uds::TrophyEventKind::Progress:
|
||||
if (const auto owner = Uds::FindContext(context)) NpTrophy2ApplyEvent_nid_no_patch(owner->userId, owner->serviceLabel, Uds::StatEvent(event));
|
||||
return;
|
||||
case Uds::TrophyEventKind::Malformed:
|
||||
ignore(event, context, trophy.problem);
|
||||
return;
|
||||
case Uds::TrophyEventKind::Progress:
|
||||
if (firstProgress(trophy.trophyId)) ignore(event, context, "trophy progress is not recorded");
|
||||
return;
|
||||
case Uds::TrophyEventKind::Unlock:
|
||||
if (const auto owner = Uds::FindContext(context)) NpTrophy2RecordUnlock_nid_no_patch(owner->userId, owner->serviceLabel, trophy.trophyId);
|
||||
else ignore(event, context, "the context does not exist");
|
||||
|
||||
@@ -45,6 +45,20 @@ 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":[
|
||||
@@ -308,11 +322,36 @@ static void IgnoreOtherAggregations(Unlocks& unlocks) {
|
||||
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,
|
||||
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{}()));
|
||||
@@ -329,6 +368,8 @@ int main() {
|
||||
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;
|
||||
@@ -338,6 +379,7 @@ int main() {
|
||||
UnlockThroughStats(context, unlocks);
|
||||
IgnoreMissingAndMalformedFiles(unlocks);
|
||||
IgnoreOtherAggregations(unlocks);
|
||||
RecordTrophyProgress(unlocks);
|
||||
NpTrophy2RemoveUnlockListener_nid_no_patch(listener);
|
||||
|
||||
std::filesystem::current_path(previous);
|
||||
|
||||
@@ -16,5 +16,6 @@
|
||||
- [Trophy queries](../../../core/libs/prx/libSceNpTrophy2/src/TrophyInfo.cpp) - not confirmed on the console: `sceNpTrophy2GetTrophyInfo` of a trophy id that is not in the title's [trophy data](../../../core/libs/prx/libSceNpTrophy2/src/TrophyPackage.cpp) (`sce_sys/trophy2/trophyNN.ucp`) returns `0x8055390A` (KytyPS5's `NP_TROPHY2_ERROR_INVALID_TROPHY_ID`) and writes nothing; a context not created or destroyed reports placeholders and no unlocks and returns success; hidden trophies report their names and descriptions before the unlock; `progress_percentage` weighs gold 6, silver 2 and bronze 1 and leaves the platinum out, as KytyPS5 does (the ratio of the published PSN trophy points). A group that is not in the data throws. Without the file, or when it is malformed, the queries report the old placeholders and `sceNpTrophy2GetTrophyInfoArray` lists only trophy 0. Icons are not read. Unlocks come from the [trophy store](../../../core/libs/prx/libSceNpTrophy2/src/TrophyStore.cpp).
|
||||
- [Trophy data limits](../../../core/libs/prx/libSceNpTrophy2/src/TrophyPackage.cpp): a `trophyNN.ucp` or `udsNN.ucp` larger than 256 MiB or listing more than 4096 files, and a JSON file in it that nests deeper than 64 levels or holds more than 1048576 values, are not used, and the title runs as without that file. The limits keep the memory the reader takes to the file and about 140 MiB of parsed JSON; the console's limits are not known.
|
||||
- [Text language](../../../core/libs/prx/libSceNpTrophy2/src/TrophyPackage.cpp): texts come from `tropmeta_<tag>.json` for the console language, then `en-US`, then the file's `defaultLanguage`. The tags for languages 0 to 29 are KytyPS5's; `uk-UA` for 30 (Ukrainian) is an assumption.
|
||||
- [Trophy progress statistics](../../../core/libs/prx/libSceNpTrophy2/src/TrophyPackage.cpp): `max` (the largest value posted, starting from `initialValue`, or `minValue` without one), `int32` (0 to 2147483647) and a rule condition of an `eventName` and one `property` whose `comparator` is `==` and `value` an unsigned integer follow the rules a title ships for its progressive trophies, not a console measurement. Borderlands 4 (PPSA01494) feeds each one's `udsStatId` from `$._trophy_progress` of `_UpdateTrophyProgress`, through an `int32` `max` statistic and a rule on `$._trophy_id`. `latest` and the unsigned types follow KytyPS5.
|
||||
- [Platinum](../../../core/libs/prx/libSceNpTrophy2/src/TitleTrophies.cpp): an unlock through any path unlocks the platinum that the trophy names in `platinumTrophyId` once every trophy naming that platinum is unlocked, as KytyPS5 does; trophies without `platinumTrophyId` are not required. With the title's trophy data loaded, a posted `_UnlockTrophy` of a trophy that is not in the data, of a platinum or of a progressive trophy is not stored and prints a `[trophy] ignored` line, as KytyPS5 refuses them, so a progressive trophy unlocks only through its statistic; without trophy data any id of 0 or more is stored, including ids the data would make the platinum, a progressive trophy or no trophy, and the platinum is not unlocked when the trophies it needs are. The console's behaviour is not known.
|
||||
- [Damaged records](../../../core/libs/prx/libSceNpTrophy2/src/TrophyStore.cpp): a line in `_trophy/<user id>_<service label>.txt` or `.stats.txt` that is not a new `<id> <value>` pair (a blank line, a carriage return, a repeated or negative id, a value that is not a number) makes `readRecord` throw a `std::runtime_error` that names the file and the line. Every call that reads that record then throws it instead of returning, which stops the title as any throwing export does, so the title gets no return value; main returned 0 from these calls. The failed read is not kept, so each later call reads the file again and throws again until the file is fixed or deleted. The unlock record is read by `sceNpTrophy2GetTrophyInfo`, `sceNpTrophy2GetTrophyInfoArray` and `sceNpTrophy2GetGameInfo` for a created context, by `sceNpTrophy2GetGroupInfo` and `sceNpTrophy2GetGroupInfoArray` when the title's trophy data is loaded, by a `sceNpUniversalDataSystemPostEvent` of an `_UnlockTrophy` that the trophy data does not refuse, and by a statistic change when the trophy data is loaded; the stat record by a `sceNpUniversalDataSystemPostEvent` of an event that a statistics rule copies and by the trophy queries of a progressive trophy with a statistic. What the console does with a damaged record is not known.
|
||||
@@ -19,5 +19,5 @@
|
||||
|
||||
### Functional
|
||||
|
||||
- [Trophy events](../../../core/libs/prx/libSceNpUniversalDataSystem/src/Event.cpp): `sceNpUniversalDataSystemPostEvent` stores an `_UnlockTrophy` event with an int32 `_trophy_id` of 0 or more as an unlock for the user and service label of its context, as KytyPS5 does, except an id that [libSceNpTrophy2](../../../core/libs/prx/libSceNpTrophy2/src/TrophyStore.cpp) refuses when the title's trophy data is loaded (one not in the data, a platinum or a progressive trophy). `_UpdateTrophyProgress`, named only by a third-party unlocker, is logged once per trophy and not interpreted. A context need not be registered, and every post of an event that `sceNpUniversalDataSystemCreateEvent` returned and that is not yet destroyed returns success (a null or any other event pointer stops the game): KytyPS5's errors for an unknown or unregistered context and for a malformed unlock are not verified. The storage statistics stay zero.
|
||||
- [Statistics](../../../core/libs/prx/libSceNpUniversalDataSystem/src/Event.cpp): every other event goes through the title's rules in `sce_sys/uds/udsNN.ucp`, which [libSceNpTrophy2](../../../core/libs/prx/libSceNpTrophy2/src/TitleTrophies.cpp) reads. Only statistics with `"aggregation":"latest"` and `dataType` `uint32` or `uint64` are kept, as in KytyPS5; no public source documents the other aggregations or types, so their definitions and rules are skipped. Only rules whose condition is an `eventName` alone and whose input is a `$.name` path (dotted for nested objects) are applied. A value is used when it is an integer of any width that is not negative and fits the statistic's type and `minValue`/`maxValue`; KytyPS5 requires the statistic's exact width, and whether the console converts, clamps or rejects other values is not known. Statistics are stored per user and service label, and every trophy's `unlockCondition` is compared after each event that sets one.
|
||||
- [Trophy events](../../../core/libs/prx/libSceNpUniversalDataSystem/src/Event.cpp): `sceNpUniversalDataSystemPostEvent` stores an `_UnlockTrophy` event with an int32 `_trophy_id` of 0 or more as an unlock for the user and service label of its context, as KytyPS5 does, except an id that [libSceNpTrophy2](../../../core/libs/prx/libSceNpTrophy2/src/TrophyStore.cpp) refuses when the title's trophy data is loaded (one not in the data, a platinum or a progressive trophy). `_UpdateTrophyProgress` goes through the title's statistic rules like any other event (below). A context need not be registered, and every post of an event that `sceNpUniversalDataSystemCreateEvent` returned and that is not yet destroyed returns success (a null or any other event pointer stops the game): KytyPS5's errors for an unknown or unregistered context and for a malformed unlock are not verified. The storage statistics stay zero.
|
||||
- [Statistics](../../../core/libs/prx/libSceNpUniversalDataSystem/src/Event.cpp): every other event goes through the title's rules in `sce_sys/uds/udsNN.ucp`, which [libSceNpTrophy2](../../../core/libs/prx/libSceNpTrophy2/src/TitleTrophies.cpp) reads. Only `"aggregation":"latest"` and `"max"` statistics of `dataType` `uint32`, `uint64` or `int32` are kept; no public source documents the other aggregations (such as `count`, `collection` and `eachLatest`) or types, so their definitions and rules are skipped. Only rules whose input is a `$.name` path (dotted for nested objects) are applied. A value is used when it is an integer of any width that is not negative and fits the statistic's type and `minValue`/`maxValue`; KytyPS5 requires the statistic's exact width, and whether the console converts, clamps or rejects other values is not known. Statistics are stored per user and service label, and every trophy's `unlockCondition` is compared after each event that sets one.
|
||||
+1
-1
@@ -274,7 +274,7 @@ The trophy data comes from the game's own `app0/sce_sys/trophy2/trophyNN.ucp`, w
|
||||
|
||||
Many games unlock trophies through statistics instead: `app0/sce_sys/uds/udsNN.ucp` names, for each event, the property whose value becomes a statistic, and a trophy's `unlockCondition` compares a statistic with a target. The latest value of each statistic is stored in `_trophy/<user id>_<service label>.stats.txt`, one `<stat id> <value>` line per statistic, and is kept across runs; delete it together with the unlock file to start over. A progressive trophy reports its statistic as progress. Only statistics that keep the latest value (`"aggregation":"latest"`) as `uint32` or `uint64`, and rules that copy one property of an event chosen by its name (`$.name` or `$.object.name`), are applied; the others are listed in `[trophy]` lines when the file is read.
|
||||
|
||||
`APS5_TRACE_UDS=1` prints every posted event, such as `[uds] context 1 posted _UnlockTrophy {"_trophy_id":12}`, every statistic an event sets, such as `[uds] stat 10 of user 268435456 is 10 (COLLECT $.counter)`, and a `[uds]` line for a missing `trophyNN.ucp` or `udsNN.ucp`. An event that is not used prints the reason in a `[trophy] ignored` line: an `_UnlockTrophy` event without an int32 `_trophy_id` of 0 or more, or posted to a context that does not exist; the first `_UpdateTrophyProgress` event of each trophy, because that event is not interpreted; and, once per rule, an event whose property is missing, negative, not an integer or outside the statistic's range.
|
||||
`APS5_TRACE_UDS=1` prints every posted event, such as `[uds] context 1 posted _UnlockTrophy {"_trophy_id":12}`, every statistic an event sets, such as `[uds] stat 10 of user 268435456 is 10 (COLLECT $.counter)`, and a `[uds]` line for a missing `trophyNN.ucp` or `udsNN.ucp`. An event that is not used prints the reason in a `[trophy] ignored` line: an `_UnlockTrophy` event without an int32 `_trophy_id` of 0 or more, or posted to a context that does not exist; and, once per rule, an event whose property is missing, negative, not an integer or outside the statistic's range.
|
||||
|
||||
A missing or malformed `trophyNN.ucp` or `udsNN.ucp` turns off only what it provides, and a malformed one prints the reason once: without trophy data the queries report placeholders and statistics unlock nothing, and without statistics rules events change no statistic. Icons are not read: `sceNpTrophy2GetGameIcon`, `sceNpTrophy2GetGroupIcon`, `sceNpTrophy2GetTrophyIcon` and `sceNpTrophy2GetRewardIcon` stop the game with an error. So does an unlock or statistics file in `_trophy/` that is damaged (a line that is not a new `<id> <value>` pair, such as a line an edit left blank or ended with a carriage return) or cannot be read or written. No notification is shown, and callbacks registered with `sceNpTrophy2RegisterUnlockCallback` are not called.
|
||||
|
||||
|
||||
Reference in new issue
Block a user