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
+1
View File
@@ -96,6 +96,7 @@ install(TARGETS launcher
if(APPLE)
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.
find_program(KYTY_MACDEPLOYQT NAMES macdeployqt PATHS "${Qt6_DIR}/../../../bin" NO_DEFAULT_PATH)
if(NOT KYTY_MACDEPLOYQT)
@@ -6,7 +6,7 @@
<rect>
<x>0</x>
<y>0</y>
<width>760</width>
<width>840</width>
<height>620</height>
</rect>
</property>
@@ -182,7 +182,22 @@
</layout>
</widget>
</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">
<property name="title">
<string>Debugging &amp;&amp; logs</string>
+4
View File
@@ -122,6 +122,7 @@ public:
bool hide_cursor_enabled = false;
bool readback_linear_images = false;
bool tessellation_enabled = false;
bool trophy_enabled = true;
int vblank_frequency = 60;
int console_language = DEFAULT_CONSOLE_LANGUAGE;
bool vulkan_validation_enabled = false;
@@ -154,6 +155,7 @@ public:
hide_cursor_enabled = other.hide_cursor_enabled;
readback_linear_images = other.readback_linear_images;
tessellation_enabled = other.tessellation_enabled;
trophy_enabled = other.trophy_enabled;
vblank_frequency = other.vblank_frequency;
console_language = other.console_language;
vulkan_validation_enabled = other.vulkan_validation_enabled;
@@ -201,6 +203,7 @@ public:
KYTY_CFG_SET(hide_cursor_enabled);
KYTY_CFG_SET(readback_linear_images);
KYTY_CFG_SET(tessellation_enabled);
KYTY_CFG_SET(trophy_enabled);
KYTY_CFG_SET(vblank_frequency);
KYTY_CFG_SET(console_language);
KYTY_CFG_SET(vulkan_validation_enabled);
@@ -244,6 +247,7 @@ public:
KYTY_CFG_GET(hide_cursor_enabled);
KYTY_CFG_GET(readback_linear_images);
KYTY_CFG_GET(tessellation_enabled);
trophy_enabled = s->value("trophy_enabled", trophy_enabled).toBool();
vblank_frequency = s->value("vblank_frequency", vblank_frequency).toInt();
console_language = s->value("console_language", console_language).toInt();
if (console_language < 0 || console_language > MAX_CONSOLE_LANGUAGE) {
@@ -28,6 +28,7 @@ public:
~ConfigurationListWidget() override;
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; }
ConfigurationItem* GetSelectedItem() { return m_selected_item; }
@@ -78,6 +79,7 @@ private:
bool m_run_enabled = true;
Ui::ConfigurationListWidget* m_ui = nullptr;
QString m_settings_file;
QString m_runtime_directory;
QStringList m_game_dirs;
Configuration m_global_info;
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 MaxImageSize = uint64_t {32} << 20u;
inline constexpr uint64_t MaxTrophyPackageSize = uint64_t {128} << 20u;
[[nodiscard]] std::filesystem::path ToPath(const QString& path);
[[nodiscard]] QString FromPath(const std::filesystem::path& path);
+3 -2
View File
@@ -12,10 +12,11 @@ public:
explicit TrophyViewerDialog(QWidget* parent = nullptr);
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:
bool LoadGame(const Configuration& info, QString& error);
bool LoadGame(const Configuration& info, const QString& runtime_directory, QString& error);
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),
m_info(info) {
m_ui->setupUi(this);
setMinimumWidth(width());
InitGameDirectories();
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);
});
// 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);
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_readback->setChecked(info.readback_linear_images);
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->comboBox_console_language->clear();
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.readback_linear_images = ui.checkBox_readback->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.console_language = ui.comboBox_console_language->currentIndex();
info.vulkan_validation_enabled = ui.checkBox_vulkan_validation->isChecked();
+2 -1
View File
@@ -835,7 +835,8 @@ void ConfigurationListWidget::ViewTrophies() {
return;
}
TrophyViewerDialog::ShowForGame(&item->GetInfo(), this);
const auto config = CreateConfiguration(*item);
TrophyViewerDialog::ShowForGame(config.get(), m_runtime_directory, this);
}
void ConfigurationListWidget::open_game_folder() {
+2
View File
@@ -170,6 +170,7 @@ void MainDialogPrivate::FindInterpreter() {
}
bool found = QFile::exists(m_interpreter);
m_ui->widget->SetRuntimeDirectory(QFileInfo(m_interpreter).absolutePath());
if (found) {
m_ui->label_Interpreter->setText(tr("Emulator: ") + m_interpreter);
@@ -244,6 +245,7 @@ static QStringList CreateEmulatorArgs(const Configuration& info) {
args << "--hide-cursor";
}
args << "--readback-linear-images" << BoolArg(info.readback_linear_images);
args << "--trophy-notifications" << BoolArg(info.trophy_enabled);
if (info.tessellation_enabled) {
args << "--tessellation";
}
+70 -478
View File
@@ -1,462 +1,62 @@
#include "trophyViewerDialog.h"
#include "common/archive.h"
#include "common/trophies.h"
#include "configuration.h"
#include "gameContent.h"
#include <QAbstractItemView>
#include <QBrush>
#include <QByteArray>
#include <QDialogButtonBox>
#include <QDir>
#include <QFileInfo>
#include <QFont>
#include <QHeaderView>
#include <QIcon>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QJsonValue>
#include <QLabel>
#include <QMap>
#include <QImage>
#include <QMessageBox>
#include <QObject>
#include <QPixmap>
#include <QRegularExpression>
#include <QSet>
#include <QSize>
#include <QString>
#include <QStringList>
#include <QTabWidget>
#include <QTableWidget>
#include <QTableWidgetItem>
#include <QVBoxLayout>
#include <QVariant>
#include <QWidget>
#include <QtEndian>
#include <utility>
namespace {
constexpr quint32 UCP_MAGIC = 0xb228c60a;
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 const QRegularExpression TrophyFilePattern(QStringLiteral("^trophy(\\d+)\\.ucp$"));
static QStringList FindTrophyFiles(const Configuration* info) {
if (info == nullptr || info->basedir.isEmpty()) {
return {};
}
QStringList files;
for (const auto& file:
GameContent::ListFiles(info->basedir, QStringLiteral("sce_sys/trophy2"))) {
const auto name = QFileInfo(file).fileName();
if (name.startsWith(QStringLiteral("trophy"), Qt::CaseInsensitive) &&
name.endsWith(QStringLiteral(".ucp"), Qt::CaseInsensitive)) {
for (const auto& file: GameContent::ListFiles(
info->basedir, QString::fromLatin1(Common::Trophies::PackageDirectory))) {
if (TrophyFilePattern.match(QFileInfo(file).fileName()).hasMatch()) {
files.append(file);
}
}
files.sort(Qt::CaseInsensitive);
files.sort();
return files;
}
QStringList trophy_files;
QSet<QString> seen;
for (const auto& file: files) {
auto key = QFileInfo(file).canonicalFilePath();
if (key.isEmpty()) {
key = file;
}
key = QDir::cleanPath(key).toCaseFolded();
if (!seen.contains(key)) {
seen.insert(key);
trophy_files.append(file);
}
static QString GradeToText(int grade) {
switch (grade) {
case 1: return QObject::tr("Platinum");
case 2: return QObject::tr("Gold");
case 3: return QObject::tr("Silver");
case 4: return QObject::tr("Bronze");
default: return {};
}
return trophy_files;
}
static void PrepareTable(QTableWidget* table) {
table->setColumnCount(4);
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);
@@ -469,30 +69,16 @@ static void PrepareTable(QTableWidget* table) {
table->setColumnWidth(3, 480);
}
static QTableWidgetItem* CreateItem(const QString& text) {
auto* item = new QTableWidgetItem(text);
item->setFlags(item->flags() & ~Qt::ItemIsEditable);
return item;
}
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) {
static QString TrophyTooltip(const Common::Trophies::Trophy& trophy) {
QStringList lines {QString::fromStdString(trophy.name),
QString::fromStdString(trophy.description),
QObject::tr("Grade: %1").arg(GradeToText(trophy.grade))};
if (trophy.hidden) {
lines.append(QObject::tr("Hidden trophy"));
}
if (row.has_reward && !row.reward.isEmpty()) {
lines.append(QObject::tr("Reward: %1").arg(row.reward));
if (trophy.has_reward && !trophy.reward.empty()) {
lines.append(QObject::tr("Reward: %1").arg(QString::fromStdString(trophy.reward)));
}
return lines.join(QLatin1Char('\n'));
}
@@ -516,7 +102,8 @@ bool TrophyViewerDialog::HasTrophyData(const Configuration* info) {
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) {
return;
}
@@ -527,7 +114,7 @@ void TrophyViewerDialog::ShowForGame(const Configuration* info, QWidget* parent)
}
QString error;
if (!dlg.LoadGame(*info, error)) {
if (!dlg.LoadGame(*info, runtime_directory, error)) {
QMessageBox::warning(parent, tr("Trophy Viewer"), error);
return;
}
@@ -535,66 +122,71 @@ void TrophyViewerDialog::ShowForGame(const Configuration* info, QWidget* parent)
dlg.exec();
}
bool TrophyViewerDialog::LoadGame(const Configuration& info, QString& error) {
const auto reader = Common::OpenArchive(GameContent::ToPath(info.basedir));
bool TrophyViewerDialog::LoadGame(const Configuration& info, const QString& runtime_directory,
QString& error) {
const auto reader = Common::OpenArchive(GameContent::ToPath(info.basedir));
const auto trophy_files = FindTrophyFiles(&info);
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;
}
QStringList errors;
for (const auto& file: trophy_files) {
TrophySet set;
QString set_error;
if (!BuildTrophySet(file, set, set_error)) {
errors.append(set_error);
const auto package =
Common::Trophies::LoadPackage(GameContent::ToPath(file), info.console_language);
if (package.trophies.empty()) {
errors.append(tr("Could not read trophy package %1.").arg(QFileInfo(file).fileName()));
continue;
}
auto* table = new QTableWidget(set.trophies.size(), 4, m_tabs);
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<int>(package.trophies.size()), 4, m_tabs);
PrepareTable(table);
for (int row_index = 0; row_index < set.trophies.size(); row_index++) {
const auto& row = set.trophies.at(row_index);
table->setRowHeight(row_index, 112);
auto* status = CreateItem(tr("locked"));
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);
if (row.hidden) {
status->setForeground(QBrush(Qt::gray));
}
auto status_font = status->font();
status_font.setBold(true);
status->setFont(status_font);
table->setItem(row_index, 0, status);
auto* icon_label = new QLabel(table);
icon_label->setAlignment(Qt::AlignCenter);
icon_label->setMinimumSize(QSize(108, 108));
if (!row.icon.isNull()) {
icon_label->setPixmap(
row.icon.scaled(QSize(96, 96), Qt::KeepAspectRatio, Qt::SmoothTransformation));
auto* icon_item = new QTableWidgetItem;
icon_item->setTextAlignment(Qt::AlignCenter);
QPixmap icon;
if (icon.loadFromData(reinterpret_cast<const uchar*>(trophy.icon_png.data()),
static_cast<uint>(trophy.icon_png.size()))) {
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);
auto font = name->font();
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(row));
name->setToolTip(TrophyTooltip(trophy));
auto* detail = new QTableWidgetItem(QString::fromStdString(trophy.description));
detail->setToolTip(name->toolTip());
table->setItem(row_index, 2, name);
auto* detail = CreateItem(row.detail);
detail->setToolTip(TrophyTooltip(row));
table->setItem(row_index, 3, detail);
++row_index;
}
m_tabs->addTab(table, set.tab_title);
m_tabs->addTab(table, tr("Trophy %1").arg(label));
}
if (m_tabs->count() == 0) {
error = errors.isEmpty() ? tr("No readable trophy data found.")
: errors.join(QLatin1Char('\n'));
error = errors.join(QLatin1Char('\n'));
return false;
}
return true;
}