diff --git a/CMakeLists.txt b/CMakeLists.txt index 2ebfa982..86682f1e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -413,6 +413,12 @@ add_executable(archive_file_tests EXCLUDE_FROM_ALL target_link_libraries(archive_file_tests common ZArchive::zarchive) target_include_directories(archive_file_tests PRIVATE ${inc_headers}) +add_executable(trophy_system_tests EXCLUDE_FROM_ALL + "${KYTY_TESTS_DIR}/TrophySystemTests.cpp" +) +target_link_libraries(trophy_system_tests common) +target_include_directories(trophy_system_tests PRIVATE ${inc_headers}) + add_executable(avplayer_file_tests EXCLUDE_FROM_ALL "${KYTY_TESTS_DIR}/AvPlayerFileTests.cpp") target_link_libraries(avplayer_file_tests common FFmpeg::ffmpeg ZArchive::zarchive) target_include_directories(avplayer_file_tests PRIVATE ${inc_headers}) @@ -606,6 +612,7 @@ if(BUILD_TESTING) add_test(NAME memory_tracker COMMAND $) add_test(NAME page_manager COMMAND $) add_test(NAME archive_file COMMAND $) + add_test(NAME trophy_system COMMAND $) add_test(NAME avplayer_file COMMAND $) add_test(NAME bit_array COMMAND $) add_test(NAME lru_cache COMMAND $) diff --git a/assets/images/hidden-trophy.png b/assets/images/hidden-trophy.png new file mode 100644 index 00000000..0011566f Binary files /dev/null and b/assets/images/hidden-trophy.png differ diff --git a/src/common/trophies.cpp b/src/common/trophies.cpp index b28dbb55..cb7d8926 100644 --- a/src/common/trophies.cpp +++ b/src/common/trophies.cpp @@ -5,9 +5,9 @@ #include #include #include -#include #include #include +#include namespace Common::Trophies { namespace { @@ -40,6 +40,25 @@ int ReadId(const Json& value) { return error == std::errc {} && end == text.data() + text.size() && id >= 0 ? id : -1; } +std::optional ReadUnsigned(const Json& value) { + if (value.is_number_unsigned()) { + return value.get(); + } + if (value.is_number_integer()) { + const auto number = value.get(); + return number >= 0 ? std::optional(static_cast(number)) : std::nullopt; + } + if (!value.is_string()) { + return std::nullopt; + } + const auto& text = value.get_ref(); + uint64_t number = 0; + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), number); + return error == std::errc {} && end == text.data() + text.size() + ? std::optional(number) + : std::nullopt; +} + Json ReadJson(const Files& files, std::string_view name) { const auto entry = files.find(name); if (entry == files.end()) { @@ -48,19 +67,18 @@ Json ReadJson(const Files& files, std::string_view name) { return Json::parse(entry->second.begin(), entry->second.end(), nullptr, false); } -Package ParsePackage(std::span data, int console_language) { +bool ReadFiles(std::span data, Files& files, uint64_t& size) { if (data.size() < 0x40 || data.size() > MaxPackageSize || ReadBigEndian(data.data(), 4) != 0xb228c60a || ReadBigEndian(data.data() + 4, 4) != 1) { - return {}; + return false; } - const auto size = ReadBigEndian(data.data() + 8, 8); + size = ReadBigEndian(data.data() + 8, 8); const auto count = ReadBigEndian(data.data() + 0x10, 4); const auto toc = ReadBigEndian(data.data() + 0x14, 4); if (size < 0x40 || size > data.size() || count > 4096 || toc > size || 0x20 + count * 0x40 > size - toc) { - return {}; + return false; } - Files files; for (uint64_t i = 0; i < count; ++i) { const auto* entry = data.data() + toc + 0x20 + i * 0x40; const auto* name = reinterpret_cast(entry); @@ -68,10 +86,19 @@ Package ParsePackage(std::span data, int console_language) { const auto offset = ReadBigEndian(entry + 0x20, 8); const auto length = ReadBigEndian(entry + 0x28, 8); if (offset > size || length > size - offset) { - return {}; + return false; } files.emplace(filename, data.subspan(offset, length)); } + return true; +} + +Package ParsePackage(std::span data, int console_language) { + Files files; + uint64_t size = 0; + if (!ReadFiles(data, files, size)) { + return {}; + } const auto conf = ReadJson(files, "tropconf.json"); if (!conf.is_object() || !conf.contains("trophies") || !conf["trophies"].is_array() || conf["trophies"].size() > 1000 || !conf.contains("defaultLanguage") || @@ -129,19 +156,29 @@ Package ParsePackage(std::span data, int console_language) { trophy.has_reward = definition.contains("hasReward") && definition["hasReward"] == true; if (definition.contains("unlockCondition")) { const auto& condition = definition["unlockCondition"]; - if (condition.is_object() && condition.contains("progressive") && - condition["progressive"] == true) { - if (!condition.contains("targetValue") || !condition["targetValue"].is_string()) { - return {}; + if (condition.is_object()) { + trophy.progressive = + condition.contains("progressive") && condition["progressive"] == true; + if (condition.contains("udsStatId")) { + trophy.uds_stat_id = ReadUnsigned(condition["udsStatId"]); + if (!trophy.uds_stat_id) { + return {}; + } } - const auto& value = condition["targetValue"].get_ref(); - uint64_t target = 0; - const auto [end, error] = - std::from_chars(value.data(), value.data() + value.size(), target); - if (error != std::errc {} || end != value.data() + value.size()) { - return {}; + if (condition.contains("targetValue")) { + trophy.target = ReadUnsigned(condition["targetValue"]); + } + const auto comparison = condition.find("comparator"); + if (comparison != condition.end()) { + if (*comparison == "ge") + trophy.comparison = Comparison::GreaterEqual; + else if (*comparison == "gt") + trophy.comparison = Comparison::Greater; + else if (*comparison == "le") + trophy.comparison = Comparison::LessEqual; + else if (*comparison == "lt") + trophy.comparison = Comparison::Less; } - trophy.target = target; } } const auto icon = files.find(fmt::format("trop{:04}.png", trophy.id)); @@ -210,11 +247,11 @@ std::filesystem::path UnlocksPath(const std::filesystem::path& root, std::string })) { return {}; } - return root / "_SaveData" / title_id / + return root / "_Trophies" / title_id / fmt::format("trophies_{}_{}.json", user_id, service_label); } -std::set LoadUnlocks(const std::filesystem::path& path) { +UnlockData LoadUnlockData(const std::filesystem::path& path) { File file(path, File::Mode::Read); if (file.IsInvalid() || file.Size() > MaxUnlocksSize) { return {}; @@ -225,22 +262,40 @@ std::set LoadUnlocks(const std::filesystem::path& path) { !json["unlockedTrophies"].is_array()) { return {}; } - std::set unlocked; + UnlockData unlocks; for (const auto& value: json["unlockedTrophies"]) { const auto id = ReadId(value); if (id >= 0) { - unlocked.insert(id); + unlocks.unlocked.insert(id); } } - return unlocked; + if (json.contains("unlockedAt") && json["unlockedAt"].is_object()) { + for (auto it = json["unlockedAt"].begin(); it != json["unlockedAt"].end(); ++it) { + const auto id = ReadId(Json(it.key())); + if (id >= 0 && unlocks.unlocked.contains(id)) { + const auto tick = ReadUnsigned(it.value()); + if (tick && *tick >= UnixEpochTick && *tick <= 315537897599999999ULL) { + unlocks.timestamps.emplace(id, *tick); + } + } + } + } + return unlocks; } -bool SaveUnlocks(const std::filesystem::path& path, const std::set& unlocked) { +bool SaveUnlockData(const std::filesystem::path& path, const UnlockData& unlocks) { if (path.empty() || !File::CreateDirectories(path.parent_path())) { return false; } - const auto text = Json {{"unlockedTrophies", unlocked}}.dump(); - auto temporary = path; + Json timestamps = Json::object(); + for (const auto& [id, tick]: unlocks.timestamps) { + if (unlocks.unlocked.contains(id)) { + timestamps[std::to_string(id)] = tick; + } + } + const auto text = + Json {{"unlockedTrophies", unlocks.unlocked}, {"unlockedAt", timestamps}}.dump(); + auto temporary = path; temporary += ".tmp"; File file; if (text.size() > MaxUnlocksSize || !file.Create(temporary)) { @@ -261,4 +316,138 @@ bool SaveUnlocks(const std::filesystem::path& path, const std::set& unlocke return false; } +UdsRules LoadUdsRules(const std::filesystem::path& path) { + File file(path, File::Mode::Read); + if (file.IsInvalid() || file.Size() > MaxPackageSize) { + return {}; + } + const auto bytes = file.ReadWholeBuffer(); + Files files; + uint64_t size = 0; + if (!ReadFiles(bytes, files, size)) { + return {}; + } + const auto definitions = ReadJson(files, "stats_definition.json"); + const auto extraction = ReadJson(files, "stats_extraction.json"); + if (!definitions.is_object() || !definitions.contains("statDefinitionArray") || + !definitions["statDefinitionArray"].is_array() || !extraction.is_object() || + !extraction.contains("statsExtractionRuleArray") || + !extraction["statsExtractionRuleArray"].is_array()) { + return {}; + } + // Only direct unsigned latest-value stats are evaluated here. Aggregated stats + // and filtered/nested extraction require UDS state and event types we do not implement. + std::map stats; + for (const auto& definition: definitions["statDefinitionArray"]) { + if (!definition.is_object() || !definition.contains("statId") || + !definition.contains("aggregation") || definition["aggregation"] != "latest" || + !definition.contains("dataType")) { + continue; + } + const auto id = ReadUnsigned(definition["statId"]); + const auto& type = definition["dataType"]; + if (!id || (type != "uint64" && type != "uint32")) { + continue; + } + const uint64_t maximum = type == "uint32" ? UINT32_MAX : UINT64_MAX; + if ((definition.contains("minValue") && ReadUnsigned(definition["minValue"]) != 0) || + (definition.contains("maxValue") && ReadUnsigned(definition["maxValue"]) != maximum)) { + continue; + } + stats.emplace(*id, type == "uint32"); + } + UdsRules events; + for (const auto& rule: extraction["statsExtractionRuleArray"]) { + if (!rule.is_object() || !rule.contains("condition") || !rule.contains("action")) { + continue; + } + const auto& condition = rule["condition"]; + const auto& action = rule["action"]; + if (!condition.is_object() || condition.size() != 1 || !condition.contains("eventName") || + !condition["eventName"].is_string() || !action.is_object() || + !action.contains("input") || !action["input"].is_string() || + !action.contains("output") || !action["output"].is_object() || + !action["output"].contains("statId")) { + continue; + } + const auto id = ReadUnsigned(action["output"]["statId"]); + const auto& input = action["input"].get_ref(); + if (!id || !stats.contains(*id) || !input.starts_with("$.") || input.size() == 2 || + input.find_first_not_of( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_", 2) != + std::string::npos) { + continue; + } + events[condition["eventName"].get()].push_back( + {*id, input.substr(2), stats.at(*id)}); + } + return events; +} + +std::filesystem::path UdsPackagePath(uint32_t service_label) { + return std::filesystem::path("sce_sys/uds") / fmt::format("uds{:02}.ucp", service_label); +} + +std::vector FindUdsTrophies(const Package& package, std::string_view event_name, + const std::map& properties) { + const auto rules = package.event_rules.find(std::string(event_name)); + if (rules == package.event_rules.end()) { + return {}; + } + std::vector ids; + for (const auto& [id, trophy]: package.trophies) { + if (trophy.grade == 1 || !trophy.uds_stat_id || !trophy.target) { + continue; + } + for (const auto& rule: rules->second) { + const auto value = properties.find(rule.input); + if (rule.stat_id != *trophy.uds_stat_id || value == properties.end()) { + continue; + } + std::optional number; + if (rule.is_uint32) { + if (const auto* input = std::get_if(&value->second)) number = *input; + } else if (const auto* input = std::get_if(&value->second)) { + number = *input; + } + if (!number) continue; + bool matched = false; + switch (trophy.comparison) { + case Comparison::GreaterEqual: matched = *number >= *trophy.target; break; + case Comparison::Greater: matched = *number > *trophy.target; break; + case Comparison::LessEqual: matched = *number <= *trophy.target; break; + case Comparison::Less: matched = *number < *trophy.target; break; + case Comparison::None: break; + } + if (matched) { + ids.push_back(id); + break; + } + } + } + return ids; +} + +uint32_t Progress::Percentage() const { + const auto points = total_grade[2] * 6 + total_grade[3] * 2 + total_grade[4]; + const auto earned_points = earned_grade[2] * 6 + earned_grade[3] * 2 + earned_grade[4]; + return points == 0 ? 0 : earned_points * 100 / points; +} + +Progress GetProgress(const Package& package, const UnlockData& unlocks, std::optional group) { + Progress progress; + for (const auto& [id, trophy]: package.trophies) { + if (group && trophy.group_id != *group) { + continue; + } + ++progress.total; + ++progress.total_grade[trophy.grade]; + if (unlocks.unlocked.contains(id)) { + ++progress.earned; + ++progress.earned_grade[trophy.grade]; + } + } + return progress; +} + } // namespace Common::Trophies diff --git a/src/common/trophies.h b/src/common/trophies.h index 7be1ed72..dde85ed5 100644 --- a/src/common/trophies.h +++ b/src/common/trophies.h @@ -1,6 +1,7 @@ #ifndef KYTY_COMMON_TROPHIES_H_ #define KYTY_COMMON_TROPHIES_H_ +#include #include #include #include @@ -9,11 +10,15 @@ #include #include #include +#include #include namespace Common::Trophies { -inline constexpr char PackageDirectory[] = "sce_sys/trophy2"; +inline constexpr char PackageDirectory[] = "sce_sys/trophy2"; +inline constexpr uint64_t UnixEpochTick = 62135596800000000ULL; + +enum class Comparison { None, GreaterEqual, Greater, LessEqual, Less }; struct Trophy { int id = 0; @@ -21,6 +26,9 @@ struct Trophy { int platinum_id = -1; int grade = 0; std::optional target; + std::optional uds_stat_id; + Comparison comparison = Comparison::None; + bool progressive = false; std::string name; std::string description; std::string reward; @@ -29,19 +37,50 @@ struct Trophy { bool has_reward = false; }; +struct UdsRule { + uint64_t stat_id; + std::string input; + bool is_uint32; +}; + +using UdsRules = std::map>; +using UdsInteger = std::variant; + struct Package { std::string title; std::map groups; std::map trophies; + UdsRules event_rules; }; +struct UnlockData { + std::set unlocked; + std::map timestamps; +}; + +struct Progress { + std::array total_grade {}; + std::array earned_grade {}; + uint32_t total = 0; + uint32_t earned = 0; + + [[nodiscard]] uint32_t Percentage() const; +}; + +[[nodiscard]] Progress GetProgress(const Package& package, const UnlockData& unlocks, + std::optional group = std::nullopt); + [[nodiscard]] Package LoadPackage(const std::filesystem::path& path, int console_language); [[nodiscard]] std::filesystem::path PackagePath(uint32_t service_label); +[[nodiscard]] std::filesystem::path UdsPackagePath(uint32_t service_label); +[[nodiscard]] UdsRules LoadUdsRules(const std::filesystem::path& path); +[[nodiscard]] std::vector FindUdsTrophies(const Package& package, std::string_view event_name, + const std::map& properties); [[nodiscard]] std::filesystem::path UnlocksPath(const std::filesystem::path& root, std::string_view title_id, int user_id, uint32_t service_label); -[[nodiscard]] std::set LoadUnlocks(const std::filesystem::path& path); -[[nodiscard]] bool SaveUnlocks(const std::filesystem::path& path, const std::set& unlocked); +[[nodiscard]] UnlockData LoadUnlockData(const std::filesystem::path& path); +[[nodiscard]] bool SaveUnlockData(const std::filesystem::path& path, const UnlockData& unlocks); } // namespace Common::Trophies diff --git a/src/graphics/presentation/systemOverlay.cpp b/src/graphics/presentation/systemOverlay.cpp index ab4863b8..22076f21 100644 --- a/src/graphics/presentation/systemOverlay.cpp +++ b/src/graphics/presentation/systemOverlay.cpp @@ -1100,56 +1100,68 @@ struct SystemOverlay::Impl { static_cast(frame_extent.height) / 1080.0f), 0.65f, 1.25f); const float width = std::max( - 0.0f, std::min(440.0f * screen_scale, static_cast(frame_extent.width) - 32.0f)); + 0.0f, std::min(520.0f * screen_scale, static_cast(frame_extent.width) - 32.0f)); const float height = 104.0f * screen_scale; + const float rounding = 14.0f * screen_scale; const float fade_in = std::clamp(age / 0.22f, 0.0f, 1.0f); const float fade_out = std::clamp((TOAST_LIFETIME - age) / 0.4f, 0.0f, 1.0f); const float alpha = std::min(fade_in, fade_out); const float slide = (1.0f - fade_in) * (width + 24.0f); - const ImVec2 top_left {static_cast(frame_extent.width) - 24.0f - width + slide, + const ImVec2 top_left {static_cast(frame_extent.width) - 32.0f - width + slide, 32.0f}; const ImVec2 bottom_right {top_left.x + width, top_left.y + height}; auto* draw = ImGui::GetForegroundDrawList(); - draw->AddRectFilled({top_left.x + 5.0f, top_left.y + 6.0f}, - {bottom_right.x + 5.0f, bottom_right.y + 6.0f}, - IM_COL32(0, 0, 0, static_cast(95.0f * alpha)), 10.0f); - draw->AddRectFilled(top_left, bottom_right, - IM_COL32(18, 21, 29, static_cast(242.0f * alpha)), 10.0f); - draw->AddRect(top_left, bottom_right, - IM_COL32(190, 154, 78, static_cast(200.0f * alpha)), 10.0f, - ImDrawFlags_RoundCornersAll, 1.5f); + const auto fade = [alpha](int r, int g, int b, int a) { + return IM_COL32(r, g, b, static_cast(static_cast(a) * alpha)); + }; + draw->AddRectFilled({top_left.x + 3.0f, top_left.y + 5.0f}, + {bottom_right.x + 3.0f, bottom_right.y + 5.0f}, fade(0, 0, 0, 90), + rounding); + draw->AddRectFilled(top_left, bottom_right, fade(24, 26, 32, 235), rounding); + draw->AddRect(top_left, bottom_right, fade(255, 255, 255, 28), rounding, + ImDrawFlags_RoundCornersAll, 1.0f); + const float pad = 10.0f * screen_scale; + const float icon_size = height - 2.0f * pad; + float text_left = top_left.x + 20.0f * screen_scale; if (trophy_notification_image != nullptr) { - constexpr float ICON_SIZE = 88.0f; - const ImVec2 icon_min {top_left.x + 8.0f * screen_scale, - top_left.y + (height - ICON_SIZE * screen_scale) * 0.5f}; - const ImVec2 icon_max {icon_min.x + ICON_SIZE * screen_scale, - icon_min.y + ICON_SIZE * screen_scale}; - draw->AddImage(trophy_notification_image->GetTexRef(), icon_min, icon_max, {0, 0}, - {1, 1}, IM_COL32(255, 255, 255, static_cast(255.0f * alpha))); + const ImVec2 icon_min {top_left.x + pad, top_left.y + pad}; + const ImVec2 icon_max {icon_min.x + icon_size, icon_min.y + icon_size}; + draw->AddImageRounded(trophy_notification_image->GetTexRef(), icon_min, icon_max, + {0, 0}, {1, 1}, fade(255, 255, 255, 255), 8.0f * screen_scale); + draw->AddRect(icon_min, icon_max, fade(255, 255, 255, 40), 8.0f * screen_scale, + ImDrawFlags_RoundCornersAll, 1.0f); + text_left = icon_max.x + 18.0f * screen_scale; } - const ImVec2 text_position {top_left.x + - (trophy_notification_image ? 112.0f : 18.0f) * screen_scale, - top_left.y + 18.0f * screen_scale}; - draw->PushClipRect(top_left, {bottom_right.x - 12.0f * screen_scale, bottom_right.y}, true); - draw->AddText(nullptr, 13.0f * screen_scale, text_position, - IM_COL32(232, 203, 139, static_cast(255.0f * alpha)), "Trophy earned"); - draw->AddText(nullptr, 21.0f * screen_scale, - {text_position.x, text_position.y + 22.0f * screen_scale}, - IM_COL32(255, 255, 255, static_cast(255.0f * alpha)), - trophy_notification->name.c_str()); - constexpr std::array GRADE_LABELS {"Platinum", "Gold", "Silver", "Bronze"}; - constexpr std::array GRADE_COLORS { - IM_COL32(161, 225, 245, 255), IM_COL32(255, 207, 82, 255), IM_COL32(211, 220, 232, 255), - IM_COL32(211, 142, 91, 255)}; - const int grade = trophy_notification->grade; - if (grade > 0 && grade <= static_cast(GRADE_LABELS.size())) { - draw->AddText(nullptr, 13.0f * screen_scale, - {text_position.x, text_position.y + 50.0f * screen_scale}, - ImGui::GetColorU32(GRADE_COLORS[grade - 1], alpha), - GRADE_LABELS[grade - 1]); + constexpr std::array GRADE_COLORS {IM_COL32(161, 225, 245, 255), IM_COL32(255, 207, 82, 255), + IM_COL32(211, 220, 232, 255), + IM_COL32(211, 142, 91, 255)}; + const int grade = trophy_notification->grade; + const float title_y = top_left.y + height * 0.5f - 25.0f * screen_scale; + float title_x = text_left; + if (grade > 0 && grade <= static_cast(GRADE_COLORS.size())) { + // Small cup glyph tinted with the trophy grade. + const ImU32 cup = ImGui::GetColorU32(GRADE_COLORS[grade - 1], alpha); + const float u = screen_scale; + const float cx = text_left + 9.0f * u; + const float cy = title_y + 15.0f * u; + draw->AddRectFilled({cx - 8.0f * u, cy - 11.0f * u}, {cx + 8.0f * u, cy + 2.0f * u}, + cup, 2.0f * u, ImDrawFlags_RoundCornersBottom); + draw->AddRect({cx - 11.0f * u, cy - 9.0f * u}, {cx - 6.0f * u, cy - 3.0f * u}, cup, + 2.0f * u, ImDrawFlags_RoundCornersLeft, 1.8f * u); + draw->AddRect({cx + 6.0f * u, cy - 9.0f * u}, {cx + 11.0f * u, cy - 3.0f * u}, cup, + 2.0f * u, ImDrawFlags_RoundCornersRight, 1.8f * u); + draw->AddRectFilled({cx - 1.5f * u, cy + 2.0f * u}, {cx + 1.5f * u, cy + 7.0f * u}, cup); + draw->AddRectFilled({cx - 5.0f * u, cy + 7.0f * u}, {cx + 5.0f * u, cy + 10.0f * u}, cup, + 1.5f * u); + title_x += 30.0f * u; } + draw->PushClipRect(top_left, {bottom_right.x - 16.0f * screen_scale, bottom_right.y}, true); + draw->AddText(nullptr, 26.0f * screen_scale, {title_x, title_y}, + fade(255, 255, 255, 255), trophy_notification->name.c_str()); + draw->AddText(nullptr, 20.0f * screen_scale, {text_left, title_y + 38.0f * screen_scale}, + fade(176, 180, 192, 255), "Trophy earned!"); draw->PopClipRect(); } diff --git a/src/launcher/forms/configuration_list_widget.ui b/src/launcher/forms/configuration_list_widget.ui index 46c193c5..c49cf683 100644 --- a/src/launcher/forms/configuration_list_widget.ui +++ b/src/launcher/forms/configuration_list_widget.ui @@ -169,6 +169,28 @@ + + + + + 36 + 36 + + + + View saved trophies + + + + 32 + 32 + + + + true + + + diff --git a/src/launcher/icons/refresh.svg b/src/launcher/icons/refresh.svg index 33f894d2..3cf1fb35 100644 --- a/src/launcher/icons/refresh.svg +++ b/src/launcher/icons/refresh.svg @@ -1 +1,6 @@ - + + + + + + diff --git a/src/launcher/icons/trophy.svg b/src/launcher/icons/trophy.svg new file mode 100644 index 00000000..1a211a6d --- /dev/null +++ b/src/launcher/icons/trophy.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/launcher/include/configurationListWidget.h b/src/launcher/include/configurationListWidget.h index 097fa8e5..de0bee95 100644 --- a/src/launcher/include/configurationListWidget.h +++ b/src/launcher/include/configurationListWidget.h @@ -43,6 +43,7 @@ public: bool EnsureGameDirectory(); void ScanGameDirectory(); void ViewTrophies(); + void ViewTrophyOverview(); signals: diff --git a/src/launcher/include/trophyViewerDialog.h b/src/launcher/include/trophyViewerDialog.h index 589cf228..d667036f 100644 --- a/src/launcher/include/trophyViewerDialog.h +++ b/src/launcher/include/trophyViewerDialog.h @@ -3,6 +3,8 @@ #include +#include + class QTabWidget; class QWidget; @@ -14,6 +16,8 @@ public: static bool HasTrophyData(const Configuration* info); static void ShowForGame(const Configuration* info, const QString& runtime_directory, QWidget* parent); + static void ShowOverview(const std::vector& games, + const QString& runtime_directory, QWidget* parent); private: bool LoadGame(const Configuration& info, const QString& runtime_directory, QString& error); diff --git a/src/launcher/resources.qrc b/src/launcher/resources.qrc index b0b05625..86d0c986 100644 --- a/src/launcher/resources.qrc +++ b/src/launcher/resources.qrc @@ -5,6 +5,8 @@ icons/input-mapping.svg icons/refresh.svg icons/remove-configuration.svg + icons/trophy.svg + ../../assets/images/hidden-trophy.png styles/launcher.qss diff --git a/src/launcher/src/configurationListWidget.cpp b/src/launcher/src/configurationListWidget.cpp index a2665cde..05555835 100644 --- a/src/launcher/src/configurationListWidget.cpp +++ b/src/launcher/src/configurationListWidget.cpp @@ -56,6 +56,7 @@ #endif #include +#include #include "ui_configuration_list_widget.h" @@ -241,6 +242,8 @@ ConfigurationListWidget::ConfigurationListWidget(QWidget* parent) &ConfigurationListWidget::edit_configuration); connect(m_ui->delete_button, &QToolButton::clicked, this, &ConfigurationListWidget::delete_configuartion); + connect(m_ui->trophy_overview_button, &QToolButton::clicked, this, + &ConfigurationListWidget::ViewTrophyOverview); connect(m_ui->cfgs_list, &QTreeWidget::currentItemChanged, this, &ConfigurationListWidget::SelectItem); connect(m_ui->cfgs_list, &QTreeWidget::itemDoubleClicked, this, @@ -293,6 +296,7 @@ void ConfigurationListWidget::UpdateToolbarIcons() { set_icon(m_ui->input_mapping_button, QStringLiteral(":/icons/input-mapping.svg")); set_icon(m_ui->edit_button, QStringLiteral(":/icons/edit-configuration.svg")); set_icon(m_ui->delete_button, QStringLiteral(":/icons/remove-configuration.svg")); + set_icon(m_ui->trophy_overview_button, QStringLiteral(":/icons/trophy.svg")); } void ConfigurationListWidget::WriteSettings() { @@ -840,6 +844,20 @@ void ConfigurationListWidget::ViewTrophies() { TrophyViewerDialog::ShowForGame(config.get(), m_runtime_directory, this); } +void ConfigurationListWidget::ViewTrophyOverview() { + std::vector> configurations; + std::vector games; + configurations.reserve(static_cast(m_ui->cfgs_list->topLevelItemCount())); + games.reserve(static_cast(m_ui->cfgs_list->topLevelItemCount())); + for (int index = 0; index < m_ui->cfgs_list->topLevelItemCount(); ++index) { + const auto* item = + static_cast(m_ui->cfgs_list->topLevelItem(index)); + configurations.push_back(CreateConfiguration(*item)); + games.push_back(configurations.back().get()); + } + TrophyViewerDialog::ShowOverview(games, m_runtime_directory, this); +} + void ConfigurationListWidget::open_game_folder() { auto* item = static_cast(m_ui->cfgs_list->currentItem()); if (item == nullptr) { diff --git a/src/launcher/src/trophyViewerDialog.cpp b/src/launcher/src/trophyViewerDialog.cpp index 886a6811..748cfa71 100644 --- a/src/launcher/src/trophyViewerDialog.cpp +++ b/src/launcher/src/trophyViewerDialog.cpp @@ -6,18 +6,23 @@ #include "gameContent.h" #include +#include #include #include -#include -#include -#include +#include #include +#include +#include #include +#include +#include #include +#include #include +#include #include +#include #include -#include #include namespace { @@ -49,26 +54,6 @@ static QString GradeToText(int grade) { } } -static void PrepareTable(QTableWidget* table) { - table->setHorizontalHeaderLabels({QObject::tr("Unlocked"), QObject::tr("Trophy"), - QObject::tr("Name"), QObject::tr("Description")}); - table->setAlternatingRowColors(true); - table->setEditTriggers(QAbstractItemView::NoEditTriggers); - table->setSelectionBehavior(QAbstractItemView::SelectRows); - table->setSelectionMode(QAbstractItemView::SingleSelection); - table->setIconSize(QSize(96, 96)); - table->setShowGrid(false); - table->setWordWrap(true); - table->verticalHeader()->setVisible(false); - table->verticalHeader()->setDefaultSectionSize(112); - table->horizontalHeader()->setStretchLastSection(true); - table->horizontalHeader()->setHighlightSections(false); - table->setColumnWidth(0, 100); - table->setColumnWidth(1, 132); - table->setColumnWidth(2, 260); - table->setColumnWidth(3, 480); -} - static QString TrophyTooltip(const Common::Trophies::Trophy& trophy) { QStringList lines {QString::fromStdString(trophy.name), QString::fromStdString(trophy.description), @@ -102,6 +87,225 @@ bool TrophyViewerDialog::HasTrophyData(const Configuration* info) { return !FindTrophyFiles(info).isEmpty(); } +namespace { + +using Common::Trophies::Progress; + +Common::Trophies::UnlockData LoadUnlocks(const Configuration& info, + const QString& runtime_directory, const QString& file) { + const auto label = TrophyFilePattern.match(QFileInfo(file).fileName()).captured(1).toUInt(); + return Common::Trophies::LoadUnlockData(Common::Trophies::UnlocksPath( + GameContent::ToPath(runtime_directory), info.title_id.toStdString(), info.user_id, label)); +} + +Progress GetGameProgress(const Configuration* info, const QString& runtime_directory) { + Progress result; + if (info == nullptr) { + return result; + } + const auto reader = Common::OpenArchive(GameContent::ToPath(info->basedir)); + for (const auto& file: FindTrophyFiles(info)) { + const auto package = + Common::Trophies::LoadPackage(GameContent::ToPath(file), info->console_language); + const auto progress = + Common::Trophies::GetProgress(package, LoadUnlocks(*info, runtime_directory, file)); + result.total += progress.total; + result.earned += progress.earned; + for (size_t grade = 1; grade < result.total_grade.size(); ++grade) { + result.total_grade[grade] += progress.total_grade[grade]; + result.earned_grade[grade] += progress.earned_grade[grade]; + } + } + return result; +} + +QPixmap MakeCupIcon(const QColor& color, int size) { + QPixmap pixmap(size, size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing); + const double u = size / 20.0; + painter.setPen(Qt::NoPen); + painter.setBrush(color); + QPainterPath cup; + cup.moveTo(5 * u, 2 * u); + cup.lineTo(15 * u, 2 * u); + cup.lineTo(14 * u, 9 * u); + cup.quadTo(10 * u, 13 * u, 6 * u, 9 * u); + cup.closeSubpath(); + painter.drawPath(cup); + painter.setPen(QPen(color, 1.6 * u)); + painter.setBrush(Qt::NoBrush); + painter.drawArc(QRectF(1.5 * u, 3 * u, 5 * u, 5 * u), 90 * 16, 180 * 16); + painter.drawArc(QRectF(13.5 * u, 3 * u, 5 * u, 5 * u), 90 * 16, -180 * 16); + painter.drawLine(QPointF(10 * u, 12 * u), QPointF(10 * u, 16 * u)); + painter.setPen(Qt::NoPen); + painter.setBrush(color); + painter.drawRoundedRect(QRectF(6 * u, 16 * u, 8 * u, 2.5 * u), u, u); + return pixmap; +} + +QColor GradeColor(int grade) { + switch (grade) { + case 1: return QColor(0x7fc8ff); + case 2: return QColor(0xf5c542); + case 3: return QColor(0xc4cad2); + default: return QColor(0xcd7f4f); + } +} + +QWidget* MakeGradeCount(QWidget* parent, int grade, int value, int icon_size, int font_size) { + auto* box = new QWidget(parent); + auto* row = new QHBoxLayout(box); + row->setContentsMargins(0, 0, 0, 0); + auto* icon = new QLabel(box); + icon->setPixmap(MakeCupIcon(GradeColor(grade), icon_size)); + auto* text = new QLabel(QString::number(value), box); + text->setStyleSheet(QStringLiteral("font-size: %1px;").arg(font_size)); + row->addWidget(icon); + row->addWidget(text); + return box; +} + +QPixmap LoadGameArt(const Configuration* game, int height) { + QPixmap art; + const auto data = GameContent::ReadFile(game->basedir, QStringLiteral("sce_sys/icon0.png"), + GameContent::MaxImageSize); + if (!data.isEmpty() && art.loadFromData(data)) { + return art.scaledToHeight(height, Qt::SmoothTransformation); + } + return {}; +} + +} // namespace + +void TrophyViewerDialog::ShowOverview(const std::vector& games, + const QString& runtime_directory, QWidget* parent) { + QDialog dialog(parent); + dialog.setWindowTitle(QObject::tr("Trophies")); + dialog.resize(820, 640); + dialog.setStyleSheet(QStringLiteral( + "QDialog { background: palette(window); }" + "QLabel { color: palette(text); }" + "QListWidget { background: transparent; border: none; outline: none; }" + "QListWidget::item { border-bottom: 1px solid palette(mid); padding: 4px; }" + "QListWidget::item:selected, QListWidget::item:hover { background: palette(midlight); " + "border: 1px solid palette(highlight); }" + "QProgressBar { background: palette(mid); border: none; height: 4px; }" + "QProgressBar::chunk { background: palette(highlight); }")); + auto* layout = new QVBoxLayout(&dialog); + + struct GameProgress { + const Configuration* game; + Progress progress; + }; + std::vector trophy_games; + Progress totals; + for (const auto* game: games) { + const auto progress = GetGameProgress(game, runtime_directory); + if (progress.total == 0) { + continue; + } + trophy_games.push_back({game, progress}); + totals.earned += progress.earned; + for (size_t grade = 1; grade < totals.earned_grade.size(); ++grade) { + totals.earned_grade[grade] += progress.earned_grade[grade]; + } + } + + auto* header = new QHBoxLayout; + auto* title = new QLabel(QObject::tr("Trophies"), &dialog); + title->setStyleSheet(QStringLiteral("font-size: 30px;")); + header->addWidget(title); + header->addStretch(1); + auto* total = new QLabel(QObject::tr("Total %1").arg(totals.earned), &dialog); + total->setAlignment(Qt::AlignCenter); + total->setStyleSheet(QStringLiteral("font-size: 16px;")); + header->addWidget(total); + for (int grade = 1; grade <= 4; ++grade) { + header->addWidget(MakeGradeCount(&dialog, grade, totals.earned_grade[grade], 32, 18)); + } + layout->addLayout(header); + + auto* list = new QListWidget(&dialog); + list->setSelectionMode(QAbstractItemView::SingleSelection); + list->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + layout->addWidget(list, 1); + + for (size_t index = 0; index < trophy_games.size(); ++index) { + const auto& [game, progress] = trophy_games[index]; + auto* item = new QListWidgetItem(list); + item->setSizeHint(QSize(760, 112)); + item->setData(Qt::UserRole, static_cast(index)); + + auto* row_widget = new QWidget(list); + row_widget->setAttribute(Qt::WA_TransparentForMouseEvents); + auto* row = new QHBoxLayout(row_widget); + auto* art = new QLabel(row_widget); + art->setFixedSize(80, 80); + art->setAlignment(Qt::AlignCenter); + const auto pixmap = LoadGameArt(game, 80); + if (!pixmap.isNull()) { + art->setPixmap(pixmap); + } else { + art->setStyleSheet(QStringLiteral("background: palette(mid);")); + } + row->addWidget(art); + const auto name = !game->name.isEmpty() ? game->name : game->title_id; + auto* text = new QLabel(QStringLiteral("%1\n%2").arg(name, game->title_id), row_widget); + text->setStyleSheet(QStringLiteral("font-size: 18px;")); + row->addWidget(text, 1); + + auto* percent_box = new QVBoxLayout; + percent_box->setSpacing(2); + percent_box->setContentsMargins(0, 0, 0, 0); + auto* percent = new QLabel(QStringLiteral("%1%").arg(progress.Percentage()), row_widget); + percent->setAlignment(Qt::AlignRight); + percent->setStyleSheet(QStringLiteral("font-size: 24px;")); + percent->setMinimumHeight(34); + auto* earned = new QLabel( + QObject::tr("Earned %1/%2").arg(progress.earned).arg(progress.total), row_widget); + earned->setAlignment(Qt::AlignRight); + earned->setMinimumHeight(18); + auto* bar = new QProgressBar(row_widget); + bar->setRange(0, 100); + bar->setValue(progress.Percentage()); + bar->setTextVisible(false); + bar->setFixedWidth(130); + percent_box->addWidget(percent); + percent_box->addWidget(earned); + percent_box->addWidget(bar); + row->addLayout(percent_box); + for (int grade = 1; grade <= 4; ++grade) { + if (grade == 1 && progress.total_grade[1] == 0) { + continue; + } + row->addWidget(MakeGradeCount(row_widget, grade, progress.earned_grade[grade], 24, 18)); + } + list->setItemWidget(item, row_widget); + } + if (trophy_games.empty()) { + auto* empty = + new QLabel(QObject::tr("No games with trophies found in the game folders."), &dialog); + empty->setAlignment(Qt::AlignCenter); + layout->insertWidget(1, empty); + list->hide(); + } + + connect(list, &QListWidget::itemClicked, &dialog, + [&dialog, &trophy_games, &runtime_directory](QListWidgetItem* item) { + const auto index = item->data(Qt::UserRole).toInt(); + if (index >= 0 && static_cast(index) < trophy_games.size()) { + ShowForGame(trophy_games[static_cast(index)].game, runtime_directory, + &dialog); + } + }); + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close, &dialog); + connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + layout->addWidget(buttons); + dialog.exec(); +} + void TrophyViewerDialog::ShowForGame(const Configuration* info, const QString& runtime_directory, QWidget* parent) { if (info == nullptr) { @@ -139,51 +343,131 @@ bool TrophyViewerDialog::LoadGame(const Configuration& info, const QString& runt errors.append(tr("Could not read trophy package %1.").arg(QFileInfo(file).fileName())); continue; } - const auto label = TrophyFilePattern.match(QFileInfo(file).fileName()).captured(1).toUInt(); - const auto unlocked = Common::Trophies::LoadUnlocks( - Common::Trophies::UnlocksPath(GameContent::ToPath(runtime_directory), - info.title_id.toStdString(), info.user_id, label)); - auto* table = new QTableWidget(static_cast(package.trophies.size()), 4, m_tabs); - PrepareTable(table); - int row_index = 0; - for (const auto& [id, trophy]: package.trophies) { - const bool trophy_unlocked = unlocked.contains(id); - auto* status = new QTableWidgetItem(trophy_unlocked ? tr("UNLOCKED") : tr("LOCKED")); - status->setTextAlignment(Qt::AlignCenter); - auto status_font = status->font(); - status_font.setBold(true); - status->setFont(status_font); - table->setItem(row_index, 0, status); + const auto unlocks = LoadUnlocks(info, runtime_directory, file); + const auto counts = Common::Trophies::GetProgress(package, unlocks); + auto* page = new QWidget(m_tabs); + auto* page_layout = new QVBoxLayout(page); - auto* icon_item = new QTableWidgetItem; - icon_item->setTextAlignment(Qt::AlignCenter); - QPixmap icon; - if (icon.loadFromData(reinterpret_cast(trophy.icon_png.data()), - static_cast(trophy.icon_png.size()))) { - if (!trophy_unlocked) { - icon = QPixmap::fromImage( - icon.toImage().convertToFormat(QImage::Format_Grayscale8)); - } - icon_item->setIcon(QIcon(icon)); - } - table->setItem(row_index, 1, icon_item); - - const auto name_text = trophy.name.empty() ? (trophy.hidden ? tr("Hidden Trophy") - : tr("Trophy %1").arg(id)) - : QString::fromStdString(trophy.name); - auto* name = new QTableWidgetItem(name_text); - auto font = name->font(); - font.setBold(true); - name->setFont(font); - name->setToolTip(TrophyTooltip(trophy)); - auto* detail = new QTableWidgetItem(QString::fromStdString(trophy.description)); - detail->setToolTip(name->toolTip()); - table->setItem(row_index, 2, name); - table->setItem(row_index, 3, detail); - ++row_index; + auto* header = new QHBoxLayout; + auto* art = new QLabel(page); + if (const auto pixmap = LoadGameArt(&info, 56); !pixmap.isNull()) { + art->setPixmap(pixmap); + header->addWidget(art); } - m_tabs->addTab(table, tr("Trophy %1").arg(label)); + auto* title = new QLabel(QString::fromStdString(package.title), page); + title->setStyleSheet(QStringLiteral("font-size: 28px;")); + header->addWidget(title, 1); + const auto make_stat = [page](const QString& label, const QString& value) { + auto* box = new QVBoxLayout; + auto* name = new QLabel(label, page); + auto* number = new QLabel(value, page); + name->setStyleSheet(QStringLiteral("font-size: 14px;")); + number->setStyleSheet(QStringLiteral("font-size: 22px;")); + box->addWidget(name); + box->addWidget(number); + return box; + }; + header->addLayout( + make_stat(tr("Progress"), QStringLiteral("%1%").arg(counts.Percentage()))); + header->addLayout( + make_stat(tr("Earned"), QStringLiteral("%1/%2").arg(counts.earned).arg(counts.total))); + for (int grade = 1; grade <= 4; ++grade) { + if (counts.total_grade[grade] > 0) { + header->addWidget(MakeGradeCount(page, grade, counts.earned_grade[grade], 30, 20)); + } + } + page_layout->addLayout(header); + auto* progress = new QProgressBar(page); + progress->setRange(0, 100); + progress->setValue(counts.Percentage()); + progress->setTextVisible(false); + page_layout->addWidget(progress); + auto* count_label = new QLabel(tr("All trophies: %1").arg(counts.total), page); + page_layout->addWidget(count_label); + + auto* list = new QListWidget(page); + list->setSelectionMode(QAbstractItemView::NoSelection); + list->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + list->setSpacing(4); + page_layout->addWidget(list, 1); + for (const auto& [id, trophy]: package.trophies) { + const bool trophy_unlocked = unlocks.unlocked.contains(id); + const auto hidden_locked = trophy.hidden && !trophy_unlocked; + auto* item = new QListWidgetItem(list); + item->setSizeHint(QSize(900, 96)); + + auto* card = new QWidget(list); + card->setStyleSheet( + QStringLiteral("QWidget#card { background: palette(base); border-radius: 12px; }")); + card->setObjectName(QStringLiteral("card")); + auto* row = new QHBoxLayout(card); + auto* icon = new QLabel(card); + icon->setFixedSize(76, 76); + icon->setAlignment(Qt::AlignCenter); + QPixmap pixmap; + if (hidden_locked) { + pixmap = QPixmap(QStringLiteral(":/icons/hidden-trophy.png")); + } else if (pixmap.loadFromData(reinterpret_cast(trophy.icon_png.data()), + static_cast(trophy.icon_png.size())) && + !trophy_unlocked) { + pixmap = + QPixmap::fromImage(pixmap.toImage().convertToFormat(QImage::Format_Grayscale8)); + } + if (!pixmap.isNull()) { + icon->setPixmap( + pixmap.scaled(76, 76, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + } + row->addWidget(icon); + + auto* left = new QVBoxLayout; + const auto name_text = hidden_locked ? tr("Hidden trophy") + : trophy.name.empty() ? tr("Trophy %1").arg(id) + : QString::fromStdString(trophy.name); + auto* name = new QLabel(name_text, card); + name->setStyleSheet(QStringLiteral("font-size: 20px; font-weight: bold;")); + left->addWidget(name); + if (!hidden_locked) { + auto* grade_row = new QHBoxLayout; + auto* cup = new QLabel(card); + cup->setPixmap(MakeCupIcon(GradeColor(trophy.grade), 18)); + auto* grade_text = new QLabel(GradeToText(trophy.grade), card); + grade_text->setStyleSheet(QStringLiteral("font-size: 15px;")); + grade_row->addWidget(cup); + grade_row->addWidget(grade_text); + grade_row->addStretch(1); + left->addLayout(grade_row); + } + + row->addLayout(left, 2); + + auto* right = new QVBoxLayout; + QString date_text; + if (const auto found = unlocks.timestamps.find(id); found != unlocks.timestamps.end()) { + date_text = QDateTime::fromMSecsSinceEpoch( + static_cast( + (found->second - Common::Trophies::UnixEpochTick) / 1000)) + .toLocalTime() + .toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")); + } else if (trophy_unlocked) { + date_text = tr("Earned"); + } + auto* date = new QLabel(date_text, card); + date->setAlignment(Qt::AlignRight); + date->setStyleSheet(QStringLiteral("font-size: 15px;")); + auto* detail = new QLabel( + hidden_locked ? QString {} : QString::fromStdString(trophy.description), card); + detail->setWordWrap(true); + detail->setAlignment(Qt::AlignLeft | Qt::AlignTop); + detail->setStyleSheet(QStringLiteral("font-size: 17px;")); + right->addWidget(date); + right->addWidget(detail, 1); + row->addLayout(right, 3); + card->setToolTip(hidden_locked ? name_text : TrophyTooltip(trophy)); + list->setItemWidget(item, card); + } + m_tabs->addTab(page, QString::fromStdString(package.title)); } + m_tabs->tabBar()->setVisible(m_tabs->count() > 1); if (m_tabs->count() == 0) { error = errors.join(QLatin1Char('\n')); return false; diff --git a/src/launcher/styles/launcher.qss b/src/launcher/styles/launcher.qss index 007bc58e..07e7f869 100644 --- a/src/launcher/styles/launcher.qss +++ b/src/launcher/styles/launcher.qss @@ -39,9 +39,11 @@ QLineEdit#search_line_edit:focus { border-color: palette(highlight); } +QToolButton#refresh_button, QToolButton#global_settings_button, QToolButton#input_mapping_button, QToolButton#edit_button, +QToolButton#trophy_overview_button, QToolButton#delete_button { background: transparent; border: 1px solid transparent; @@ -49,17 +51,21 @@ QToolButton#delete_button { padding: 3px; } +QToolButton#refresh_button:hover, QToolButton#global_settings_button:hover, QToolButton#input_mapping_button:hover, QToolButton#edit_button:hover, +QToolButton#trophy_overview_button:hover, QToolButton#delete_button:hover { background: palette(button); border-color: palette(mid); } +QToolButton#refresh_button:pressed, QToolButton#global_settings_button:pressed, QToolButton#input_mapping_button:pressed, QToolButton#edit_button:pressed, +QToolButton#trophy_overview_button:pressed, QToolButton#delete_button:pressed { background: palette(midlight); } diff --git a/src/libs/libTrophy.cpp b/src/libs/libTrophy.cpp index d06441c9..4c2fba23 100644 --- a/src/libs/libTrophy.cpp +++ b/src/libs/libTrophy.cpp @@ -7,13 +7,12 @@ #include "loader/systemContent.h" #include -#include +#include #include #include #include #include #include -#include #include #include @@ -104,7 +103,7 @@ using TrophyKey = std::pair; struct TrophyState { const Trophies::Package* package = nullptr; - std::set unlocked; + Trophies::UnlockData unlocks; std::filesystem::path path; }; @@ -130,20 +129,33 @@ static TrophyState* LoadTrophyState(TrophyKey key) { if (const auto it = g_states.find(key); it != g_states.end()) { return &it->second; } + std::string title_id; + Loader::SystemContentParamSfoGetString("TITLE_ID", &title_id); auto& package = g_packages[key.second]; if (package.trophies.empty()) { const auto filename = "/app0/" + Trophies::PackagePath(key.second).generic_string(); package = Trophies::LoadPackage(LibKernel::FileSystem::GetRealFilename(filename), Config::GetConsoleLanguage()); if (package.trophies.empty()) { + LOGF("[Trophy] could not load trophy package %s for %s\n", filename.c_str(), + title_id.c_str()); return nullptr; } + LOGF("[Trophy] loaded %zu trophy definitions for %s (service %u)\n", + package.trophies.size(), title_id.c_str(), key.second); + const auto uds_filename = "/app0/" + Trophies::UdsPackagePath(key.second).generic_string(); + package.event_rules = + Trophies::LoadUdsRules(LibKernel::FileSystem::GetRealFilename(uds_filename)); + if (package.event_rules.empty()) { + LOGF("[Trophy] no supported UDS extraction rules in %s\n", uds_filename.c_str()); + } else { + LOGF("[Trophy] loaded UDS extraction rules for %zu events from %s\n", + package.event_rules.size(), uds_filename.c_str()); + } } - std::string title_id; - Loader::SystemContentParamSfoGetString("TITLE_ID", &title_id); - auto path = Trophies::UnlocksPath({}, title_id, key.first, key.second); - auto unlocked = Trophies::LoadUnlocks(path); - return &g_states.emplace(key, TrophyState {&package, std::move(unlocked), std::move(path)}) + auto path = Trophies::UnlocksPath({}, title_id, key.first, key.second); + auto unlocks = Trophies::LoadUnlockData(path); + return &g_states.emplace(key, TrophyState {&package, std::move(unlocks), std::move(path)}) .first->second; } @@ -165,7 +177,7 @@ static void FillTrophyInfo(const TrophyState& state, const Trophies::Trophy& tro details->group_id = trophy.group_id; details->hidden = trophy.hidden; details->has_reward = trophy.has_reward; - if (trophy.target) { + if (trophy.progressive && trophy.target) { details->target.type = 1; details->target.value = *trophy.target; } @@ -177,8 +189,12 @@ static void FillTrophyInfo(const TrophyState& state, const Trophies::Trophy& tro if (data != nullptr) { *data = {}; data->trophy_id = trophy.id; - data->unlocked = state.unlocked.contains(trophy.id); - if (trophy.target) { + data->unlocked = state.unlocks.unlocked.contains(trophy.id); + if (const auto timestamp = state.unlocks.timestamps.find(trophy.id); + timestamp != state.unlocks.timestamps.end()) { + data->timestamp_tick = timestamp->second; + } + if (trophy.progressive && trophy.target) { data->progress.type = 1; data->progress.value = data->unlocked ? *trophy.target : 0; } @@ -188,19 +204,13 @@ static void FillTrophyInfo(const TrophyState& state, const Trophies::Trophy& tro // Game and group structures share the same count fields. template static void FillTrophyCounts(const TrophyState& state, int group, Details* details, Data* data) { - std::array total {}, unlocked {}; - for (const auto& [id, trophy]: state.package->trophies) { - if (group != -2 && trophy.group_id != group) { - continue; - } - ++total[trophy.grade]; - if (state.unlocked.contains(id)) { - ++unlocked[trophy.grade]; - } - } + const auto progress = Trophies::GetProgress( + *state.package, state.unlocks, group == -2 ? std::nullopt : std::optional(group)); + const auto& total = progress.total_grade; + const auto& unlocked = progress.earned_grade; if (details != nullptr) { *details = {}; - details->num_trophies = total[1] + total[2] + total[3] + total[4]; + details->num_trophies = progress.total; details->num_platinum = total[1]; details->num_gold = total[2]; details->num_silver = total[3]; @@ -208,14 +218,12 @@ static void FillTrophyCounts(const TrophyState& state, int group, Details* detai } if (data != nullptr) { *data = {}; - data->unlocked_trophies = unlocked[1] + unlocked[2] + unlocked[3] + unlocked[4]; + data->unlocked_trophies = progress.earned; data->unlocked_platinum = unlocked[1]; data->unlocked_gold = unlocked[2]; data->unlocked_silver = unlocked[3]; data->unlocked_bronze = unlocked[4]; - const auto points = total[2] * 6 + total[3] * 2 + total[4]; - const auto earned = unlocked[2] * 6 + unlocked[3] * 2 + unlocked[4]; - data->progress_percentage = points == 0 ? 0 : earned * 100 / points; + data->progress_percentage = progress.Percentage(); } } @@ -231,31 +239,45 @@ static void FillGroupInfo(const TrophyState& state, int group_id, const std::str } } -static void RecordTrophyUnlock(TrophyState& state, int trophy_id) { +static void RecordTrophyUnlock(TrophyState& state, int trophy_id, bool from_stat = false) { const auto found = state.package->trophies.find(trophy_id); - if (found == state.package->trophies.end() || found->second.grade == 1 || - found->second.target || !state.unlocked.insert(trophy_id).second) { + if (found == state.package->trophies.end()) { + LOGF("[Trophy] ignored unknown trophy ID %d\n", trophy_id); return; } + if (found->second.grade == 1 || (!from_stat && found->second.progressive) || + !state.unlocks.unlocked.insert(trophy_id).second) { + return; + } + const auto timestamp = + Trophies::UnixEpochTick + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + state.unlocks.timestamps.emplace(trophy_id, timestamp); std::vector earned {trophy_id}; const auto platinum_id = found->second.platinum_id; if (platinum_id >= 0 && state.package->trophies.contains(platinum_id) && std::all_of(state.package->trophies.begin(), state.package->trophies.end(), [&](const auto& entry) { return entry.second.platinum_id != platinum_id || - state.unlocked.contains(entry.first); + state.unlocks.unlocked.contains(entry.first); }) && - state.unlocked.insert(platinum_id).second) { + state.unlocks.unlocked.insert(platinum_id).second) { + state.unlocks.timestamps.emplace(platinum_id, timestamp); earned.push_back(platinum_id); } - if (!Trophies::SaveUnlocks(state.path, state.unlocked)) { + if (!Trophies::SaveUnlockData(state.path, state.unlocks)) { LOGF("[Trophy] could not save unlocks to %s\n", state.path.string().c_str()); + } else { + LOGF("[Trophy] recorded unlock %d: %s\n", trophy_id, found->second.name.c_str()); } if (Config::TrophyEnabled()) { for (const auto id: earned) { const auto& trophy = state.package->trophies.at(id); Graphics::NotifyTrophyUnlocked(trophy.name, trophy.grade, trophy.icon_png); } + } else { + LOGF("[Trophy] notification disabled by configuration\n"); } } @@ -541,11 +563,13 @@ struct NpUniversalDataSystemMemoryStat { }; struct NpUniversalDataSystemEventPropertyObject { - std::optional trophy_id; + std::map integers; - void SetTrophyId(const char* key, std::optional value) { - if (std::strcmp(key, "_trophy_id") == 0) { - trophy_id = value; + void SetInteger(const char* key, std::optional value) { + if (value) { + integers.insert_or_assign(key, *value); + } else { + integers.erase(key); } } }; @@ -647,6 +671,7 @@ static int KYTY_SYSV_ABI NpUniversalDataSystemCreateEvent( } *new_event = new NpUniversalDataSystemEvent { event_name, prop != nullptr ? *prop : NpUniversalDataSystemEventPropertyObject {}}; + LOGF("[UDS] created event: %s\n", event_name); if (prop_ptr != nullptr) { *prop_ptr = &(*new_event)->properties; } @@ -668,14 +693,26 @@ static int KYTY_SYSV_ABI NpUniversalDataSystemPostEvent(int context, int handle, return static_cast(0x80553120); // NOT_REGISTERED } const auto& uds_event = *static_cast(event); + LOGF("[UDS] posted event: %s\n", uds_event.name.c_str()); if (uds_event.name == "_UnlockTrophy") { - const auto trophy_id = uds_event.properties.trophy_id; - if (!trophy_id || *trophy_id < 0) { + const auto property = uds_event.properties.integers.find("_trophy_id"); + const auto* trophy_id = property == uds_event.properties.integers.end() + ? nullptr + : std::get_if(&property->second); + if (trophy_id == nullptr || *trophy_id < 0) { return NP_UNIVERSAL_DATA_SYSTEM_ERROR_INVALID_ARGUMENT; } if (auto* state = LibNpTrophy2::LoadTrophyState(it->second.key); state != nullptr) { LibNpTrophy2::RecordTrophyUnlock(*state, *trophy_id); } + } else if (!uds_event.properties.integers.empty()) { + if (auto* state = LibNpTrophy2::LoadTrophyState(it->second.key); state != nullptr) { + const auto ids = LibNpTrophy2::Trophies::FindUdsTrophies( + *state->package, uds_event.name, uds_event.properties.integers); + for (const auto trophy_id: ids) { + LibNpTrophy2::RecordTrophyUnlock(*state, trophy_id, true); + } + } } return 0; } @@ -809,7 +846,7 @@ static int KYTY_SYSV_ABI NpUniversalDataSystemEventPropertyObjectSetString( if (object == nullptr || key == nullptr || value == nullptr) { return NP_UNIVERSAL_DATA_SYSTEM_ERROR_INVALID_ARGUMENT; } - object->SetTrophyId(key, std::nullopt); + object->SetInteger(key, std::nullopt); return 0; } @@ -826,7 +863,7 @@ static int KYTY_SYSV_ABI NpUniversalDataSystemEventPropertyObjectSetInt32( if (object == nullptr || key == nullptr) { return NP_UNIVERSAL_DATA_SYSTEM_ERROR_INVALID_ARGUMENT; } - object->SetTrophyId(key, value); + object->SetInteger(key, value); return 0; } @@ -838,7 +875,7 @@ static int KYTY_SYSV_ABI NpUniversalDataSystemEventPropertyObjectSetUInt32( if (object == nullptr || key == nullptr) { return NP_UNIVERSAL_DATA_SYSTEM_ERROR_INVALID_ARGUMENT; } - object->SetTrophyId(key, std::nullopt); + object->SetInteger(key, value); LOGF("\t object = 0x%016" PRIx64 "\n" "\t key = %s\n" @@ -855,7 +892,7 @@ static int KYTY_SYSV_ABI NpUniversalDataSystemEventPropertyObjectSetInt64( if (object == nullptr || key == nullptr) { return NP_UNIVERSAL_DATA_SYSTEM_ERROR_INVALID_ARGUMENT; } - object->SetTrophyId(key, std::nullopt); + object->SetInteger(key, std::nullopt); LOGF("\t object = 0x%016" PRIx64 "\n" "\t key = %s\n" @@ -872,7 +909,7 @@ static int KYTY_SYSV_ABI NpUniversalDataSystemEventPropertyObjectSetUInt64( if (object == nullptr || key == nullptr) { return NP_UNIVERSAL_DATA_SYSTEM_ERROR_INVALID_ARGUMENT; } - object->SetTrophyId(key, std::nullopt); + object->SetInteger(key, value); LOGF("\t object = 0x%016" PRIx64 "\n" "\t key = %s\n" @@ -889,7 +926,7 @@ static int KYTY_SYSV_ABI NpUniversalDataSystemEventPropertyObjectSetFloat32( if (object == nullptr || key == nullptr) { return NP_UNIVERSAL_DATA_SYSTEM_ERROR_INVALID_ARGUMENT; } - object->SetTrophyId(key, std::nullopt); + object->SetInteger(key, std::nullopt); LOGF("\t object = 0x%016" PRIx64 "\n" "\t key = %s\n" @@ -906,7 +943,7 @@ static int KYTY_SYSV_ABI NpUniversalDataSystemEventPropertyObjectSetFloat64( if (object == nullptr || key == nullptr) { return NP_UNIVERSAL_DATA_SYSTEM_ERROR_INVALID_ARGUMENT; } - object->SetTrophyId(key, std::nullopt); + object->SetInteger(key, std::nullopt); LOGF("\t object = 0x%016" PRIx64 "\n" "\t key = %s\n" @@ -923,7 +960,7 @@ static int KYTY_SYSV_ABI NpUniversalDataSystemEventPropertyObjectSetBool( if (object == nullptr || key == nullptr) { return NP_UNIVERSAL_DATA_SYSTEM_ERROR_INVALID_ARGUMENT; } - object->SetTrophyId(key, std::nullopt); + object->SetInteger(key, std::nullopt); LOGF("\t object = 0x%016" PRIx64 "\n" "\t key = %s\n" @@ -941,7 +978,7 @@ static int KYTY_SYSV_ABI NpUniversalDataSystemEventPropertyObjectSetBinary( if (object == nullptr || key == nullptr || (value == nullptr && value_size != 0)) { return NP_UNIVERSAL_DATA_SYSTEM_ERROR_INVALID_ARGUMENT; } - object->SetTrophyId(key, std::nullopt); + object->SetInteger(key, std::nullopt); LOGF("\t object = 0x%016" PRIx64 "\n" "\t key = %s\n" @@ -962,7 +999,7 @@ static int KYTY_SYSV_ABI NpUniversalDataSystemEventPropertyObjectSetObject( if (object == nullptr || key == nullptr) { return NP_UNIVERSAL_DATA_SYSTEM_ERROR_INVALID_ARGUMENT; } - object->SetTrophyId(key, std::nullopt); + object->SetInteger(key, std::nullopt); if (value_ptr != nullptr) { *value_ptr = @@ -996,7 +1033,7 @@ static int KYTY_SYSV_ABI NpUniversalDataSystemEventPropertyObjectSetArray( if (object == nullptr || key == nullptr) { return NP_UNIVERSAL_DATA_SYSTEM_ERROR_INVALID_ARGUMENT; } - object->SetTrophyId(key, std::nullopt); + object->SetInteger(key, std::nullopt); if (value_ptr != nullptr) { *value_ptr = (value != nullptr ? const_cast(value) diff --git a/tests/TrophySystemTests.cpp b/tests/TrophySystemTests.cpp new file mode 100644 index 00000000..ce68a2fa --- /dev/null +++ b/tests/TrophySystemTests.cpp @@ -0,0 +1,261 @@ +#include "common/file.h" +#include "common/trophies.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +namespace Trophies = Common::Trophies; + +void Check(bool value, const char *text) { + if (!value) { + std::fprintf(stderr, "TrophySystemTests: failed: %s\n", text); + std::abort(); + } +} + +class TempDirectory { +public: + TempDirectory() { + const auto unique = + std::chrono::steady_clock::now().time_since_epoch().count(); + m_path = std::filesystem::temp_directory_path() / + ("kyty_trophy_test_" + std::to_string(unique)); + Check(std::filesystem::create_directories(m_path), + "create temporary directory"); + } + ~TempDirectory() { + std::error_code error; + std::filesystem::remove_all(m_path, error); + } + [[nodiscard]] const std::filesystem::path &Path() const { return m_path; } + +private: + std::filesystem::path m_path; +}; + +void WriteBigEndian(std::vector &bytes, size_t offset, uint64_t value, + size_t length) { + Check(offset <= bytes.size() && length <= bytes.size() - offset, + "write within package"); + for (size_t i = 0; i < length; ++i) { + bytes[offset + length - 1 - i] = static_cast(value & 0xff); + value >>= 8; + } +} + +void WritePackage(const std::filesystem::path &path, + const std::map &files) { + size_t offset = 0x40 + files.size() * 0x40; + size_t size = offset; + for (const auto &[name, contents] : files) + size += contents.size(); + std::vector bytes(size); + WriteBigEndian(bytes, 0, 0xb228c60a, 4); + WriteBigEndian(bytes, 4, 1, 4); + WriteBigEndian(bytes, 8, bytes.size(), 8); + WriteBigEndian(bytes, 0x10, files.size(), 4); + WriteBigEndian(bytes, 0x14, 0x20, 4); + size_t entry = 0x40; + for (const auto &[name, contents] : files) { + Check(name.size() < 0x20, "fixture file name fits"); + std::memcpy(bytes.data() + entry, name.data(), name.size()); + WriteBigEndian(bytes, entry + 0x20, offset, 8); + WriteBigEndian(bytes, entry + 0x28, contents.size(), 8); + std::memcpy(bytes.data() + offset, contents.data(), contents.size()); + offset += contents.size(); + entry += 0x40; + } + Common::File file; + Check(file.Create(path), "create package fixture"); + uint32_t written = 0; + file.Write(bytes.data(), static_cast(bytes.size()), &written); + Check(written == bytes.size(), "write package fixture"); +} + +Trophies::Package MakePackage(const std::filesystem::path &directory) { + const auto path = directory / "trophy00.ucp"; + WritePackage( + path, + {{"tropconf.json", R"({"defaultLanguage":"en-US","trophies":[ + {"id":"0","grade":"P"}, + {"id":"1","grade":"G","unlockCondition":{"udsStatId":"10","comparator":"ge","targetValue":"10","progressive":false}}, + {"id":"2","grade":"S","unlockCondition":{"udsStatId":"20","comparator":"gt","targetValue":"100","progressive":false}}, + {"id":"3","grade":"B","groupId":"1","unlockCondition":{"udsStatId":"30","comparator":"le","targetValue":"5","progressive":false}}, + {"id":"4","grade":"B","unlockCondition":{"udsStatId":"10","comparator":"lt","targetValue":"-1","progressive":false}} + ]})"}, + {"tropmeta_en-US.json", + R"({"metadata":{"titleMetadata":{"name":"Test game"},"trophyMetadata":[ + {"id":"1","name":"Find All Coins","detail":"Find all coins in the level."} + ]}})"}}); + return Trophies::LoadPackage(path, 1); +} + +void TestEventExtraction(const std::filesystem::path &directory) { + auto package = MakePackage(directory); + Check(package.trophies.size() == 5, + "unsupported signed target does not discard trophy metadata"); + Check(package.trophies.at(1).uds_stat_id == 10 && + package.trophies.at(1).target == 10 && + !package.trophies.at(1).progressive, + "non-progressive trophy retains its stat target"); + Check(!package.trophies.at(4).target, + "negative target is not converted to unsigned"); + Check(Trophies::FindUdsTrophies(package, "FIND_ALL_COINS", + {{"counter", uint64_t{100}}}) + .empty(), + "missing mapping never guesses from trophy text"); + const auto path = directory / "uds00.ucp"; + WritePackage(path, {{"stats_definition.json", R"({"statDefinitionArray":[ + {"statId":10,"dataType":"uint64","aggregation":"latest","minValue":"0"}, + {"statId":20,"dataType":"uint64","aggregation":"latest"}, + {"statId":30,"dataType":"uint32","aggregation":"latest"}, + {"statId":40,"dataType":"uint64","aggregation":"sum"}, + {"statId":50,"dataType":"uint64","aggregation":"count"}, + {"statId":60,"dataType":"uint64","aggregation":"max"}, + {"statId":70,"dataType":"uint64","aggregation":"latest","maxValue":"3"} + ]})"}, + {"stats_extraction.json", R"({"statsExtractionRuleArray":[ + {"condition":{"eventName":"COLLECT"},"action":{"input":"$.counter","output":{"statId":10}}}, + {"condition":{"eventName":"COLLECT"},"action":{"input":"$.counterBase","output":{"statId":20}}}, + {"condition":{"eventName":"TIME"},"action":{"input":"$.seconds","output":{"statId":30}}}, + {"condition":{"eventName":"FILTERED","property":{"path":"$.level","comparator":"==","value":1}},"action":{"input":"$.counter","output":{"statId":10}}}, + {"condition":{"eventName":"NESTED"},"action":{"input":"$.player.counter","output":{"statId":10}}}, + {"condition":{"eventName":"SUM"},"action":{"input":"$.counter","output":{"statId":40}}}, + {"condition":{"eventName":"COUNT"},"action":{"output":{"statId":50}}}, + {"condition":{"eventName":"MAX"},"action":{"input":"$.counter","output":{"statId":60}}}, + {"condition":{"eventName":"BOUNDED"},"action":{"input":"$.counter","output":{"statId":70}}}, + {"bad":1} + ]})"}}); + package.event_rules = Trophies::LoadUdsRules(path); + Check(package.event_rules.size() == 2, + "skip unsupported extraction and aggregation"); + Check(Trophies::FindUdsTrophies( + package, "COLLECT", + {{"counter", uint64_t{9}}, {"counterBase", uint64_t{100}}}) + .empty(), + "do not add independent counters or treat gt as ge"); + Check(Trophies::FindUdsTrophies( + package, "COLLECT", + {{"counter", uint64_t{10}}, {"counterBase", uint64_t{100}}}) == + std::vector{1}, + "each stat reads its configured property"); + Check(Trophies::FindUdsTrophies( + package, "COLLECT", + {{"counter", uint64_t{0}}, {"counterBase", uint64_t{101}}}) == + std::vector{2}, + "strict comparison requires greater value"); + Check(Trophies::FindUdsTrophies( + package, "COLLECT", + {{"counter", uint64_t{10}}, {"counterBase", uint64_t{101}}}) == + std::vector({1, 2}), + "one event can unlock multiple mapped trophies"); + Check(Trophies::FindUdsTrophies( + package, "TIME", {{"seconds", uint32_t{5}}}) == std::vector{3}, + "less-equal condition accepts its boundary"); + Check(Trophies::FindUdsTrophies(package, "TIME", {{"seconds", uint32_t{6}}}) + .empty(), + "less-equal condition rejects larger input"); + Check(Trophies::FindUdsTrophies(package, "TIME", {{"counter", uint64_t{0}}}) + .empty(), + "missing property is not zero"); + package.trophies.at(3).comparison = Trophies::Comparison::Less; + Check(Trophies::FindUdsTrophies(package, "TIME", {{"seconds", uint32_t{5}}}) + .empty(), + "strict less rejects its boundary"); + Check(Trophies::FindUdsTrophies( + package, "TIME", {{"seconds", uint32_t{4}}}) == std::vector{3}, + "strict less accepts lower input"); + package.trophies.at(3).comparison = Trophies::Comparison::GreaterEqual; + Check(Trophies::FindUdsTrophies(package, "TIME", + {{"seconds", uint64_t{UINT32_MAX} + 1}}) + .empty(), + "uint64 input cannot update a uint32 stat"); + Check( + Trophies::FindUdsTrophies(package, "COLLECT", {{"counter", int32_t{100}}}) + .empty(), + "signed inputs cannot update unsigned stats"); + Check(Trophies::FindUdsTrophies(package, "COLLECT", + {{"counter", uint32_t{100}}}) + .empty(), + "unsigned input width must match the stat"); + package.trophies.at(1).comparison = Trophies::Comparison::None; + Check(Trophies::FindUdsTrophies(package, "COLLECT", + {{"counter", uint64_t{100}}}) + .empty(), + "unknown comparator cannot unlock"); + WritePackage(path, {{"stats_extraction.json", "{}"}}); + Check(Trophies::LoadUdsRules(path).empty(), + "missing stat definitions cannot infer aggregation"); + Check(Trophies::LoadUdsRules(directory / "missing.ucp").empty(), + "missing UDS package has no rules"); +} + +void TestProgress(const std::filesystem::path &directory) { + const auto package = MakePackage(directory); + Trophies::UnlockData unlocks; + unlocks.unlocked = {0, 1, 999}; + const auto progress = Trophies::GetProgress(package, unlocks); + Check(progress.total == 5 && progress.earned == 2, + "ignore unlock IDs absent from package"); + Check(progress.earned_grade[1] == 1 && progress.earned_grade[2] == 1 && + progress.Percentage() == 60, + "percentage uses 6:2:1 grade points and excludes platinum"); + const auto group = Trophies::GetProgress(package, unlocks, 1); + Check(group.total == 1 && group.earned == 0 && group.Percentage() == 0, + "group counts filter trophies"); + Check(Trophies::GetProgress({}, {}).Percentage() == 0, + "empty package progress is zero"); +} + +void TestUnlockPersistence(const std::filesystem::path &directory) { + const auto path = Trophies::UnlocksPath(directory, "PPSA00001", 1000, 0); + Check(path == directory / "_Trophies/PPSA00001/trophies_1000_0.json", + "store trophies outside save data"); + Check(Trophies::UnlocksPath(directory, "../bad", 1000, 0).empty(), + "reject unsafe title IDs"); + Check(Trophies::LoadUnlockData(path).unlocked.empty(), + "missing unlock file loads as empty"); + Trophies::UnlockData unlocks; + unlocks.unlocked = {4, 42}; + const auto tick = Trophies::UnixEpochTick + 1791288000123456ULL; + unlocks.timestamps.emplace(42, tick); + Check(Trophies::SaveUnlockData(path, unlocks), "save timestamped unlocks"); + auto loaded = Trophies::LoadUnlockData(path); + Check(loaded.unlocked == unlocks.unlocked && + loaded.timestamps == unlocks.timestamps, + "persist full UTC microsecond timestamp"); + loaded.unlocked.insert(43); + loaded.timestamps.emplace(43, tick + 1000000); + Check(Trophies::SaveUnlockData(path, loaded), "replace existing unlock file"); + Check(Trophies::LoadUnlockData(path).timestamps.at(42) == tick, + "retain original unlock time"); + + const auto blocked = directory / "blocked.json"; + std::filesystem::create_directory(blocked); + Common::File sentinel; + Check(sentinel.Create(blocked / "keep"), + "create replacement failure fixture"); + sentinel.Close(); + Check(!Trophies::SaveUnlockData(blocked, loaded) && + std::filesystem::exists(blocked / "keep"), + "failed replacement leaves destination intact"); + Check(!std::filesystem::exists(directory / "blocked.json.tmp"), + "failed replacement removes temporary file"); +} +} // namespace + +int main() { + TempDirectory directory; + TestEventExtraction(directory.Path()); + TestProgress(directory.Path()); + TestUnlockPersistence(directory.Path()); + std::printf("TrophySystemTests: all cases passed\n"); + return 0; +}