From fb7ff7ec03e8b7a940b7debdc9f54892a98ab4a5 Mon Sep 17 00:00:00 2001 From: Felipe <54341095+Pipe530@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:45:26 -0300 Subject: [PATCH] Map UDS events to trophies from dumped metadata; trophy viewer/overview improvements (#1088) * Map UDS events to trophies via dumped metadata and redesign trophy UI Correlate each UDS event to its stat ID (uds00.ucp) and trophy target (trophy00.ucp) instead of a hard-coded event, store trophy JSON with unlock date/time outside _SaveData, and add a PS5-style toast, viewer and overview. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Fix trophy rule evaluation and share progress accounting * Match trophy and reload toolbar styling --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: nmzik --- CMakeLists.txt | 7 + assets/images/hidden-trophy.png | Bin 0 -> 9052 bytes src/common/trophies.cpp | 241 ++++++++-- src/common/trophies.h | 45 +- src/graphics/presentation/systemOverlay.cpp | 86 ++-- .../forms/configuration_list_widget.ui | 22 + src/launcher/icons/refresh.svg | 7 +- src/launcher/icons/trophy.svg | 7 + .../include/configurationListWidget.h | 1 + src/launcher/include/trophyViewerDialog.h | 4 + src/launcher/resources.qrc | 2 + src/launcher/src/configurationListWidget.cpp | 18 + src/launcher/src/trophyViewerDialog.cpp | 416 +++++++++++++++--- src/launcher/styles/launcher.qss | 6 + src/libs/libTrophy.cpp | 135 +++--- tests/TrophySystemTests.cpp | 261 +++++++++++ 16 files changed, 1076 insertions(+), 182 deletions(-) create mode 100644 assets/images/hidden-trophy.png create mode 100644 src/launcher/icons/trophy.svg create mode 100644 tests/TrophySystemTests.cpp 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 0000000000000000000000000000000000000000..0011566f17b00ef5baa6277bdc267ad825feadeb GIT binary patch literal 9052 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 uPCsq4Ei*Z{(}|S1|Ifcl;@XxrlzspHARUNt1^ #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; +}