From 66e21a6245adeab427cd69795cf6f5fc32563129 Mon Sep 17 00:00:00 2001 From: nmzik Date: Sat, 24 Oct 2026 23:47:10 +0200 Subject: [PATCH] addcont: add user add-ons with --addcont and a launcher list --- src/common/additionalContent.cpp | 19 +++++ src/common/additionalContent.h | 7 +- src/emulator.cpp | 2 +- src/emulator.h | 12 +-- .../include/additionalContentDialog.h | 29 +++++-- src/launcher/include/configuration.h | 21 ++--- .../include/configurationListWidget.h | 1 + src/launcher/src/additionalContentDialog.cpp | 81 +++++++++++++++++-- src/launcher/src/configurationListWidget.cpp | 31 ++++++- src/launcher/src/mainDialog.cpp | 3 + src/libs/libNpEntitlementAccess.cpp | 5 +- src/loader/additionalContent.cpp | 23 ++++-- src/loader/additionalContent.h | 4 +- src/main.cpp | 11 +++ tests/KernelFileSystemTests.cpp | 38 ++++++++- 15 files changed, 242 insertions(+), 45 deletions(-) diff --git a/src/common/additionalContent.cpp b/src/common/additionalContent.cpp index 5a155f989..189025cd2 100644 --- a/src/common/additionalContent.cpp +++ b/src/common/additionalContent.cpp @@ -109,4 +109,23 @@ std::vector FindAddContent(const std::filesystem::path& dir, return entries; } +std::optional ParseAddContent(std::string_view value) { + static const std::regex label_pattern("^[A-Z0-9]{16}$"); + const auto eq = value.find('='); + const auto label = value.substr(0, eq); + if (!std::regex_match(label.begin(), label.end(), label_pattern)) { + return std::nullopt; + } + + AddContentEntry entry {{}, std::string(label), {}, {}}; + if (eq != std::string_view::npos) { + entry.mount_dir = PathFromUtf8(FixFilenameSlash(value.substr(eq + 1))); + std::error_code ec; + if (!std::filesystem::is_directory(entry.mount_dir, ec)) { + return std::nullopt; + } + } + return entry; +} + } // namespace Common diff --git a/src/common/additionalContent.h b/src/common/additionalContent.h index 84bdce057..44259ab3a 100644 --- a/src/common/additionalContent.h +++ b/src/common/additionalContent.h @@ -2,7 +2,9 @@ #define KYTY_COMMON_ADDITIONAL_CONTENT_H_ #include +#include #include +#include #include namespace Common { @@ -10,7 +12,7 @@ namespace Common { struct AddContentEntry { std::string content_id; std::string entitlement_label; - std::filesystem::path mount_dir; + std::filesystem::path mount_dir; // Empty for an add-on without extra data. std::string title; }; @@ -18,6 +20,9 @@ struct AddContentEntry { std::vector FindAddContent(const std::filesystem::path& dir, const std::string& title_id); +// Parses a user add-on: "LABEL" without extra data, or "LABEL=DIR" with its data in DIR. +std::optional ParseAddContent(std::string_view value); + } // namespace Common #endif /* KYTY_COMMON_ADDITIONAL_CONTENT_H_ */ diff --git a/src/emulator.cpp b/src/emulator.cpp index 94e41b7a4..7834a390b 100644 --- a/src/emulator.cpp +++ b/src/emulator.cpp @@ -188,7 +188,7 @@ void Run(const RunOptions& options) { MountSandboxDirs(); - Loader::AddContentLoad(options.addcont_dir, title_id); + Loader::AddContentLoad(options.addcont_dir, options.addcont, title_id); auto* rt = Common::Singleton::Instance(); Libs::InitAll(rt->Symbols()); diff --git a/src/emulator.h b/src/emulator.h index 6a2963767..abe54fef2 100644 --- a/src/emulator.h +++ b/src/emulator.h @@ -1,6 +1,7 @@ #ifndef EMULATOR_INCLUDE_EMULATOR_EMULATOR_H_ #define EMULATOR_INCLUDE_EMULATOR_EMULATOR_H_ +#include "common/additionalContent.h" #include "common/emulatorConfig.h" #include "common/stringUtils.h" @@ -9,11 +10,12 @@ namespace Emulator { struct RunOptions { - Config::ConfigOptions config; - std::filesystem::path app0_dir; - std::filesystem::path elf; - std::filesystem::path game_patch; - std::filesystem::path addcont_dir; + Config::ConfigOptions config; + std::filesystem::path app0_dir; + std::filesystem::path elf; + std::filesystem::path game_patch; + std::filesystem::path addcont_dir; + std::vector addcont; }; void Run(const RunOptions& options); diff --git a/src/launcher/include/additionalContentDialog.h b/src/launcher/include/additionalContentDialog.h index 2ef31aa24..cb2afb382 100644 --- a/src/launcher/include/additionalContentDialog.h +++ b/src/launcher/include/additionalContentDialog.h @@ -4,26 +4,39 @@ #include #include #include +#include + +#include class Configuration; class QLabel; +class QLineEdit; class QListWidget; class QPushButton; class AdditionalContentDialog final: public QDialog { public: - AdditionalContentDialog(const Configuration& game, const QString& folder, - QWidget* parent = nullptr); + // entries are the game's user add-ons, "LABEL" or "LABEL=DIR"; save receives every edit. + AdditionalContentDialog(const Configuration& game, const QString& folder, QStringList entries, + std::function save, + QWidget* parent = nullptr); private: void Scan(); + void Add(); + void Remove(); - QString m_title_id; - QString m_folder; - QIcon m_game_icon; - QLabel* m_status = nullptr; - QListWidget* m_list = nullptr; - QPushButton* m_open = nullptr; + QString m_title_id; + QString m_folder; + QStringList m_entries; + std::function m_save; + QIcon m_game_icon; + QLabel* m_status = nullptr; + QListWidget* m_list = nullptr; + QPushButton* m_open = nullptr; + QPushButton* m_remove = nullptr; + QLineEdit* m_label_edit = nullptr; + QLineEdit* m_dir_edit = nullptr; }; #endif // ADDITIONAL_CONTENT_DIALOG_H diff --git a/src/launcher/include/configuration.h b/src/launcher/include/configuration.h index b1a0c07c4..afa364909 100644 --- a/src/launcher/include/configuration.h +++ b/src/launcher/include/configuration.h @@ -102,16 +102,17 @@ public: Configuration() = default; - QString name; - QString title_id; /* Serial / title id from sce_sys/param.json */ - QString gameVersion; /* appVersion / contentVersion from sce_sys/param.json */ - QString firmwareVer; /* requiredSystemSoftwareVersion from sce_sys/param.json */ - QString basedir; /* Game base directory */ - QString game_path; /* Launcher-unique game path */ - QString addons_dir; /* Global setting; not saved per game. */ - bool custom_settings = false; - GameStatus game_status = GameStatus::Unknown; - QString game_comment; + QString name; + QString title_id; /* Serial / title id from sce_sys/param.json */ + QString gameVersion; /* appVersion / contentVersion from sce_sys/param.json */ + QString firmwareVer; /* requiredSystemSoftwareVersion from sce_sys/param.json */ + QString basedir; /* Game base directory */ + QString game_path; /* Launcher-unique game path */ + QString addons_dir; /* Global setting; not saved per game. */ + QStringList addcont; /* User add-ons ("LABEL" or "LABEL=DIR"), saved by title ID. */ + bool custom_settings = false; + GameStatus game_status = GameStatus::Unknown; + QString game_comment; // Controller preferences always come from the global configuration. ControllerSettings controller; diff --git a/src/launcher/include/configurationListWidget.h b/src/launcher/include/configurationListWidget.h index d6e0353c1..aa07f58f0 100644 --- a/src/launcher/include/configurationListWidget.h +++ b/src/launcher/include/configurationListWidget.h @@ -87,6 +87,7 @@ private: QString m_runtime_directory; QStringList m_game_dirs; QString m_addons_dir; + QMap m_addcont; // User add-ons by title ID. Configuration m_global_info; QMap m_custom_infos; CompatibilityDatabase* m_compatibility = nullptr; diff --git a/src/launcher/src/additionalContentDialog.cpp b/src/launcher/src/additionalContentDialog.cpp index 3230da986..dc5fca74f 100644 --- a/src/launcher/src/additionalContentDialog.cpp +++ b/src/launcher/src/additionalContentDialog.cpp @@ -7,7 +7,10 @@ #include #include #include +#include +#include #include +#include #include #include #include @@ -16,8 +19,11 @@ #include AdditionalContentDialog::AdditionalContentDialog(const Configuration& game, const QString& folder, - QWidget* parent) - : QDialog(parent), m_title_id(game.title_id.trimmed().toUpper()), m_folder(folder) { + QStringList entries, + std::function save, + QWidget* parent) + : QDialog(parent), m_title_id(game.title_id.trimmed().toUpper()), m_folder(folder), + m_entries(std::move(entries)), m_save(std::move(save)) { setAttribute(Qt::WA_DeleteOnClose); setWindowTitle(tr("Additional Content - %1").arg(game.name)); resize(720, 480); @@ -39,6 +45,28 @@ AdditionalContentDialog::AdditionalContentDialog(const Configuration& game, cons m_list->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); layout->addWidget(m_list, 1); + auto* add_layout = new QHBoxLayout; + m_label_edit = new QLineEdit(this); + m_label_edit->setPlaceholderText(tr("Label or content ID")); + m_dir_edit = new QLineEdit(this); + m_dir_edit->setPlaceholderText(tr("Add-on data folder (empty for a license only)")); + auto* browse = new QPushButton(tr("Browse..."), this); + auto* add = new QPushButton(tr("Add"), this); + add->setDefault(true); + add_layout->addWidget(m_label_edit, 1); + add_layout->addWidget(m_dir_edit, 2); + add_layout->addWidget(browse); + add_layout->addWidget(add); + layout->addLayout(add_layout); + connect(browse, &QPushButton::clicked, this, [this]() { + const auto dir = + QFileDialog::getExistingDirectory(this, tr("Add-on data folder"), m_dir_edit->text()); + if (!dir.isEmpty()) { + m_dir_edit->setText(QDir::toNativeSeparators(dir)); + } + }); + connect(add, &QPushButton::clicked, this, &AdditionalContentDialog::Add); + m_status = new QLabel(this); m_status->setTextFormat(Qt::PlainText); m_status->setWordWrap(true); @@ -47,10 +75,15 @@ AdditionalContentDialog::AdditionalContentDialog(const Configuration& game, cons auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close, this); auto* refresh = buttons->addButton(tr("Refresh"), QDialogButtonBox::ActionRole); m_open = buttons->addButton(tr("Open folder"), QDialogButtonBox::ActionRole); + m_remove = buttons->addButton(tr("Remove"), QDialogButtonBox::ActionRole); layout->addWidget(buttons); connect(refresh, &QPushButton::clicked, this, &AdditionalContentDialog::Scan); connect(m_open, &QPushButton::clicked, this, [this]() { QDesktopServices::openUrl(QUrl::fromLocalFile(m_folder)); }); + connect(m_remove, &QPushButton::clicked, this, &AdditionalContentDialog::Remove); + connect(m_list, &QListWidget::currentItemChanged, this, [this](const QListWidgetItem* item) { + m_remove->setEnabled(item != nullptr && item->data(Qt::UserRole).isValid()); + }); connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::close); Scan(); @@ -73,18 +106,54 @@ void AdditionalContentDialog::Scan() { tr("%1\nInstalled").arg(title), m_list); item->setToolTip(QString::fromStdString(entry.content_id)); } + for (const auto& entry: m_entries) { + const auto label = entry.section('=', 0, 0); + const auto dir = entry.section('=', 1); + auto text = tr("%1\nLicense only").arg(label); + if (!dir.isEmpty()) { + text = (QDir(dir).exists() ? tr("%1\nData: %2") : tr("%1\nData folder not found: %2")) + .arg(label, QDir::toNativeSeparators(dir)); + } + auto* item = new QListWidgetItem(m_game_icon, text, m_list); + item->setData(Qt::UserRole, entry); + } + m_remove->setEnabled(false); - if (m_folder.isEmpty()) { + if (m_list->count() != 0) { + m_status->setText(tr("%n add-on(s)", nullptr, m_list->count())); + } else if (m_folder.isEmpty()) { m_status->setText(tr("No add-ons folder is set. Open Global settings and set the " "Add-ons folder to the location of your extracted add-on packages.")); } else if (!folder_exists) { m_status->setText(tr("The add-ons folder does not exist:\n%1\nCheck the path in Global " "settings.") .arg(QDir::toNativeSeparators(m_folder))); - } else if (m_list->count() == 0) { + } else { m_status->setText( tr("No add-ons found for this game in:\n%1").arg(QDir::toNativeSeparators(m_folder))); - } else { - m_status->setText(tr("%n installed add-on(s)", nullptr, m_list->count())); } } + +void AdditionalContentDialog::Add() { + // A content ID ends with its entitlement label. + const auto label = m_label_edit->text().trimmed().section('-', -1); + const auto dir = m_dir_edit->text().trimmed(); + const auto entry = dir.isEmpty() ? label : label + '=' + QDir(dir).absolutePath(); + if (!Common::ParseAddContent(entry.toStdString())) { + m_status->setText(tr("Enter a 16-character entitlement label (A-Z, 0-9) and an existing " + "data folder, or no folder for a license only.")); + return; + } + m_entries.removeIf([&](const QString& e) { return e.section('=', 0, 0) == label; }); + m_entries.append(entry); + m_save(m_entries); + m_label_edit->clear(); + m_dir_edit->clear(); + Scan(); +} + +void AdditionalContentDialog::Remove() { + m_entries.removeOne(m_list->currentItem()->data(Qt::UserRole).toString()); + m_save(m_entries); + Scan(); +} diff --git a/src/launcher/src/configurationListWidget.cpp b/src/launcher/src/configurationListWidget.cpp index 10fb67943..bbda3dde2 100644 --- a/src/launcher/src/configurationListWidget.cpp +++ b/src/launcher/src/configurationListWidget.cpp @@ -79,6 +79,7 @@ constexpr char CONF_LAUNCHER[] = "Launcher"; constexpr char CONF_GAME_DIR[] = "game_dir"; constexpr char CONF_GAME_DIRS[] = "game_dirs"; constexpr char CONF_ADDONS_DIR[] = "addons_dir"; +constexpr char CONF_ADDCONT[] = "AdditionalContent"; constexpr char CONF_GLOBAL[] = "GlobalConfiguration"; constexpr char SAVE_DATA_DIR[] = "_SaveData"; @@ -348,6 +349,13 @@ void ConfigurationListWidget::WriteSettings() { s->remove(CONF_GAME_DIR); s->endGroup(); + s->remove(CONF_ADDCONT); + s->beginGroup(CONF_ADDCONT); + for (auto it = m_addcont.constBegin(); it != m_addcont.constEnd(); ++it) { + s->setValue(it.key(), it.value()); + } + s->endGroup(); + s->remove(CONF_GLOBAL); s->beginGroup(CONF_GLOBAL); m_global_info.WriteSettings(s.get()); @@ -395,6 +403,13 @@ void ConfigurationListWidget::ReadSettings() { m_addons_dir = NormalizeGameDirectory(s->value(CONF_ADDONS_DIR).toString()); s->endGroup(); + m_addcont.clear(); + s->beginGroup(CONF_ADDCONT); + for (const auto& title_id: s->childKeys()) { + m_addcont.insert(title_id, SettingsStringList(s->value(title_id))); + } + s->endGroup(); + s->beginGroup(CONF_GLOBAL); if (!s->childKeys().isEmpty()) { m_global_info.ReadSettings(s.get()); @@ -458,6 +473,7 @@ ConfigurationListWidget::CreateConfiguration(const ConfigurationItem& item) cons } info->host_input_mapping = m_global_info.host_input_mapping; info->addons_dir = m_addons_dir; + info->addcont = m_addcont.value(item.GetInfo().title_id.trimmed().toUpper()); return info; } @@ -1273,11 +1289,22 @@ void ConfigurationListWidget::ShowContextMenu(QTreeWidgetItem* witem) { connect(action_addcont, &QAction::triggered, this, [this, item = QPointer(item)]() { if (item != nullptr) { - auto* dialog = new AdditionalContentDialog(item->GetInfo(), m_addons_dir, this); + const auto title_id = item->GetInfo().title_id.trimmed().toUpper(); + auto* dialog = new AdditionalContentDialog( + item->GetInfo(), m_addons_dir, m_addcont.value(title_id), + [this, title_id](const QStringList& entries) { + if (entries.isEmpty()) { + m_addcont.remove(title_id); + } else { + m_addcont.insert(title_id, entries); + } + WriteSettings(); + }, + this); dialog->show(); } }); - action_addcont->setEnabled(item != nullptr); + action_addcont->setEnabled(item != nullptr && !item->GetInfo().title_id.trimmed().isEmpty()); QAction* action_compress = menu.addAction(tr("Compress to ZAR..."), this, SLOT(compress_game())); QAction* action_remove_save_data = diff --git a/src/launcher/src/mainDialog.cpp b/src/launcher/src/mainDialog.cpp index 6f6fa3479..3a2b42a07 100644 --- a/src/launcher/src/mainDialog.cpp +++ b/src/launcher/src/mainDialog.cpp @@ -305,6 +305,9 @@ static QStringList CreateEmulatorArgs(const Configuration& info) { if (!info.addons_dir.isEmpty()) { args << "--addcont-dir" << info.addons_dir; } + for (const auto& entry: info.addcont) { + args << "--addcont" << entry; + } const auto patch_plan = Cheats::PlanPath(info.title_id); if (QFileInfo::exists(patch_plan)) { diff --git a/src/libs/libNpEntitlementAccess.cpp b/src/libs/libNpEntitlementAccess.cpp index b3b3e1d31..3c130517d 100644 --- a/src/libs/libNpEntitlementAccess.cpp +++ b/src/libs/libNpEntitlementAccess.cpp @@ -172,8 +172,9 @@ MakeAddcontInfo(const Loader::AddContentEntry& entry) { NpEntitlementAccessAddcontEntitlementInfo info {}; std::memcpy(info.entitlement_label.data, entry.entitlement_label.data(), entry.entitlement_label.size()); - info.package_type = 2; // Additional content with extra data. - info.download_status = 4; // Installed. + const bool extra_data = !entry.mount_dir.empty(); + info.package_type = extra_data ? 2 : 3; // PSAC (with extra data) or PSAL (license only). + info.download_status = extra_data ? 4 : 0; // INSTALLED or NO_EXTRA_DATA. return info; } diff --git a/src/loader/additionalContent.cpp b/src/loader/additionalContent.cpp index 47fd34e1d..436fadc3f 100644 --- a/src/loader/additionalContent.cpp +++ b/src/loader/additionalContent.cpp @@ -10,10 +10,24 @@ namespace Loader { static std::vector g_add_content; -void AddContentLoad(const std::filesystem::path& dir, const std::string& title_id) { +static auto FindLabel(std::string_view label) { + return std::find_if(g_add_content.begin(), g_add_content.end(), + [&](const auto& entry) { return entry.entitlement_label == label; }); +} + +void AddContentLoad(const std::filesystem::path& dir, const std::vector& user, + const std::string& title_id) { g_add_content = Common::FindAddContent(dir, title_id); + for (const auto& entry: user) { + const auto it = FindLabel(entry.entitlement_label); + if (it == g_add_content.end()) { + g_add_content.push_back(entry); + } else { + *it = entry; + } + } for (const auto& entry: g_add_content) { - LOGF("AddContent: registered %s -> %s\n", entry.content_id.c_str(), + LOGF("AddContent: registered %s -> %s\n", entry.entitlement_label.c_str(), Common::PathToString(entry.mount_dir).c_str()); } } @@ -26,10 +40,7 @@ const AddContentEntry* AddContentFind(const char* label, size_t label_size) { if (label == nullptr || label_size == 0) { return nullptr; } - const std::string_view value(label, std::find(label, label + label_size, '\0') - label); - const auto it = - std::find_if(g_add_content.begin(), g_add_content.end(), - [&](const auto& entry) { return entry.entitlement_label == value; }); + const auto it = FindLabel({label, std::find(label, label + label_size, '\0')}); return it == g_add_content.end() ? nullptr : &*it; } diff --git a/src/loader/additionalContent.h b/src/loader/additionalContent.h index 41803e0da..589b59b0a 100644 --- a/src/loader/additionalContent.h +++ b/src/loader/additionalContent.h @@ -9,7 +9,9 @@ namespace Loader { using AddContentEntry = Common::AddContentEntry; -void AddContentLoad(const std::filesystem::path& dir, const std::string& title_id); +// User entries replace detected packages with the same label. +void AddContentLoad(const std::filesystem::path& dir, const std::vector& user, + const std::string& title_id); const std::vector& AddContentGetAll(); const AddContentEntry* AddContentFind(const char* label, size_t label_size); diff --git a/src/main.cpp b/src/main.cpp index a2cc5c03c..7a3795799 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -48,6 +48,9 @@ static void PrintUsage() { ::printf(" --game Game directory, ELF, or ZArchive to load.\n"); ::printf(" --game-patch ETAHen cheat file.\n"); ::printf(" --addcont-dir Add-ons (DLC) folder (default: none).\n"); + ::printf( + " --addcont