Add trophy notification support

Co-authored-by: Felipe <54341095+Pipe530@users.noreply.github.com>
This commit is contained in:
nmzik
2026-10-05 09:48:46 +04:00
parent 1d55272467
commit 72e4989b1e
22 changed files with 1967 additions and 1618 deletions
+10 -1
View File
@@ -1,4 +1,4 @@
cmake_minimum_required(VERSION 3.18...4.3) cmake_minimum_required(VERSION 3.19...4.3)
if(CMAKE_VERSION VERSION_GREATER_EQUAL 4.0) if(CMAKE_VERSION VERSION_GREATER_EQUAL 4.0)
set(CMAKE_POLICY_VERSION_MINIMUM 3.10 CACHE STRING "Minimum policy version for bundled dependencies") set(CMAKE_POLICY_VERSION_MINIMUM 3.10 CACHE STRING "Minimum policy version for bundled dependencies")
@@ -725,6 +725,15 @@ endif()
add_executable(kyty_emulator "${KYTY_SOURCE_DIR}/main.cpp" ${kyty_emulator_src}) add_executable(kyty_emulator "${KYTY_SOURCE_DIR}/main.cpp" ${kyty_emulator_src})
configure_macos_guest_address_space(kyty_emulator) configure_macos_guest_address_space(kyty_emulator)
set(KYTY_TROPHY_UNLOCK_SOUND "${CMAKE_CURRENT_SOURCE_DIR}/assets/sounds/trophy-unlock.wav")
add_custom_target(kyty_trophy_sound
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:kyty_emulator>/assets/sounds"
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${KYTY_TROPHY_UNLOCK_SOUND}"
"$<TARGET_FILE_DIR:kyty_emulator>/assets/sounds/trophy-unlock.wav"
VERBATIM)
add_dependencies(kyty_emulator kyty_trophy_sound)
install(FILES "${KYTY_TROPHY_UNLOCK_SOUND}" DESTINATION assets/sounds)
target_link_libraries(kyty_emulator ${kyty_emulator_link_libraries}) target_link_libraries(kyty_emulator ${kyty_emulator_link_libraries})
if (WIN32) if (WIN32)
target_link_libraries(kyty_emulator opengl32) target_link_libraries(kyty_emulator opengl32)
Binary file not shown.
+1
View File
@@ -27,6 +27,7 @@ target_link_libraries(common
kyty_git_version kyty_git_version
spdlog::spdlog spdlog::spdlog
ZArchive::zarchive ZArchive::zarchive
nlohmann_json::nlohmann_json
) )
get_property(inc_headers TARGET common PROPERTY INCLUDE_DIRECTORIES) get_property(inc_headers TARGET common PROPERTY INCLUDE_DIRECTORIES)
+4
View File
@@ -156,6 +156,10 @@ bool TessellationEnabled() {
return g_config->tessellation_enabled; return g_config->tessellation_enabled;
} }
bool TrophyEnabled() {
return g_config->trophy_enabled;
}
bool PlayGoHackEnabled() { bool PlayGoHackEnabled() {
return g_config->playgo_hack_enabled; return g_config->playgo_hack_enabled;
} }
+2
View File
@@ -74,6 +74,7 @@ struct ConfigOptions {
bool renderdoc_enabled = false; bool renderdoc_enabled = false;
bool readback_linear_images = false; bool readback_linear_images = false;
bool tessellation_enabled = false; bool tessellation_enabled = false;
bool trophy_enabled = true;
bool playgo_hack_enabled = false; bool playgo_hack_enabled = false;
#if KYTY_PLATFORM == KYTY_PLATFORM_WINDOWS #if KYTY_PLATFORM == KYTY_PLATFORM_WINDOWS
bool red_zone_protection_enabled = false; bool red_zone_protection_enabled = false;
@@ -123,6 +124,7 @@ bool GpuAssistedValidationEnabled();
bool RenderDocEnabled(); bool RenderDocEnabled();
bool ReadbackLinearImagesEnabled(); bool ReadbackLinearImagesEnabled();
bool TessellationEnabled(); bool TessellationEnabled();
bool TrophyEnabled();
bool PlayGoHackEnabled(); bool PlayGoHackEnabled();
#if KYTY_PLATFORM == KYTY_PLATFORM_WINDOWS #if KYTY_PLATFORM == KYTY_PLATFORM_WINDOWS
bool RedZoneProtectionEnabled(); bool RedZoneProtectionEnabled();
+264
View File
@@ -0,0 +1,264 @@
#include "common/trophies.h"
#include "common/file.h"
#include <algorithm>
#include <array>
#include <charconv>
#include <span>
#include <fmt/format.h>
#include <nlohmann/json.hpp>
namespace Common::Trophies {
namespace {
using Json = nlohmann::json;
using Files = std::map<std::string_view, std::span<const std::byte>>;
constexpr uint64_t MaxUnlocksSize = uint64_t {1} << 20u;
constexpr uint64_t MaxPackageSize = uint64_t {1} << 30u;
uint64_t ReadBigEndian(const std::byte* data, size_t size) {
uint64_t value = 0;
for (size_t i = 0; i < size; ++i) {
value = (value << 8u) | std::to_integer<uint8_t>(data[i]);
}
return value;
}
int ReadId(const Json& value) {
if (value.is_number_integer()) {
const auto number = value.get<int64_t>();
return number >= 0 && number <= INT32_MAX ? static_cast<int>(number) : -1;
}
if (!value.is_string()) {
return -1;
}
const auto& text = value.get_ref<const std::string&>();
int id = -1;
const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), id);
return error == std::errc {} && end == text.data() + text.size() && id >= 0 ? id : -1;
}
Json ReadJson(const Files& files, std::string_view name) {
const auto entry = files.find(name);
if (entry == files.end()) {
return {};
}
return Json::parse(entry->second.begin(), entry->second.end(), nullptr, false);
}
Package ParsePackage(std::span<const std::byte> data, int console_language) {
if (data.size() < 0x40 || data.size() > MaxPackageSize ||
ReadBigEndian(data.data(), 4) != 0xb228c60a || ReadBigEndian(data.data() + 4, 4) != 1) {
return {};
}
const auto 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 {};
}
Files files;
for (uint64_t i = 0; i < count; ++i) {
const auto* entry = data.data() + toc + 0x20 + i * 0x40;
const auto* name = reinterpret_cast<const char*>(entry);
const std::string_view filename(name, std::find(name, name + 0x20, '\0') - name);
const auto offset = ReadBigEndian(entry + 0x20, 8);
const auto length = ReadBigEndian(entry + 0x28, 8);
if (offset > size || length > size - offset) {
return {};
}
files.emplace(filename, data.subspan(offset, length));
}
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") ||
!conf["defaultLanguage"].is_string()) {
return {};
}
static constexpr std::array<std::string_view, 30> locales = {
"ja-JP", "en-US", "fr-FR", "es-ES", "de-DE", "it-IT", "nl-NL", "pt-PT",
"ru-RU", "ko-KR", "zh-Hant", "zh-Hans", "fi-FI", "sv-SE", "da-DK", "no-NO",
"pl-PL", "pt-BR", "en-GB", "tr-TR", "es-419", "ar-AE", "fr-CA", "cs-CZ",
"hu-HU", "el-GR", "ro-RO", "th-TH", "vi-VN", "id-ID"};
const auto locale =
locales[console_language >= 0 && console_language < locales.size() ? console_language : 1];
auto meta = ReadJson(files, fmt::format("tropmeta_{}.json", locale));
if (meta.is_null()) {
meta = ReadJson(files, fmt::format("tropmeta_{}.json",
conf["defaultLanguage"].get_ref<const std::string&>()));
}
if (!meta.is_object() || !meta.contains("metadata") || !meta["metadata"].is_object()) {
return {};
}
const auto& texts = meta["metadata"];
if (!texts.contains("titleMetadata") || !texts["titleMetadata"].is_object() ||
!texts["titleMetadata"].contains("name") || !texts["titleMetadata"]["name"].is_string() ||
!texts.contains("trophyMetadata") || !texts["trophyMetadata"].is_array()) {
return {};
}
Package package;
package.title = texts["titleMetadata"]["name"].get<std::string>();
package.groups.emplace(-1, package.title);
uint64_t icon_bytes = 0;
for (const auto& definition: conf["trophies"]) {
if (!definition.is_object() || !definition.contains("id") ||
!definition.contains("grade") || !definition["grade"].is_string()) {
return {};
}
Trophy trophy;
trophy.id = ReadId(definition["id"]);
const auto grade = definition["grade"].get<std::string>();
const auto grade_index = std::string_view("PGSB").find(grade);
if (trophy.id < 0 || grade.size() != 1 || grade_index == std::string_view::npos) {
return {};
}
trophy.grade = static_cast<int>(grade_index) + 1;
if (definition.contains("groupId")) {
trophy.group_id = ReadId(definition["groupId"]);
if (trophy.group_id < 0) {
return {};
}
}
if (definition.contains("platinumTrophyId")) {
trophy.platinum_id = ReadId(definition["platinumTrophyId"]);
}
trophy.hidden = definition.contains("hidden") && definition["hidden"] == true;
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 {};
}
const auto& value = condition["targetValue"].get_ref<const std::string&>();
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 {};
}
trophy.target = target;
}
}
const auto icon = files.find(fmt::format("trop{:04}.png", trophy.id));
if (icon != files.end()) {
if (icon->second.size() > size - icon_bytes) {
return {};
}
trophy.icon_png.assign(icon->second.begin(), icon->second.end());
icon_bytes += icon->second.size();
}
package.groups.try_emplace(trophy.group_id);
if (package.groups.size() > 50 ||
!package.trophies.emplace(trophy.id, std::move(trophy)).second) {
return {};
}
}
if (texts.contains("groupMetadata") && texts["groupMetadata"].is_array()) {
for (const auto& group: texts["groupMetadata"]) {
if (group.is_object() && group.contains("id") && group.contains("name") &&
group["name"].is_string()) {
const auto found = package.groups.find(ReadId(group["id"]));
if (found != package.groups.end()) {
found->second = group["name"].get<std::string>();
}
}
}
}
for (const auto& text: texts["trophyMetadata"]) {
if (!text.is_object() || !text.contains("id")) {
continue;
}
const auto found = package.trophies.find(ReadId(text["id"]));
if (found == package.trophies.end()) {
continue;
}
for (const auto& [key, field]: {std::pair {"name", &found->second.name},
{"detail", &found->second.description},
{"reward", &found->second.reward}}) {
if (text.contains(key) && text[key].is_string()) {
*field = text[key].get<std::string>();
}
}
}
return package;
}
} // namespace
Package LoadPackage(const std::filesystem::path& path, int console_language) {
File file(path, File::Mode::Read);
if (file.IsInvalid() || file.Size() > MaxPackageSize) {
return {};
}
return ParsePackage(file.ReadWholeBuffer(), console_language);
}
std::filesystem::path PackagePath(uint32_t service_label) {
return std::filesystem::path(PackageDirectory) / fmt::format("trophy{:02}.ucp", service_label);
}
std::filesystem::path UnlocksPath(const std::filesystem::path& root, std::string_view title_id,
int user_id, uint32_t service_label) {
if (title_id.empty() || !std::all_of(title_id.begin(), title_id.end(), [](char c) {
return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
c == '_' || c == '-';
})) {
return {};
}
return root / "_SaveData" / title_id /
fmt::format("trophies_{}_{}.json", user_id, service_label);
}
std::set<int> LoadUnlocks(const std::filesystem::path& path) {
File file(path, File::Mode::Read);
if (file.IsInvalid() || file.Size() > MaxUnlocksSize) {
return {};
}
const auto bytes = file.ReadWholeBuffer();
const auto json = Json::parse(bytes.begin(), bytes.end(), nullptr, false);
if (!json.is_object() || !json.contains("unlockedTrophies") ||
!json["unlockedTrophies"].is_array()) {
return {};
}
std::set<int> unlocked;
for (const auto& value: json["unlockedTrophies"]) {
const auto id = ReadId(value);
if (id >= 0) {
unlocked.insert(id);
}
}
return unlocked;
}
bool SaveUnlocks(const std::filesystem::path& path, const std::set<int>& unlocked) {
if (path.empty() || !File::CreateDirectories(path.parent_path())) {
return false;
}
const auto text = Json {{"unlockedTrophies", unlocked}}.dump();
auto temporary = path;
temporary += ".tmp";
File file;
if (text.size() > MaxUnlocksSize || !file.Create(temporary)) {
return false;
}
uint32_t written = 0;
file.Write(text.data(), static_cast<uint32_t>(text.size()), &written);
const bool complete = written == text.size() && file.Flush();
file.Close();
std::error_code error;
if (complete) {
std::filesystem::rename(temporary, path, error);
if (!error) {
return true;
}
}
std::filesystem::remove(temporary, error);
return false;
}
} // namespace Common::Trophies
+48
View File
@@ -0,0 +1,48 @@
#ifndef KYTY_COMMON_TROPHIES_H_
#define KYTY_COMMON_TROPHIES_H_
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <map>
#include <optional>
#include <set>
#include <string>
#include <string_view>
#include <vector>
namespace Common::Trophies {
inline constexpr char PackageDirectory[] = "sce_sys/trophy2";
struct Trophy {
int id = 0;
int group_id = -1;
int platinum_id = -1;
int grade = 0;
std::optional<uint64_t> target;
std::string name;
std::string description;
std::string reward;
std::vector<std::byte> icon_png;
bool hidden = false;
bool has_reward = false;
};
struct Package {
std::string title;
std::map<int, std::string> groups;
std::map<int, Trophy> trophies;
};
[[nodiscard]] Package LoadPackage(const std::filesystem::path& path, int console_language);
[[nodiscard]] std::filesystem::path PackagePath(uint32_t service_label);
[[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<int> LoadUnlocks(const std::filesystem::path& path);
[[nodiscard]] bool SaveUnlocks(const std::filesystem::path& path, const std::set<int>& unlocked);
} // namespace Common::Trophies
#endif // KYTY_COMMON_TROPHIES_H_
+227 -12
View File
@@ -3,14 +3,17 @@
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include "common/assert.h" #include "common/assert.h"
#include "common/logging/log.h"
#include "common/stringUtils.h" #include "common/stringUtils.h"
#include "graphics/host_gpu/graphicContext.h" #include "graphics/host_gpu/graphicContext.h"
#include "imgui.h" #include "imgui.h"
#include "imgui_internal.h"
#include "imgui_impl_vulkan.h" #include "imgui_impl_vulkan.h"
#include "libs/controller.h" #include "libs/controller.h"
#include "libs/dialog.h" #include "libs/dialog.h"
#include "libs/ime.h" #include "libs/ime.h"
#include "libs/imeDialog.h" #include "libs/imeDialog.h"
#include "stb_image.h"
#include <algorithm> #include <algorithm>
#include <array> #include <array>
@@ -18,12 +21,17 @@
#include <chrono> #include <chrono>
#include <cmath> #include <cmath>
#include <cstdint> #include <cstdint>
#include <cstring>
#include <deque> #include <deque>
#include <limits>
#include <memory>
#include <mutex> #include <mutex>
#include <optional>
#include <span> #include <span>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <utility> #include <utility>
#include <vector>
namespace Libs::Graphics { namespace Libs::Graphics {
@@ -178,6 +186,14 @@ bool g_input_lifecycle_active = false;
bool g_controller_captured = false; bool g_controller_captured = false;
OverlaySession g_session; OverlaySession g_session;
SDL_Window* g_input_window = nullptr; SDL_Window* g_input_window = nullptr;
struct TrophyNotification {
std::string name;
int32_t grade;
std::vector<std::byte> icon_png;
};
std::mutex g_trophy_notification_mutex;
std::deque<TrophyNotification> g_trophy_notifications;
std::atomic<bool> g_trophy_notification_active {false};
void ClearInputEvents() { void ClearInputEvents() {
std::scoped_lock lock(g_input_mutex); std::scoped_lock lock(g_input_mutex);
@@ -467,8 +483,9 @@ SystemOverlayVisualState GetSystemOverlayVisualState() noexcept {
const auto core = CoreIme::GetVisualState(); const auto core = CoreIme::GetVisualState();
const auto dialog = DialogIme::GetVisualState(); const auto dialog = DialogIme::GetVisualState();
const auto system = SystemDialog::GetVisualState(); const auto system = SystemDialog::GetVisualState();
return {core.active || dialog.active || system.active, const bool trophy_active = g_trophy_notification_active.load(std::memory_order_acquire);
core.revision + dialog.revision + system.revision}; return {core.active || dialog.active || system.active || trophy_active,
core.revision + dialog.revision + system.revision + (trophy_active ? 1u : 0u)};
} }
bool ProcessSystemOverlayInput(const SDL_Event& event) { bool ProcessSystemOverlayInput(const SDL_Event& event) {
@@ -599,12 +616,34 @@ bool ProcessSystemOverlayInput(const SDL_Event& event) {
} }
} }
void NotifyTrophyUnlocked(std::string_view name, int32_t grade,
std::span<const std::byte> icon_png) {
std::scoped_lock lock(g_trophy_notification_mutex);
g_trophy_notifications.push_back(
{std::string(name), grade, std::vector<std::byte>(icon_png.begin(), icon_png.end())});
g_trophy_notification_active.store(true, std::memory_order_release);
}
struct SystemOverlay::Impl { struct SystemOverlay::Impl {
using Clock = std::chrono::steady_clock;
explicit Impl(GraphicContext& context): graphics(context) {} explicit Impl(GraphicContext& context): graphics(context) {}
~Impl() { ~Impl() {
SDL_DestroyAudioStream(trophy_sound_stream);
SDL_free(trophy_sound_data);
if (trophy_sound_initialized) {
SDL_QuitSubSystem(SDL_INIT_AUDIO);
}
ReleaseVulkan(); ReleaseVulkan();
if (imgui_context != nullptr) { if (imgui_context != nullptr) {
ImGui::SetCurrentContext(imgui_context);
if (trophy_notification_image) {
ImGui::UnregisterUserTexture(trophy_notification_image.get());
}
for (const auto& texture: retired_trophy_images) {
ImGui::UnregisterUserTexture(texture.get());
}
ImGui::DestroyContext(imgui_context); ImGui::DestroyContext(imgui_context);
} }
} }
@@ -631,6 +670,75 @@ struct SystemOverlay::Impl {
style.ItemSpacing = {8.0f, 8.0f}; style.ItemSpacing = {8.0f, 8.0f};
} }
void PlayTrophySound() {
if (!trophy_sound_attempted) {
trophy_sound_attempted = true;
trophy_sound_initialized = SDL_InitSubSystem(SDL_INIT_AUDIO);
if (!trophy_sound_initialized) {
LOGF("[Trophy] could not initialize audio: %s\n", SDL_GetError());
return;
}
const char* base_path = SDL_GetBasePath();
if (base_path == nullptr) {
LOGF("[Trophy] could not locate sound: %s\n", SDL_GetError());
return;
}
const std::string path = std::string(base_path) + "assets/sounds/trophy-unlock.wav";
SDL_AudioSpec spec {};
if (!SDL_LoadWAV(path.c_str(), &spec, &trophy_sound_data, &trophy_sound_size)) {
LOGF("[Trophy] could not load %s: %s\n", path.c_str(), SDL_GetError());
return;
}
if (trophy_sound_size > static_cast<uint32_t>(std::numeric_limits<int>::max())) {
LOGF("[Trophy] sound is too large: %s\n", path.c_str());
return;
}
trophy_sound_stream = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK,
&spec, nullptr, nullptr);
if (trophy_sound_stream == nullptr) {
LOGF("[Trophy] could not open audio stream: %s\n", SDL_GetError());
return;
}
}
if (trophy_sound_stream != nullptr &&
(!SDL_PutAudioStreamData(trophy_sound_stream, trophy_sound_data,
static_cast<int>(trophy_sound_size)) ||
!SDL_ResumeAudioStreamDevice(trophy_sound_stream))) {
LOGF("[Trophy] could not play sound: %s\n", SDL_GetError());
}
}
std::unique_ptr<ImTextureData> LoadTrophyIcon(std::span<const std::byte> encoded) {
if (encoded.empty() ||
encoded.size() > static_cast<std::size_t>(std::numeric_limits<int>::max())) {
return nullptr;
}
const auto* data = reinterpret_cast<const stbi_uc*>(encoded.data());
const int size = static_cast<int>(encoded.size());
int width = 0;
int height = 0;
if (!stbi_info_from_memory(data, size, &width, &height, nullptr) || width <= 0 ||
height <= 0 || width > 512 || height > 512) {
LOGF("[Trophy] invalid icon or dimensions exceed 512x512\n");
return nullptr;
}
auto* pixels = stbi_load_from_memory(data, size, &width, &height, nullptr, 4);
if (pixels == nullptr) {
LOGF("[Trophy] could not decode trophy icon: %s\n", stbi_failure_reason());
return nullptr;
}
auto texture = std::make_unique<ImTextureData>();
texture->Create(ImTextureFormat_RGBA32, width, height);
std::memcpy(texture->Pixels, pixels,
static_cast<std::size_t>(width) * static_cast<std::size_t>(height) * 4);
texture->UseColors = true;
stbi_image_free(pixels);
ImGui::RegisterUserTexture(texture.get());
return texture;
}
void EnsureVulkan(vk::Format format, uint32_t image_count) { void EnsureVulkan(vk::Format format, uint32_t image_count) {
EnsureContext(); EnsureContext();
if (vulkan_initialized) { if (vulkan_initialized) {
@@ -952,9 +1060,104 @@ struct SystemOverlay::Impl {
} }
} }
void DrawTrophyNotification(vk::Extent2D frame_extent, Clock::time_point now) {
std::erase_if(retired_trophy_images, [](const auto& texture) {
if (texture->Status != ImTextureStatus_Destroyed) {
return false;
}
ImGui::UnregisterUserTexture(texture.get());
return true;
});
constexpr float TOAST_LIFETIME = 5.0f;
if (trophy_notification &&
std::chrono::duration<float>(now - trophy_notification_born).count() >=
TOAST_LIFETIME) {
trophy_notification.reset();
if (trophy_notification_image) {
// Keep the texture registered until the backend retires in-flight frames.
trophy_notification_image->WantDestroyNextFrame = true;
retired_trophy_images.push_back(std::move(trophy_notification_image));
}
}
if (!trophy_notification) {
{
std::scoped_lock lock(g_trophy_notification_mutex);
if (g_trophy_notifications.empty()) {
g_trophy_notification_active.store(!retired_trophy_images.empty(),
std::memory_order_release);
return;
}
trophy_notification = std::move(g_trophy_notifications.front());
g_trophy_notifications.pop_front();
}
trophy_notification_born = now;
trophy_notification_image = LoadTrophyIcon(trophy_notification->icon_png);
PlayTrophySound();
}
const float age = std::chrono::duration<float>(now - trophy_notification_born).count();
const float screen_scale =
std::clamp(std::min(static_cast<float>(frame_extent.width) / 1920.0f,
static_cast<float>(frame_extent.height) / 1080.0f),
0.65f, 1.25f);
const float width = std::max(
0.0f, std::min(440.0f * screen_scale, static_cast<float>(frame_extent.width) - 32.0f));
const float height = 104.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<float>(frame_extent.width) - 24.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<int>(95.0f * alpha)), 10.0f);
draw->AddRectFilled(top_left, bottom_right,
IM_COL32(18, 21, 29, static_cast<int>(242.0f * alpha)), 10.0f);
draw->AddRect(top_left, bottom_right,
IM_COL32(190, 154, 78, static_cast<int>(200.0f * alpha)), 10.0f,
ImDrawFlags_RoundCornersAll, 1.5f);
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<int>(255.0f * alpha)));
}
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<int>(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<int>(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<int>(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]);
}
draw->PopClipRect();
}
bool PrepareFrame(vk::Extent2D frame_extent, vk::Format format, uint32_t image_count) { bool PrepareFrame(vk::Extent2D frame_extent, vk::Format format, uint32_t image_count) {
OverlaySnapshot snapshot; OverlaySnapshot snapshot;
if (!GetOverlaySnapshot(&snapshot)) { const bool has_overlay = GetOverlaySnapshot(&snapshot);
const auto now = Clock::now();
if (!has_overlay && !g_trophy_notification_active.load(std::memory_order_acquire)) {
return false; return false;
} }
const auto prepared_session = snapshot.session; const auto prepared_session = snapshot.session;
@@ -977,7 +1180,6 @@ struct SystemOverlay::Impl {
auto& io = ImGui::GetIO(); auto& io = ImGui::GetIO();
io.DisplaySize = {static_cast<float>(frame_extent.width), io.DisplaySize = {static_cast<float>(frame_extent.width),
static_cast<float>(frame_extent.height)}; static_cast<float>(frame_extent.height)};
const auto now = std::chrono::steady_clock::now();
io.DeltaTime = last_frame == std::chrono::steady_clock::time_point {} io.DeltaTime = last_frame == std::chrono::steady_clock::time_point {}
? 1.0f / 60.0f ? 1.0f / 60.0f
: std::clamp(std::chrono::duration<float>(now - last_frame).count(), : std::clamp(std::chrono::duration<float>(now - last_frame).count(),
@@ -985,15 +1187,19 @@ struct SystemOverlay::Impl {
last_frame = now; last_frame = now;
ImGui_ImplVulkan_NewFrame(); ImGui_ImplVulkan_NewFrame();
ImGui::NewFrame(); ImGui::NewFrame();
if (!GetOverlaySnapshot(&snapshot) || snapshot.session != prepared_session) { GetOverlaySnapshot(&snapshot);
if (snapshot.session != prepared_session) {
ImGui::EndFrame(); ImGui::EndFrame();
return false; return false;
} }
if (snapshot.session.kind == OverlayKind::Dialog) { if (has_overlay) {
DrawDialog(snapshot.dialog, frame_extent); if (snapshot.session.kind == OverlayKind::Dialog) {
} else { DrawDialog(snapshot.dialog, frame_extent);
DrawIme(snapshot.ime, frame_extent); } else {
DrawIme(snapshot.ime, frame_extent);
}
} }
DrawTrophyNotification(frame_extent, now);
ImGui::Render(); ImGui::Render();
extent = frame_extent; extent = frame_extent;
return true; return true;
@@ -1040,9 +1246,18 @@ struct SystemOverlay::Impl {
float button_height = 42.0f; float button_height = 42.0f;
ImVec2 panel_offset {}; ImVec2 panel_offset {};
ImVec2 right_stick {}; ImVec2 right_stick {};
OverlaySession session; OverlaySession session;
vk::Extent2D extent {}; std::unique_ptr<ImTextureData> trophy_notification_image;
std::chrono::steady_clock::time_point last_frame; std::optional<TrophyNotification> trophy_notification;
Clock::time_point trophy_notification_born;
std::vector<std::unique_ptr<ImTextureData>> retired_trophy_images;
SDL_AudioStream* trophy_sound_stream = nullptr;
uint8_t* trophy_sound_data = nullptr;
uint32_t trophy_sound_size = 0;
bool trophy_sound_attempted = false;
bool trophy_sound_initialized = false;
vk::Extent2D extent {};
std::chrono::steady_clock::time_point last_frame;
}; };
SystemOverlay::SystemOverlay(GraphicContext& graphics): m_impl(std::make_unique<Impl>(graphics)) {} SystemOverlay::SystemOverlay(GraphicContext& graphics): m_impl(std::make_unique<Impl>(graphics)) {}
@@ -4,7 +4,10 @@
#include "common/common.h" #include "common/common.h"
#include "graphics/host_gpu/vulkanCommon.h" #include "graphics/host_gpu/vulkanCommon.h"
#include <cstddef>
#include <memory> #include <memory>
#include <span>
#include <string_view>
union SDL_Event; union SDL_Event;
struct SDL_Window; struct SDL_Window;
@@ -22,6 +25,8 @@ void InitializeSystemOverlayInput(SDL_Window* window);
void ShutdownSystemOverlayInput(); void ShutdownSystemOverlayInput();
bool ProcessSystemOverlayInput(const SDL_Event& event); bool ProcessSystemOverlayInput(const SDL_Event& event);
SystemOverlayVisualState GetSystemOverlayVisualState() noexcept; SystemOverlayVisualState GetSystemOverlayVisualState() noexcept;
void NotifyTrophyUnlocked(std::string_view name, int32_t grade,
std::span<const std::byte> icon_png);
class SystemOverlay final { class SystemOverlay final {
public: public:
+1
View File
@@ -96,6 +96,7 @@ install(TARGETS launcher
if(APPLE) if(APPLE)
install(TARGETS kyty_emulator DESTINATION "KytyPS5.app/Contents/MacOS") install(TARGETS kyty_emulator DESTINATION "KytyPS5.app/Contents/MacOS")
install(FILES "${KYTY_TROPHY_UNLOCK_SOUND}" DESTINATION "KytyPS5.app/Contents/Resources/assets/sounds")
# Deploy and ad-hoc sign the Qt frameworks and plugins in the bundle. # Deploy and ad-hoc sign the Qt frameworks and plugins in the bundle.
find_program(KYTY_MACDEPLOYQT NAMES macdeployqt PATHS "${Qt6_DIR}/../../../bin" NO_DEFAULT_PATH) find_program(KYTY_MACDEPLOYQT NAMES macdeployqt PATHS "${Qt6_DIR}/../../../bin" NO_DEFAULT_PATH)
if(NOT KYTY_MACDEPLOYQT) if(NOT KYTY_MACDEPLOYQT)
@@ -6,7 +6,7 @@
<rect> <rect>
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>760</width> <width>840</width>
<height>620</height> <height>620</height>
</rect> </rect>
</property> </property>
@@ -182,7 +182,22 @@
</layout> </layout>
</widget> </widget>
</item> </item>
<item row="3" column="0" colspan="2"> <item row="3" column="0">
<widget class="QGroupBox" name="notifications_group">
<property name="title"><string>Notifications</string></property>
<layout class="QVBoxLayout" name="notificationsLayout">
<item>
<widget class="QCheckBox" name="checkBox_trophy_notifications">
<property name="toolTip"><string>Show trophy unlock notifications and play their sound.
To use your own sound, replace the emulator's assets/sounds/trophy-unlock.wav with a WAV file and restart the game.</string></property>
<property name="text"><string>Enable trophy notifications</string></property>
<property name="checked"><bool>true</bool></property>
</widget>
</item>
</layout>
</widget>
</item>
<item row="4" column="0" colspan="2">
<widget class="QGroupBox" name="debug_group"> <widget class="QGroupBox" name="debug_group">
<property name="title"> <property name="title">
<string>Debugging &amp;&amp; logs</string> <string>Debugging &amp;&amp; logs</string>
+4
View File
@@ -122,6 +122,7 @@ public:
bool hide_cursor_enabled = false; bool hide_cursor_enabled = false;
bool readback_linear_images = false; bool readback_linear_images = false;
bool tessellation_enabled = false; bool tessellation_enabled = false;
bool trophy_enabled = true;
int vblank_frequency = 60; int vblank_frequency = 60;
int console_language = DEFAULT_CONSOLE_LANGUAGE; int console_language = DEFAULT_CONSOLE_LANGUAGE;
bool vulkan_validation_enabled = false; bool vulkan_validation_enabled = false;
@@ -154,6 +155,7 @@ public:
hide_cursor_enabled = other.hide_cursor_enabled; hide_cursor_enabled = other.hide_cursor_enabled;
readback_linear_images = other.readback_linear_images; readback_linear_images = other.readback_linear_images;
tessellation_enabled = other.tessellation_enabled; tessellation_enabled = other.tessellation_enabled;
trophy_enabled = other.trophy_enabled;
vblank_frequency = other.vblank_frequency; vblank_frequency = other.vblank_frequency;
console_language = other.console_language; console_language = other.console_language;
vulkan_validation_enabled = other.vulkan_validation_enabled; vulkan_validation_enabled = other.vulkan_validation_enabled;
@@ -201,6 +203,7 @@ public:
KYTY_CFG_SET(hide_cursor_enabled); KYTY_CFG_SET(hide_cursor_enabled);
KYTY_CFG_SET(readback_linear_images); KYTY_CFG_SET(readback_linear_images);
KYTY_CFG_SET(tessellation_enabled); KYTY_CFG_SET(tessellation_enabled);
KYTY_CFG_SET(trophy_enabled);
KYTY_CFG_SET(vblank_frequency); KYTY_CFG_SET(vblank_frequency);
KYTY_CFG_SET(console_language); KYTY_CFG_SET(console_language);
KYTY_CFG_SET(vulkan_validation_enabled); KYTY_CFG_SET(vulkan_validation_enabled);
@@ -244,6 +247,7 @@ public:
KYTY_CFG_GET(hide_cursor_enabled); KYTY_CFG_GET(hide_cursor_enabled);
KYTY_CFG_GET(readback_linear_images); KYTY_CFG_GET(readback_linear_images);
KYTY_CFG_GET(tessellation_enabled); KYTY_CFG_GET(tessellation_enabled);
trophy_enabled = s->value("trophy_enabled", trophy_enabled).toBool();
vblank_frequency = s->value("vblank_frequency", vblank_frequency).toInt(); vblank_frequency = s->value("vblank_frequency", vblank_frequency).toInt();
console_language = s->value("console_language", console_language).toInt(); console_language = s->value("console_language", console_language).toInt();
if (console_language < 0 || console_language > MAX_CONSOLE_LANGUAGE) { if (console_language < 0 || console_language > MAX_CONSOLE_LANGUAGE) {
@@ -28,6 +28,7 @@ public:
~ConfigurationListWidget() override; ~ConfigurationListWidget() override;
void SetRunEnabled(bool flag) { m_run_enabled = flag; } void SetRunEnabled(bool flag) { m_run_enabled = flag; }
void SetRuntimeDirectory(const QString& directory) { m_runtime_directory = directory; }
[[nodiscard]] const ConfigurationItem* GetSelectedItem() const { return m_selected_item; } [[nodiscard]] const ConfigurationItem* GetSelectedItem() const { return m_selected_item; }
ConfigurationItem* GetSelectedItem() { return m_selected_item; } ConfigurationItem* GetSelectedItem() { return m_selected_item; }
@@ -78,6 +79,7 @@ private:
bool m_run_enabled = true; bool m_run_enabled = true;
Ui::ConfigurationListWidget* m_ui = nullptr; Ui::ConfigurationListWidget* m_ui = nullptr;
QString m_settings_file; QString m_settings_file;
QString m_runtime_directory;
QStringList m_game_dirs; QStringList m_game_dirs;
Configuration m_global_info; Configuration m_global_info;
QMap<QString, Configuration*> m_custom_infos; QMap<QString, Configuration*> m_custom_infos;
-1
View File
@@ -12,7 +12,6 @@ namespace GameContent {
inline constexpr uint64_t MaxMetadataSize = uint64_t {1} << 20u; inline constexpr uint64_t MaxMetadataSize = uint64_t {1} << 20u;
inline constexpr uint64_t MaxImageSize = uint64_t {32} << 20u; inline constexpr uint64_t MaxImageSize = uint64_t {32} << 20u;
inline constexpr uint64_t MaxTrophyPackageSize = uint64_t {128} << 20u;
[[nodiscard]] std::filesystem::path ToPath(const QString& path); [[nodiscard]] std::filesystem::path ToPath(const QString& path);
[[nodiscard]] QString FromPath(const std::filesystem::path& path); [[nodiscard]] QString FromPath(const std::filesystem::path& path);
+3 -2
View File
@@ -12,10 +12,11 @@ public:
explicit TrophyViewerDialog(QWidget* parent = nullptr); explicit TrophyViewerDialog(QWidget* parent = nullptr);
static bool HasTrophyData(const Configuration* info); static bool HasTrophyData(const Configuration* info);
static void ShowForGame(const Configuration* info, QWidget* parent); static void ShowForGame(const Configuration* info, const QString& runtime_directory,
QWidget* parent);
private: private:
bool LoadGame(const Configuration& info, QString& error); bool LoadGame(const Configuration& info, const QString& runtime_directory, QString& error);
QTabWidget* m_tabs = nullptr; QTabWidget* m_tabs = nullptr;
}; };
+3 -3
View File
@@ -123,6 +123,7 @@ ConfigurationEditDialog::ConfigurationEditDialog(Configuration& info, QWidget* p
: QDialog(parent, Qt::WindowCloseButtonHint), m_ui(new Ui::ConfigurationEditDialog), : QDialog(parent, Qt::WindowCloseButtonHint), m_ui(new Ui::ConfigurationEditDialog),
m_info(info) { m_info(info) {
m_ui->setupUi(this); m_ui->setupUi(this);
setMinimumWidth(width());
InitGameDirectories(); InitGameDirectories();
m_ui->controller_group->setVisible(false); m_ui->controller_group->setVisible(false);
@@ -164,9 +165,6 @@ ConfigurationEditDialog::ConfigurationEditDialog(Configuration& info, QWidget* p
m_ui->lineEdit_printf_file->setEnabled(log == Configuration::LogDirection::File); m_ui->lineEdit_printf_file->setEnabled(log == Configuration::LogDirection::File);
}); });
// Keep the controls at a usable minimum while allowing the settings window
// and its expanding fields to use any additional space the user gives them.
layout()->setSizeConstraint(QLayout::SetMinimumSize);
setSizeGripEnabled(true); setSizeGripEnabled(true);
restoreGeometry(g_last_geometry); restoreGeometry(g_last_geometry);
@@ -276,6 +274,7 @@ void ConfigurationEditDialog::Init(const Configuration& info) {
m_ui->checkBox_hide_cursor->setChecked(info.hide_cursor_enabled); m_ui->checkBox_hide_cursor->setChecked(info.hide_cursor_enabled);
m_ui->checkBox_readback->setChecked(info.readback_linear_images); m_ui->checkBox_readback->setChecked(info.readback_linear_images);
m_ui->checkBox_tessellation->setChecked(info.tessellation_enabled); m_ui->checkBox_tessellation->setChecked(info.tessellation_enabled);
m_ui->checkBox_trophy_notifications->setChecked(info.trophy_enabled);
m_ui->spinBox_vblank_frequency->setValue(info.vblank_frequency); m_ui->spinBox_vblank_frequency->setValue(info.vblank_frequency);
m_ui->comboBox_console_language->clear(); m_ui->comboBox_console_language->clear();
m_ui->comboBox_console_language->addItems(CONSOLE_LANGUAGE_NAMES); m_ui->comboBox_console_language->addItems(CONSOLE_LANGUAGE_NAMES);
@@ -429,6 +428,7 @@ static void UpdateInfo(Configuration& info, Ui::ConfigurationEditDialog& ui, boo
info.hide_cursor_enabled = ui.checkBox_hide_cursor->isChecked(); info.hide_cursor_enabled = ui.checkBox_hide_cursor->isChecked();
info.readback_linear_images = ui.checkBox_readback->isChecked(); info.readback_linear_images = ui.checkBox_readback->isChecked();
info.tessellation_enabled = ui.checkBox_tessellation->isChecked(); info.tessellation_enabled = ui.checkBox_tessellation->isChecked();
info.trophy_enabled = ui.checkBox_trophy_notifications->isChecked();
info.vblank_frequency = ui.spinBox_vblank_frequency->value(); info.vblank_frequency = ui.spinBox_vblank_frequency->value();
info.console_language = ui.comboBox_console_language->currentIndex(); info.console_language = ui.comboBox_console_language->currentIndex();
info.vulkan_validation_enabled = ui.checkBox_vulkan_validation->isChecked(); info.vulkan_validation_enabled = ui.checkBox_vulkan_validation->isChecked();
+2 -1
View File
@@ -835,7 +835,8 @@ void ConfigurationListWidget::ViewTrophies() {
return; return;
} }
TrophyViewerDialog::ShowForGame(&item->GetInfo(), this); const auto config = CreateConfiguration(*item);
TrophyViewerDialog::ShowForGame(config.get(), m_runtime_directory, this);
} }
void ConfigurationListWidget::open_game_folder() { void ConfigurationListWidget::open_game_folder() {
+2
View File
@@ -170,6 +170,7 @@ void MainDialogPrivate::FindInterpreter() {
} }
bool found = QFile::exists(m_interpreter); bool found = QFile::exists(m_interpreter);
m_ui->widget->SetRuntimeDirectory(QFileInfo(m_interpreter).absolutePath());
if (found) { if (found) {
m_ui->label_Interpreter->setText(tr("Emulator: ") + m_interpreter); m_ui->label_Interpreter->setText(tr("Emulator: ") + m_interpreter);
@@ -244,6 +245,7 @@ static QStringList CreateEmulatorArgs(const Configuration& info) {
args << "--hide-cursor"; args << "--hide-cursor";
} }
args << "--readback-linear-images" << BoolArg(info.readback_linear_images); args << "--readback-linear-images" << BoolArg(info.readback_linear_images);
args << "--trophy-notifications" << BoolArg(info.trophy_enabled);
if (info.tessellation_enabled) { if (info.tessellation_enabled) {
args << "--tessellation"; args << "--tessellation";
} }
+70 -478
View File
@@ -1,462 +1,62 @@
#include "trophyViewerDialog.h" #include "trophyViewerDialog.h"
#include "common/archive.h" #include "common/archive.h"
#include "common/trophies.h"
#include "configuration.h" #include "configuration.h"
#include "gameContent.h" #include "gameContent.h"
#include <QAbstractItemView> #include <QAbstractItemView>
#include <QBrush>
#include <QByteArray>
#include <QDialogButtonBox> #include <QDialogButtonBox>
#include <QDir>
#include <QFileInfo> #include <QFileInfo>
#include <QFont> #include <QFont>
#include <QHeaderView> #include <QHeaderView>
#include <QIcon> #include <QIcon>
#include <QJsonArray> #include <QImage>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QJsonValue>
#include <QLabel>
#include <QMap>
#include <QMessageBox> #include <QMessageBox>
#include <QObject>
#include <QPixmap> #include <QPixmap>
#include <QRegularExpression> #include <QRegularExpression>
#include <QSet>
#include <QSize>
#include <QString>
#include <QStringList> #include <QStringList>
#include <QTabWidget> #include <QTabWidget>
#include <QTableWidget> #include <QTableWidget>
#include <QTableWidgetItem>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QVariant>
#include <QWidget>
#include <QtEndian>
#include <utility>
namespace { namespace {
constexpr quint32 UCP_MAGIC = 0xb228c60a; static const QRegularExpression TrophyFilePattern(QStringLiteral("^trophy(\\d+)\\.ucp$"));
constexpr quint32 UCP_VERSION = 1;
constexpr int UCP_HEADER_LEN = 0x40;
constexpr int UCP_TOC_SKIP = 0x20;
constexpr int UCP_ENTRY_LEN = 0x40;
constexpr int UCP_NAME_LEN = 0x20;
constexpr quint32 UCP_MAX_FILES = 4096;
constexpr quint64 UCP_MAX_EXTRACTED_SIZE = quint64 {64} << 20u;
struct UcpEntry {
QString name;
quint64 offset = 0;
quint64 size = 0;
};
struct TrophyDefinition {
QString id;
QString grade;
bool hidden = false;
bool has_reward = false;
};
struct TrophyText {
QString name;
QString detail;
QString reward;
};
struct TrophyRow {
QString id;
QString name;
QString detail;
QString grade;
QString reward;
bool hidden = false;
bool has_reward = false;
QPixmap icon;
};
struct TrophySet {
QString tab_title;
QList<TrophyRow> trophies;
};
static bool CanRead(const QByteArray& data, qsizetype offset, qsizetype size) {
return offset >= 0 && size >= 0 && offset <= data.size() && size <= data.size() - offset;
}
static QString ReadFixedString(const QByteArray& data, qsizetype offset, qsizetype max_size) {
if (!CanRead(data, offset, max_size)) {
return {};
}
qsizetype len = 0;
while (len < max_size && data.at(offset + len) != '\0') {
len++;
}
return QString::fromLatin1(data.constData() + offset, len);
}
static bool IsUsedUcpEntry(const QString& name) {
const auto lower = name.toCaseFolded();
return lower == QStringLiteral("tropconf.json") || lower == QStringLiteral("tropmeta.json") ||
(lower.startsWith(QStringLiteral("tropmeta_")) &&
lower.endsWith(QStringLiteral(".json"))) ||
(lower.startsWith(QStringLiteral("trop")) && lower.endsWith(QStringLiteral(".png")));
}
static bool ReadUcp(const QString& file_name, QMap<QString, QByteArray>& files, QString& error) {
const QByteArray data =
GameContent::ReadPath(GameContent::ToPath(file_name), GameContent::MaxTrophyPackageSize);
if (data.isEmpty()) {
error = QObject::tr("Could not open %1").arg(QDir::toNativeSeparators(file_name));
return false;
}
if (data.size() < UCP_HEADER_LEN) {
error = QObject::tr("%1 is too small to be a trophy package.")
.arg(QFileInfo(file_name).fileName());
return false;
}
const auto magic = qFromBigEndian<quint32>(data.constData() + 0x00);
if (magic != UCP_MAGIC) {
error = QObject::tr("%1 has an invalid trophy package magic.")
.arg(QFileInfo(file_name).fileName());
return false;
}
const auto version = qFromBigEndian<quint32>(data.constData() + 0x04);
if (version != UCP_VERSION) {
error = QObject::tr("%1 uses unsupported trophy package version %2.")
.arg(QFileInfo(file_name).fileName(), QString::number(version));
return false;
}
const auto declared_size = qFromBigEndian<quint64>(data.constData() + 0x08);
if (declared_size < UCP_HEADER_LEN || declared_size > static_cast<quint64>(data.size())) {
error = QObject::tr("%1 is truncated.").arg(QFileInfo(file_name).fileName());
return false;
}
const auto file_count = qFromBigEndian<quint32>(data.constData() + 0x10);
if (file_count > UCP_MAX_FILES) {
error = QObject::tr("%1 contains too many files.").arg(QFileInfo(file_name).fileName());
return false;
}
const auto toc_offset = static_cast<quint64>(qFromBigEndian<quint32>(data.constData() + 0x14));
const auto data_size = declared_size;
const quint64 table_size = UCP_TOC_SKIP + static_cast<quint64>(file_count) * UCP_ENTRY_LEN;
if (toc_offset > data_size || table_size > data_size - toc_offset ||
!std::in_range<qsizetype>(toc_offset + table_size)) {
error = QObject::tr("%1 has an invalid table of contents.")
.arg(QFileInfo(file_name).fileName());
return false;
}
quint64 extracted_size = 0;
for (quint32 i = 0; i < file_count; i++) {
const auto entry_offset = static_cast<qsizetype>(toc_offset + UCP_TOC_SKIP +
static_cast<quint64>(i) * UCP_ENTRY_LEN);
UcpEntry entry;
entry.name = ReadFixedString(data, entry_offset, UCP_NAME_LEN).trimmed();
entry.offset = qFromBigEndian<quint64>(data.constData() + entry_offset + 0x20);
entry.size = qFromBigEndian<quint64>(data.constData() + entry_offset + 0x28);
if (entry.name.isEmpty()) {
continue;
}
if (entry.offset > data_size || entry.size > data_size - entry.offset ||
!std::in_range<qsizetype>(entry.offset) || !std::in_range<qsizetype>(entry.size)) {
error = QObject::tr("%1 has an invalid entry for %2.")
.arg(QFileInfo(file_name).fileName(), entry.name);
return false;
}
if (!IsUsedUcpEntry(entry.name)) {
continue;
}
const auto entry_limit = entry.name.endsWith(QStringLiteral(".png"), Qt::CaseInsensitive)
? GameContent::MaxImageSize
: GameContent::MaxMetadataSize;
if (entry.size > entry_limit || entry.size > UCP_MAX_EXTRACTED_SIZE - extracted_size) {
error = QObject::tr("%1 contains an oversized entry for %2.")
.arg(QFileInfo(file_name).fileName(), entry.name);
return false;
}
files.insert(entry.name.toCaseFolded(), data.mid(static_cast<qsizetype>(entry.offset),
static_cast<qsizetype>(entry.size)));
extracted_size += entry.size;
}
return true;
}
static const QByteArray* FindFile(const QMap<QString, QByteArray>& files, const QString& name) {
const auto it = files.constFind(name.toCaseFolded());
return it == files.constEnd() ? nullptr : &it.value();
}
static const QByteArray* FindFirstTrophyMetadataFile(const QMap<QString, QByteArray>& files) {
for (auto it = files.constBegin(); it != files.constEnd(); ++it) {
if (it.key().startsWith(QStringLiteral("tropmeta_")) &&
it.key().endsWith(QStringLiteral(".json"))) {
return &it.value();
}
}
return FindFile(files, QStringLiteral("tropmeta.json"));
}
static bool ReadJsonObject(const QByteArray& data, const QString& file_name, QJsonObject& object,
QString& error) {
QJsonParseError parse_error;
const auto doc = QJsonDocument::fromJson(data, &parse_error);
if (parse_error.error != QJsonParseError::NoError || !doc.isObject()) {
error = QObject::tr("Could not read %1: %2").arg(file_name, parse_error.errorString());
return false;
}
object = doc.object();
return true;
}
static QString JsonString(const QJsonValue& value) {
if (value.isString()) {
return value.toString();
}
if (value.isDouble()) {
const double number = value.toDouble();
if (number == static_cast<int>(number)) {
return QString::number(static_cast<int>(number));
}
return QString::number(number);
}
if (value.isBool()) {
return value.toBool() ? QStringLiteral("true") : QStringLiteral("false");
}
return {};
}
static QList<TrophyDefinition> ReadDefinitions(const QJsonObject& tropconf) {
QList<TrophyDefinition> ret;
const auto trophies = tropconf.value(QStringLiteral("trophies")).toArray();
for (const auto& value: trophies) {
const auto obj = value.toObject();
TrophyDefinition def;
def.id = JsonString(obj.value(QStringLiteral("id"))).trimmed();
def.grade = JsonString(obj.value(QStringLiteral("grade"))).trimmed();
def.hidden = obj.value(QStringLiteral("hidden")).toBool(false);
def.has_reward = obj.value(QStringLiteral("hasReward")).toBool(false);
if (!def.id.isEmpty()) {
ret.append(def);
}
}
return ret;
}
static QMap<QString, TrophyText> ReadMetadata(const QJsonObject& tropmeta) {
QMap<QString, TrophyText> ret;
const auto metadata = tropmeta.value(QStringLiteral("metadata")).toObject();
const auto trophies = metadata.value(QStringLiteral("trophyMetadata")).toArray();
for (const auto& value: trophies) {
const auto obj = value.toObject();
const auto id = JsonString(obj.value(QStringLiteral("id"))).trimmed();
if (id.isEmpty()) {
continue;
}
TrophyText text;
text.name = JsonString(obj.value(QStringLiteral("name"))).trimmed();
text.detail = JsonString(obj.value(QStringLiteral("detail"))).trimmed();
text.reward = JsonString(obj.value(QStringLiteral("reward"))).trimmed();
ret.insert(id, text);
}
return ret;
}
static QString GradeToText(const QString& grade) {
if (grade == QStringLiteral("P")) {
return QObject::tr("Platinum");
}
if (grade == QStringLiteral("G")) {
return QObject::tr("Gold");
}
if (grade == QStringLiteral("S")) {
return QObject::tr("Silver");
}
if (grade == QStringLiteral("B")) {
return QObject::tr("Bronze");
}
return grade;
}
static QPixmap LoadTrophyIcon(const QMap<QString, QByteArray>& files, const QString& id) {
QStringList names;
names.append(QStringLiteral("trop%1.png").arg(id));
bool id_is_number = false;
const int id_num = id.toInt(&id_is_number);
if (id_is_number) {
names.append(QStringLiteral("trop%1.png").arg(id_num, 4, 10, QLatin1Char('0')));
}
for (const auto& name: names) {
const auto* data = FindFile(files, name);
if (data == nullptr) {
continue;
}
QPixmap pixmap;
if (pixmap.loadFromData(*data)) {
return pixmap;
}
}
return {};
}
static QString TrophyTabTitle(const QString& file_name) {
static const QRegularExpression trophy_file_re(QStringLiteral("^trophy(\\d+)\\.ucp$"),
QRegularExpression::CaseInsensitiveOption);
const auto name = QFileInfo(file_name).fileName();
const auto match = trophy_file_re.match(name);
if (match.hasMatch()) {
return QObject::tr("Trophy %1").arg(match.captured(1));
}
return QFileInfo(file_name).completeBaseName();
}
static bool BuildTrophySet(const QString& ucp_file, TrophySet& set, QString& error) {
QMap<QString, QByteArray> files;
if (!ReadUcp(ucp_file, files, error)) {
return false;
}
const auto* conf_data = FindFile(files, QStringLiteral("tropconf.json"));
if (conf_data == nullptr) {
error =
QObject::tr("%1 does not contain tropconf.json.").arg(QFileInfo(ucp_file).fileName());
return false;
}
QJsonObject tropconf;
if (!ReadJsonObject(*conf_data, QStringLiteral("tropconf.json"), tropconf, error)) {
return false;
}
const auto default_language =
JsonString(tropconf.value(QStringLiteral("defaultLanguage"))).trimmed();
const QByteArray* meta_data = nullptr;
if (!default_language.isEmpty()) {
meta_data = FindFile(files, QStringLiteral("tropmeta_%1.json").arg(default_language));
}
if (meta_data == nullptr) {
meta_data = FindFirstTrophyMetadataFile(files);
}
if (meta_data == nullptr) {
error = QObject::tr("%1 does not contain readable trophy metadata.")
.arg(QFileInfo(ucp_file).fileName());
return false;
}
QJsonObject tropmeta;
if (!ReadJsonObject(*meta_data, QStringLiteral("tropmeta.json"), tropmeta, error)) {
return false;
}
const auto definitions = ReadDefinitions(tropconf);
if (definitions.isEmpty()) {
error = QObject::tr("%1 does not define any trophies.").arg(QFileInfo(ucp_file).fileName());
return false;
}
const auto texts = ReadMetadata(tropmeta);
set.tab_title = TrophyTabTitle(ucp_file);
for (const auto& def: definitions) {
const auto text = texts.value(def.id);
TrophyRow row;
row.id = def.id;
row.grade = def.grade;
row.hidden = def.hidden;
row.has_reward = def.has_reward;
row.reward = text.reward;
row.name = text.name;
row.detail = text.detail;
row.icon = LoadTrophyIcon(files, def.id);
if (row.name.isEmpty()) {
row.name =
row.hidden ? QObject::tr("Hidden Trophy") : QObject::tr("Trophy %1").arg(def.id);
}
if (row.detail.isEmpty() && row.hidden) {
row.detail = QObject::tr("This trophy is hidden.");
}
set.trophies.append(row);
}
return true;
}
static QStringList FindTrophyFiles(const Configuration* info) { static QStringList FindTrophyFiles(const Configuration* info) {
if (info == nullptr || info->basedir.isEmpty()) { if (info == nullptr || info->basedir.isEmpty()) {
return {}; return {};
} }
QStringList files; QStringList files;
for (const auto& file: for (const auto& file: GameContent::ListFiles(
GameContent::ListFiles(info->basedir, QStringLiteral("sce_sys/trophy2"))) { info->basedir, QString::fromLatin1(Common::Trophies::PackageDirectory))) {
const auto name = QFileInfo(file).fileName(); if (TrophyFilePattern.match(QFileInfo(file).fileName()).hasMatch()) {
if (name.startsWith(QStringLiteral("trophy"), Qt::CaseInsensitive) &&
name.endsWith(QStringLiteral(".ucp"), Qt::CaseInsensitive)) {
files.append(file); files.append(file);
} }
} }
files.sort(Qt::CaseInsensitive); files.sort();
return files;
}
QStringList trophy_files; static QString GradeToText(int grade) {
QSet<QString> seen; switch (grade) {
for (const auto& file: files) { case 1: return QObject::tr("Platinum");
auto key = QFileInfo(file).canonicalFilePath(); case 2: return QObject::tr("Gold");
if (key.isEmpty()) { case 3: return QObject::tr("Silver");
key = file; case 4: return QObject::tr("Bronze");
} default: return {};
key = QDir::cleanPath(key).toCaseFolded();
if (!seen.contains(key)) {
seen.insert(key);
trophy_files.append(file);
}
} }
return trophy_files;
} }
static void PrepareTable(QTableWidget* table) { static void PrepareTable(QTableWidget* table) {
table->setColumnCount(4);
table->setHorizontalHeaderLabels({QObject::tr("Unlocked"), QObject::tr("Trophy"), table->setHorizontalHeaderLabels({QObject::tr("Unlocked"), QObject::tr("Trophy"),
QObject::tr("Name"), QObject::tr("Description")}); QObject::tr("Name"), QObject::tr("Description")});
table->setAlternatingRowColors(true); table->setAlternatingRowColors(true);
table->setEditTriggers(QAbstractItemView::NoEditTriggers); table->setEditTriggers(QAbstractItemView::NoEditTriggers);
table->setSelectionBehavior(QAbstractItemView::SelectRows); table->setSelectionBehavior(QAbstractItemView::SelectRows);
table->setSelectionMode(QAbstractItemView::SingleSelection); table->setSelectionMode(QAbstractItemView::SingleSelection);
table->setIconSize(QSize(96, 96));
table->setShowGrid(false); table->setShowGrid(false);
table->setWordWrap(true); table->setWordWrap(true);
table->verticalHeader()->setVisible(false); table->verticalHeader()->setVisible(false);
@@ -469,30 +69,16 @@ static void PrepareTable(QTableWidget* table) {
table->setColumnWidth(3, 480); table->setColumnWidth(3, 480);
} }
static QTableWidgetItem* CreateItem(const QString& text) { static QString TrophyTooltip(const Common::Trophies::Trophy& trophy) {
auto* item = new QTableWidgetItem(text); QStringList lines {QString::fromStdString(trophy.name),
item->setFlags(item->flags() & ~Qt::ItemIsEditable); QString::fromStdString(trophy.description),
return item; QObject::tr("Grade: %1").arg(GradeToText(trophy.grade))};
} if (trophy.hidden) {
static QString TrophyTooltip(const TrophyRow& row) {
QStringList lines;
lines.append(row.name);
if (!row.detail.isEmpty()) {
lines.append(row.detail);
}
const auto grade = GradeToText(row.grade);
if (!grade.isEmpty()) {
lines.append(QObject::tr("Grade: %1").arg(grade));
}
if (row.hidden) {
lines.append(QObject::tr("Hidden trophy")); lines.append(QObject::tr("Hidden trophy"));
} }
if (row.has_reward && !row.reward.isEmpty()) { if (trophy.has_reward && !trophy.reward.empty()) {
lines.append(QObject::tr("Reward: %1").arg(row.reward)); lines.append(QObject::tr("Reward: %1").arg(QString::fromStdString(trophy.reward)));
} }
return lines.join(QLatin1Char('\n')); return lines.join(QLatin1Char('\n'));
} }
@@ -516,7 +102,8 @@ bool TrophyViewerDialog::HasTrophyData(const Configuration* info) {
return !FindTrophyFiles(info).isEmpty(); return !FindTrophyFiles(info).isEmpty();
} }
void TrophyViewerDialog::ShowForGame(const Configuration* info, QWidget* parent) { void TrophyViewerDialog::ShowForGame(const Configuration* info, const QString& runtime_directory,
QWidget* parent) {
if (info == nullptr) { if (info == nullptr) {
return; return;
} }
@@ -527,7 +114,7 @@ void TrophyViewerDialog::ShowForGame(const Configuration* info, QWidget* parent)
} }
QString error; QString error;
if (!dlg.LoadGame(*info, error)) { if (!dlg.LoadGame(*info, runtime_directory, error)) {
QMessageBox::warning(parent, tr("Trophy Viewer"), error); QMessageBox::warning(parent, tr("Trophy Viewer"), error);
return; return;
} }
@@ -535,66 +122,71 @@ void TrophyViewerDialog::ShowForGame(const Configuration* info, QWidget* parent)
dlg.exec(); dlg.exec();
} }
bool TrophyViewerDialog::LoadGame(const Configuration& info, QString& error) { bool TrophyViewerDialog::LoadGame(const Configuration& info, const QString& runtime_directory,
const auto reader = Common::OpenArchive(GameContent::ToPath(info.basedir)); QString& error) {
const auto reader = Common::OpenArchive(GameContent::ToPath(info.basedir));
const auto trophy_files = FindTrophyFiles(&info); const auto trophy_files = FindTrophyFiles(&info);
if (trophy_files.isEmpty()) { if (trophy_files.isEmpty()) {
error = tr("No trophy package found in sce_sys/trophy2."); error = tr("No trophy package found in %1.")
.arg(QString::fromLatin1(Common::Trophies::PackageDirectory));
return false; return false;
} }
QStringList errors; QStringList errors;
for (const auto& file: trophy_files) { for (const auto& file: trophy_files) {
TrophySet set; const auto package =
QString set_error; Common::Trophies::LoadPackage(GameContent::ToPath(file), info.console_language);
if (!BuildTrophySet(file, set, set_error)) { if (package.trophies.empty()) {
errors.append(set_error); errors.append(tr("Could not read trophy package %1.").arg(QFileInfo(file).fileName()));
continue; continue;
} }
const auto label = TrophyFilePattern.match(QFileInfo(file).fileName()).captured(1).toUInt();
auto* table = new QTableWidget(set.trophies.size(), 4, m_tabs); 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<int>(package.trophies.size()), 4, m_tabs);
PrepareTable(table); PrepareTable(table);
int row_index = 0;
for (int row_index = 0; row_index < set.trophies.size(); row_index++) { for (const auto& [id, trophy]: package.trophies) {
const auto& row = set.trophies.at(row_index); const bool trophy_unlocked = unlocked.contains(id);
table->setRowHeight(row_index, 112); auto* status = new QTableWidgetItem(trophy_unlocked ? tr("UNLOCKED") : tr("LOCKED"));
auto* status = CreateItem(tr("locked"));
status->setTextAlignment(Qt::AlignCenter); status->setTextAlignment(Qt::AlignCenter);
if (row.hidden) { auto status_font = status->font();
status->setForeground(QBrush(Qt::gray)); status_font.setBold(true);
} status->setFont(status_font);
table->setItem(row_index, 0, status); table->setItem(row_index, 0, status);
auto* icon_label = new QLabel(table); auto* icon_item = new QTableWidgetItem;
icon_label->setAlignment(Qt::AlignCenter); icon_item->setTextAlignment(Qt::AlignCenter);
icon_label->setMinimumSize(QSize(108, 108)); QPixmap icon;
if (!row.icon.isNull()) { if (icon.loadFromData(reinterpret_cast<const uchar*>(trophy.icon_png.data()),
icon_label->setPixmap( static_cast<uint>(trophy.icon_png.size()))) {
row.icon.scaled(QSize(96, 96), Qt::KeepAspectRatio, Qt::SmoothTransformation)); if (!trophy_unlocked) {
icon = QPixmap::fromImage(
icon.toImage().convertToFormat(QImage::Format_Grayscale8));
}
icon_item->setIcon(QIcon(icon));
} }
table->setCellWidget(row_index, 1, icon_label); table->setItem(row_index, 1, icon_item);
auto* name = CreateItem(row.name); const auto name_text = trophy.name.empty() ? (trophy.hidden ? tr("Hidden Trophy")
auto font = name->font(); : tr("Trophy %1").arg(id))
: QString::fromStdString(trophy.name);
auto* name = new QTableWidgetItem(name_text);
auto font = name->font();
font.setBold(true); font.setBold(true);
name->setFont(font); name->setFont(font);
name->setToolTip(TrophyTooltip(row)); 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, 2, name);
auto* detail = CreateItem(row.detail);
detail->setToolTip(TrophyTooltip(row));
table->setItem(row_index, 3, detail); table->setItem(row_index, 3, detail);
++row_index;
} }
m_tabs->addTab(table, tr("Trophy %1").arg(label));
m_tabs->addTab(table, set.tab_title);
} }
if (m_tabs->count() == 0) { if (m_tabs->count() == 0) {
error = errors.isEmpty() ? tr("No readable trophy data found.") error = errors.join(QLatin1Char('\n'));
: errors.join(QLatin1Char('\n'));
return false; return false;
} }
return true; return true;
} }
+7 -1118
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7
View File
@@ -84,6 +84,8 @@ static void PrintUsage() {
::printf(" --spirv-debug-printf <true|false> Enable SPIR-V debug printf.\n"); ::printf(" --spirv-debug-printf <true|false> Enable SPIR-V debug printf.\n");
::printf( ::printf(
" --readback-linear-images <true|false> Read back writable linear images on submit.\n"); " --readback-linear-images <true|false> Read back writable linear images on submit.\n");
::printf(
" --trophy-notifications <true|false> Show trophy unlock toasts and play their sound.\n");
::printf(" --playgo-hack Use the supplied PlayGo stub fallback.\n"); ::printf(" --playgo-hack Use the supplied PlayGo stub fallback.\n");
#if KYTY_PLATFORM == KYTY_PLATFORM_WINDOWS #if KYTY_PLATFORM == KYTY_PLATFORM_WINDOWS
::printf(" --redzone Protect the guest SysV red zone.\n"); ::printf(" --redzone Protect the guest SysV red zone.\n");
@@ -421,6 +423,11 @@ static bool ParseArgs(int argc, char* argv[], RunOptions& options, bool& show_he
::printf("invalid boolean for %s: %s\n", arg.c_str(), value.c_str()); ::printf("invalid boolean for %s: %s\n", arg.c_str(), value.c_str());
return false; return false;
} }
} else if (arg == "--trophy-notifications") {
if (!ParseBool(value, options.config.trophy_enabled)) {
::printf("invalid boolean for %s: %s\n", arg.c_str(), value.c_str());
return false;
}
} else if (arg == "--keymap") { } else if (arg == "--keymap") {
const auto split = value.find('='); const auto split = value.find('=');
if (split == std::string::npos || split == 0 || split + 1 == value.size()) { if (split == std::string::npos || split == 0 || split + 1 == value.size()) {