mirror of
https://github.com/KytyPS5/KytyPS5.git
synced 2026-10-28 08:13:05 +00:00
Add trophy notification support
Co-authored-by: Felipe <54341095+Pipe530@users.noreply.github.com>
This commit is contained in:
1 parent
1d55272467
commit
72e4989b1e
22 files changed
+1967
-1618
No files matched your search
@@ -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 && logs</string>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user