From 4242ec5a64fd278a549c5e02454718e00c760bc5 Mon Sep 17 00:00:00 2001 From: Felipe <54341095+Pipe530@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:09:39 -0300 Subject: [PATCH] Add add-on (DLC) support (#1519) Add add-on content support Co-authored-by: nmzik --- src/common/additionalContent.cpp | 112 ++++++++++++++ src/common/additionalContent.h | 23 +++ src/emulator.cpp | 12 +- src/emulator.h | 1 + .../include/additionalContentDialog.h | 29 ++++ src/launcher/include/configuration.h | 1 + .../include/configurationEditDialog.h | 5 +- .../include/configurationListWidget.h | 1 + src/launcher/src/additionalContentDialog.cpp | 90 +++++++++++ src/launcher/src/configurationEditDialog.cpp | 32 +++- src/launcher/src/configurationListWidget.cpp | 26 +++- src/launcher/src/mainDialog.cpp | 3 + src/libs/libAppContent.cpp | 80 +++++++++- src/libs/libNpEntitlementAccess.cpp | 31 +++- src/loader/additionalContent.cpp | 36 +++++ src/loader/additionalContent.h | 18 +++ src/main.cpp | 7 + tests/KernelFileSystemTests.cpp | 143 ++++++++++++++++++ 18 files changed, 633 insertions(+), 17 deletions(-) create mode 100644 src/common/additionalContent.cpp create mode 100644 src/common/additionalContent.h create mode 100644 src/launcher/include/additionalContentDialog.h create mode 100644 src/launcher/src/additionalContentDialog.cpp create mode 100644 src/loader/additionalContent.cpp create mode 100644 src/loader/additionalContent.h diff --git a/src/common/additionalContent.cpp b/src/common/additionalContent.cpp new file mode 100644 index 000000000..5a155f989 --- /dev/null +++ b/src/common/additionalContent.cpp @@ -0,0 +1,112 @@ +#include "common/additionalContent.h" + +#include "common/stringUtils.h" + +#include +#include +#include +#include +#include +#include + +namespace Common { + +namespace { + +std::string GetString(const nlohmann::json& object, const std::string& key) { + const auto it = object.find(key); + return it != object.end() && it->is_string() ? it->get() : std::string {}; +} + +std::string GetTitle(const nlohmann::json& param) { + const auto localized = param.find("localizedParameters"); + if (localized != param.end() && localized->is_object()) { + const auto preferred = localized->find(GetString(*localized, "defaultLanguage")); + if (preferred != localized->end()) { + if (auto title = GetString(*preferred, "titleName"); !title.empty()) { + return title; + } + } + for (const auto& language: *localized) { + if (auto title = GetString(language, "titleName"); !title.empty()) { + return title; + } + } + } + return GetString(param, "titleName"); +} + +} // namespace + +std::vector FindAddContent(const std::filesystem::path& dir, + const std::string& title_id) { + if (dir.empty() || title_id.empty()) { + return {}; + } + + std::error_code ec; + const auto root = std::filesystem::absolute(dir, ec); + if (ec) { + return {}; + } + + // Scan directories separately so an unreadable child does not discard its siblings. + std::vector pending {root}; + std::vector params; + while (!pending.empty()) { + const auto current = std::move(pending.back()); + pending.pop_back(); + std::filesystem::directory_iterator it( + current, std::filesystem::directory_options::skip_permission_denied, ec); + const std::filesystem::directory_iterator end; + for (; !ec && it != end; it.increment(ec)) { + std::error_code entry_ec; + if (it->is_directory(entry_ec) && !it->is_symlink(entry_ec) && !entry_ec) { + pending.push_back(it->path()); + } else if (it->is_regular_file(entry_ec) && !entry_ec && + EqualNoCase(PathToString(it->path().filename()), "param.json") && + EqualNoCase(PathToString(it->path().parent_path().filename()), "sce_sys")) { + params.push_back(it->path()); + } + } + } + std::sort(params.begin(), params.end()); + + static const std::regex content_id_pattern( + "^[A-Z]{2}[0-9]{4}-[A-Z]{4}[0-9]{5}_[0-9]{2}-[A-Z0-9]{16}$"); + std::map by_label; + for (const auto& file: params) { + const auto size = std::filesystem::file_size(file, ec); + if (ec || size > 1024 * 1024) { + continue; + } + std::ifstream stream(file, std::ios::binary); + if (!stream) { + continue; + } + const auto param = nlohmann::json::parse(stream, nullptr, false); + // applicationCategoryType is required for applications, not additional content. + if (!param.is_object() || param.contains("applicationCategoryType")) { + continue; + } + const auto content_id = GetString(param, "contentId"); + if (!std::regex_match(content_id, content_id_pattern) || + content_id.compare(7, 9, title_id) != 0) { + continue; + } + + const auto mount_dir = file.parent_path().parent_path(); + const auto label = content_id.substr(20); + by_label.try_emplace(label, + AddContentEntry {content_id, label, mount_dir, GetTitle(param)}); + } + + std::vector entries; + entries.reserve(by_label.size()); + for (auto& [label, entry]: by_label) { + entries.push_back(std::move(entry)); + } + return entries; +} + +} // namespace Common diff --git a/src/common/additionalContent.h b/src/common/additionalContent.h new file mode 100644 index 000000000..84bdce057 --- /dev/null +++ b/src/common/additionalContent.h @@ -0,0 +1,23 @@ +#ifndef KYTY_COMMON_ADDITIONAL_CONTENT_H_ +#define KYTY_COMMON_ADDITIONAL_CONTENT_H_ + +#include +#include +#include + +namespace Common { + +struct AddContentEntry { + std::string content_id; + std::string entitlement_label; + std::filesystem::path mount_dir; + std::string title; +}; + +// Finds packages for this title, deduplicated by entitlement label. +std::vector FindAddContent(const std::filesystem::path& dir, + const std::string& title_id); + +} // namespace Common + +#endif /* KYTY_COMMON_ADDITIONAL_CONTENT_H_ */ diff --git a/src/emulator.cpp b/src/emulator.cpp index f522742ba..2929c04e7 100644 --- a/src/emulator.cpp +++ b/src/emulator.cpp @@ -22,6 +22,7 @@ #include "libs/controller.h" #include "libs/libs.h" #include "libs/network.h" +#include "loader/additionalContent.h" #include "loader/runtimeLinker.h" #include "loader/systemContent.h" #include "loader/timer.h" @@ -201,7 +202,15 @@ void Run(const RunOptions& options) { PrintSystemInfo(); std::string title_id; - if (Loader::SystemContentParamSfoGetString("TITLE_ID", &title_id) && !title_id.empty()) { + Loader::SystemContentParamSfoGetString("TITLE_ID", &title_id); + if (title_id.empty()) { + std::string content_id; + if (Loader::SystemContentParamSfoGetString("CONTENT_ID", &content_id) && + content_id.size() == 36 && content_id[6] == '-' && content_id[16] == '_') { + title_id = content_id.substr(7, 9); + } + } + if (!title_id.empty()) { Log::WriteToConsoleAndLog(fmt::format("Title ID: {}\n", title_id)); } @@ -217,6 +226,7 @@ void Run(const RunOptions& options) { MountSandboxDirs(); + Loader::AddContentLoad(options.addcont_dir, title_id); auto* rt = Common::Singleton::Instance(); Libs::InitAll(rt->Symbols()); diff --git a/src/emulator.h b/src/emulator.h index 3d9965e0c..6a2963767 100644 --- a/src/emulator.h +++ b/src/emulator.h @@ -13,6 +13,7 @@ struct RunOptions { std::filesystem::path app0_dir; std::filesystem::path elf; std::filesystem::path game_patch; + std::filesystem::path addcont_dir; }; void Run(const RunOptions& options); diff --git a/src/launcher/include/additionalContentDialog.h b/src/launcher/include/additionalContentDialog.h new file mode 100644 index 000000000..2ef31aa24 --- /dev/null +++ b/src/launcher/include/additionalContentDialog.h @@ -0,0 +1,29 @@ +#ifndef ADDITIONAL_CONTENT_DIALOG_H +#define ADDITIONAL_CONTENT_DIALOG_H + +#include +#include +#include + +class Configuration; +class QLabel; +class QListWidget; +class QPushButton; + +class AdditionalContentDialog final: public QDialog { +public: + AdditionalContentDialog(const Configuration& game, const QString& folder, + QWidget* parent = nullptr); + +private: + void Scan(); + + QString m_title_id; + QString m_folder; + QIcon m_game_icon; + QLabel* m_status = nullptr; + QListWidget* m_list = nullptr; + QPushButton* m_open = nullptr; +}; + +#endif // ADDITIONAL_CONTENT_DIALOG_H diff --git a/src/launcher/include/configuration.h b/src/launcher/include/configuration.h index 2f743c3b4..51cdca0eb 100644 --- a/src/launcher/include/configuration.h +++ b/src/launcher/include/configuration.h @@ -103,6 +103,7 @@ public: 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; diff --git a/src/launcher/include/configurationEditDialog.h b/src/launcher/include/configurationEditDialog.h index c9292d4e7..2408e9f21 100644 --- a/src/launcher/include/configurationEditDialog.h +++ b/src/launcher/include/configurationEditDialog.h @@ -6,6 +6,7 @@ #include class QByteArray; class QGroupBox; +class QLineEdit; class QListWidget; class QMoveEvent; class QResizeEvent; @@ -28,7 +29,8 @@ public: static void WriteSettings(QSettings& s); static void ReadSettings(QSettings& s); - void SetGlobalSettings(const QStringList& dirs); + void SetGlobalSettings(const QStringList& dirs, const QString& addons_dir); + [[nodiscard]] QString GetAddonsDirectory() const; [[nodiscard]] QStringList GetGameDirectories() const; signals: @@ -41,6 +43,7 @@ private: Configuration& m_info; QGroupBox* m_game_dirs_group = nullptr; QListWidget* m_game_dirs_list = nullptr; + QLineEdit* m_addons_dir_edit = nullptr; QToolButton* m_remove_game_dir_button = nullptr; bool m_global_settings = false; diff --git a/src/launcher/include/configurationListWidget.h b/src/launcher/include/configurationListWidget.h index 2f5ef2e45..6f83df131 100644 --- a/src/launcher/include/configurationListWidget.h +++ b/src/launcher/include/configurationListWidget.h @@ -85,6 +85,7 @@ private: QString m_settings_file; QString m_runtime_directory; QStringList m_game_dirs; + QString m_addons_dir; 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 new file mode 100644 index 000000000..3230da986 --- /dev/null +++ b/src/launcher/src/additionalContentDialog.cpp @@ -0,0 +1,90 @@ +#include "additionalContentDialog.h" + +#include "common/additionalContent.h" +#include "configuration.h" +#include "gameContent.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +AdditionalContentDialog::AdditionalContentDialog(const Configuration& game, const QString& folder, + QWidget* parent) + : QDialog(parent), m_title_id(game.title_id.trimmed().toUpper()), m_folder(folder) { + setAttribute(Qt::WA_DeleteOnClose); + setWindowTitle(tr("Additional Content - %1").arg(game.name)); + resize(720, 480); + + QPixmap icon; + icon.loadFromData(GameContent::ReadFile(game.basedir, QStringLiteral("sce_sys/icon0.png"), + GameContent::MaxImageSize)); + m_game_icon = icon.isNull() ? style()->standardIcon(QStyle::SP_FileIcon) : QIcon(icon); + + auto* layout = new QVBoxLayout(this); + auto* name = new QLabel(tr("%1 ยท %2").arg(game.name, m_title_id), this); + name->setTextFormat(Qt::PlainText); + name->setWordWrap(true); + layout->addWidget(name); + + m_list = new QListWidget(this); + m_list->setIconSize(QSize(64, 64)); + m_list->setSpacing(4); + m_list->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + layout->addWidget(m_list, 1); + + m_status = new QLabel(this); + m_status->setTextFormat(Qt::PlainText); + m_status->setWordWrap(true); + layout->addWidget(m_status); + + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close, this); + auto* refresh = buttons->addButton(tr("Refresh"), QDialogButtonBox::ActionRole); + m_open = buttons->addButton(tr("Open folder"), 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(buttons, &QDialogButtonBox::rejected, this, &QDialog::close); + + Scan(); +} + +void AdditionalContentDialog::Scan() { + m_list->clear(); + const bool folder_exists = !m_folder.isEmpty() && QDir(m_folder).exists(); + m_open->setEnabled(folder_exists); + + for (const auto& entry: + Common::FindAddContent(GameContent::ToPath(m_folder), m_title_id.toStdString())) { + QPixmap icon; + icon.loadFromData(GameContent::ReadFile(GameContent::FromPath(entry.mount_dir), + QStringLiteral("sce_sys/icon0.png"), + GameContent::MaxImageSize)); + const auto title = + QString::fromStdString(entry.title.empty() ? entry.entitlement_label : entry.title); + auto* item = new QListWidgetItem(icon.isNull() ? m_game_icon : QIcon(icon), + tr("%1\nInstalled").arg(title), m_list); + item->setToolTip(QString::fromStdString(entry.content_id)); + } + + 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) { + 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())); + } +} diff --git a/src/launcher/src/configurationEditDialog.cpp b/src/launcher/src/configurationEditDialog.cpp index 65e1b195a..c1aa5c9ab 100644 --- a/src/launcher/src/configurationEditDialog.cpp +++ b/src/launcher/src/configurationEditDialog.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -347,6 +348,28 @@ void ConfigurationEditDialog::InitGameDirectories() { button_layout->addStretch(1); group_layout->addLayout(button_layout); + group_layout->addWidget(new QLabel(tr("Add-ons folder"), m_game_dirs_group)); + auto* addons_layout = new QHBoxLayout; + addons_layout->setContentsMargins(0, 0, 0, 0); + addons_layout->setSpacing(4); + m_addons_dir_edit = new QLineEdit(m_game_dirs_group); + m_addons_dir_edit->setPlaceholderText(tr("Folder containing extracted add-on (DLC) packages")); + addons_layout->addWidget(m_addons_dir_edit, 1); + auto* addons_button = new QToolButton(m_game_dirs_group); + addons_button->setIcon(style()->standardIcon(QStyle::SP_DirOpenIcon)); + addons_button->setToolTip(tr("Select add-ons folder")); + addons_button->setAutoRaise(true); + addons_layout->addWidget(addons_button); + group_layout->addLayout(addons_layout); + connect(addons_button, &QToolButton::clicked, this, [this]() { + const auto dir = QFileDialog::getExistingDirectory( + this, tr("Select add-ons folder"), + m_addons_dir_edit->text().isEmpty() ? QDir::homePath() : m_addons_dir_edit->text()); + if (!dir.isEmpty()) { + m_addons_dir_edit->setText(QDir::toNativeSeparators(dir)); + } + }); + m_ui->gridLayout->addWidget(m_game_dirs_group, 1, 0, 1, 1); m_game_dirs_group->setVisible(false); @@ -359,9 +382,10 @@ void ConfigurationEditDialog::InitGameDirectories() { update_game_directory_buttons(); } -void ConfigurationEditDialog::SetGlobalSettings(const QStringList& dirs) { +void ConfigurationEditDialog::SetGlobalSettings(const QStringList& dirs, const QString& addons_dir) { m_global_settings = true; - auto* menu = new QMenu(m_ui->settings_button); + m_addons_dir_edit->setText(QDir::toNativeSeparators(addons_dir)); + auto* menu = new QMenu(m_ui->settings_button); menu->addAction(tr("Import..."), this, &ConfigurationEditDialog::ImportGameSettings); menu->addAction(tr("Export..."), this, &ConfigurationEditDialog::ExportGameSettings); m_ui->settings_button->setMenu(menu); @@ -380,6 +404,10 @@ void ConfigurationEditDialog::SetGlobalSettings(const QStringList& dirs) { resize(size().expandedTo(minimumSizeHint())); } +QString ConfigurationEditDialog::GetAddonsDirectory() const { + return m_global_settings ? m_addons_dir_edit->text().trimmed() : QString(); +} + QStringList ConfigurationEditDialog::GetGameDirectories() const { QStringList dirs; if (!m_global_settings) { diff --git a/src/launcher/src/configurationListWidget.cpp b/src/launcher/src/configurationListWidget.cpp index 9501c805a..2c7f3e1ff 100644 --- a/src/launcher/src/configurationListWidget.cpp +++ b/src/launcher/src/configurationListWidget.cpp @@ -1,5 +1,6 @@ #include "configurationListWidget.h" +#include "additionalContentDialog.h" #include "cheatFile.h" #include "common/archive.h" #include "compatibilityDatabase.h" @@ -73,6 +74,7 @@ constexpr char CONF_SECTION_NAME[] = "GameConfigurations"; 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_GLOBAL[] = "GlobalConfiguration"; constexpr char SAVE_DATA_DIR[] = "_SaveData"; @@ -395,6 +397,7 @@ void ConfigurationListWidget::WriteSettings() { s->beginGroup(CONF_LAUNCHER); m_game_dirs = NormalizeGameDirectories(m_game_dirs); s->setValue(CONF_GAME_DIRS, m_game_dirs); + s->setValue(CONF_ADDONS_DIR, m_addons_dir); s->setValue("grid_view", m_ui->grid_view_button->isChecked()); s->setValue("grid_icon_width", m_ui->grid_size_slider->value()); s->remove(CONF_GAME_DIR); @@ -444,6 +447,7 @@ void ConfigurationListWidget::ReadSettings() { if (m_game_dirs.isEmpty()) { m_game_dirs = NormalizeGameDirectories(SettingsStringList(s->value(CONF_GAME_DIR))); } + m_addons_dir = NormalizeGameDirectory(s->value(CONF_ADDONS_DIR).toString()); s->endGroup(); s->beginGroup(CONF_GLOBAL); @@ -508,6 +512,7 @@ ConfigurationListWidget::CreateConfiguration(const ConfigurationItem& item) cons info->elf = custom->elf; } info->host_input_mapping = m_global_info.host_input_mapping; + info->addons_dir = m_addons_dir; return info; } @@ -598,7 +603,13 @@ static GameMetadata GetGameMetadata(const QByteArray& param_data, const QString& ret.title_name = title; } - ret.title_id = GetJsonString(root, QStringLiteral("titleId")); + ret.title_id = GetJsonString(root, QStringLiteral("titleId")); + if (ret.title_id.isEmpty()) { + const auto content_id = GetJsonString(root, QStringLiteral("contentId")); + if (content_id.size() == 36 && content_id[6] == '-' && content_id[16] == '_') { + ret.title_id = content_id.mid(7, 9); + } + } ret.gameVersion = GetJsonString(root, QStringLiteral("appVersion")); if (ret.gameVersion.isEmpty()) { ret.gameVersion = GetJsonString(root, QStringLiteral("contentVersion")); @@ -887,7 +898,7 @@ void ConfigurationListWidget::edit_global_settings() { ConfigurationEditDialog dlg(info, this); dlg.setWindowTitle(tr("Global settings")); - dlg.SetGlobalSettings(m_game_dirs); + dlg.SetGlobalSettings(m_game_dirs, m_addons_dir); connect(&dlg, &ConfigurationEditDialog::PreviewControllerColor, this, &ConfigurationListWidget::PreviewControllerColor); connect(&dlg, &ConfigurationEditDialog::ImportGameSettings, this, @@ -901,6 +912,7 @@ void ConfigurationListWidget::edit_global_settings() { const auto game_dirs = NormalizeGameDirectories(dlg.GetGameDirectories()); const bool game_dirs_changed = game_dirs != m_game_dirs; m_game_dirs = game_dirs; + m_addons_dir = NormalizeGameDirectory(dlg.GetAddonsDirectory()); WriteSettings(); if (game_dirs_changed) { ScanGameDirectory(); @@ -1246,6 +1258,16 @@ void ConfigurationListWidget::ShowContextMenu(QTreeWidgetItem* witem) { }); action_patches->setVisible(item != nullptr && Cheats::IsSupportedTitleId(item->GetInfo().title_id)); + QAction* action_addcont = menu.addAction( + style()->standardIcon(QStyle::SP_DirIcon), tr("Additional Content...")); + connect(action_addcont, &QAction::triggered, this, + [this, item = QPointer(item)]() { + if (item != nullptr) { + auto* dialog = new AdditionalContentDialog(item->GetInfo(), m_addons_dir, this); + dialog->show(); + } + }); + action_addcont->setEnabled(item != nullptr); QAction* action_remove_save_data = menu.addAction(style()->standardIcon(QStyle::SP_DialogDiscardButton), tr("Remove save data..."), this, SLOT(remove_save_data())); diff --git a/src/launcher/src/mainDialog.cpp b/src/launcher/src/mainDialog.cpp index 03f90b316..af6358671 100644 --- a/src/launcher/src/mainDialog.cpp +++ b/src/launcher/src/mainDialog.cpp @@ -295,6 +295,9 @@ static QStringList CreateEmulatorArgs(const Configuration& info) { game = QDir(info.basedir).filePath(info.elf); } args << "--game" << game; + if (!info.addons_dir.isEmpty()) { + args << "--addcont-dir" << info.addons_dir; + } const auto patch_plan = Cheats::PlanPath(info.title_id); if (QFileInfo::exists(patch_plan)) { diff --git a/src/libs/libAppContent.cpp b/src/libs/libAppContent.cpp index 645faf05c..ab956c78b 100644 --- a/src/libs/libAppContent.cpp +++ b/src/libs/libAppContent.cpp @@ -4,15 +4,21 @@ #include "common/emulatorConfig.h" #include "common/logging/log.h" #include "common/stringUtils.h" +#include "kernel/fileSystem.h" #include "libs/errno.h" #include "libs/libs.h" #include "libs/systemService.h" +#include "loader/additionalContent.h" #include "loader/symbolDatabase.h" #include "loader/systemContent.h" +#include #include #include +#include +#include #include +#include namespace Libs { @@ -20,10 +26,21 @@ LIB_VERSION("AppContent", 1, "AppContentUtil", 1, 1); namespace AppContent { -constexpr uint32_t APP_CONTENT_APPPARAM_SKU_FLAG_FULL = 3; -constexpr int APP_CONTENT_ERROR_PARAMETER = -2133262334; /* 0x80D90002 */ -constexpr int APP_CONTENT_ERROR_NOT_MOUNTED = -2133262332; /* 0x80D90004 */ -constexpr int APP_CONTENT_ERROR_DRM_NO_ENTITLEMENT = -2133262329; /* 0x80D90007 */ +constexpr uint32_t APP_CONTENT_APPPARAM_SKU_FLAG_FULL = 3; +constexpr int APP_CONTENT_ERROR_PARAMETER = -2133262334; /* 0x80D90002 */ +constexpr int APP_CONTENT_ERROR_BUSY = -2133262333; /* 0x80D90003 */ +constexpr int APP_CONTENT_ERROR_NOT_MOUNTED = -2133262332; /* 0x80D90004 */ +constexpr int APP_CONTENT_ERROR_NOT_FOUND = -2133262331; /* 0x80D90005 */ +constexpr int APP_CONTENT_ERROR_MOUNT_FULL = -2133262330; /* 0x80D90006 */ +constexpr size_t APP_CONTENT_ADDCONT_MOUNT_MAXNUM = 64; + +struct AddcontMount { + std::string label; + std::string point; +}; + +static std::mutex g_addcont_mutex; +static std::vector g_addcont_mounts; static std::string g_title_id; @@ -174,13 +191,51 @@ int KYTY_SYSV_ABI AppContentAddcontMount(uint32_t servic service_label, reinterpret_cast(entitlement_label), reinterpret_cast(mount_point)); - if (entitlement_label == nullptr || mount_point == nullptr) { + if (service_label != 0 || entitlement_label == nullptr || mount_point == nullptr) { return APP_CONTENT_ERROR_PARAMETER; } std::memset(mount_point->data, 0, sizeof(mount_point->data)); - return APP_CONTENT_ERROR_DRM_NO_ENTITLEMENT; + const auto* entry = + Loader::AddContentFind(entitlement_label->data, sizeof(entitlement_label->data)); + if (entry == nullptr) { + return APP_CONTENT_ERROR_NOT_FOUND; + } + + std::lock_guard lock(g_addcont_mutex); + + for (const auto& m: g_addcont_mounts) { + if (m.label == entry->entitlement_label) { + return APP_CONTENT_ERROR_BUSY; + } + } + + if (g_addcont_mounts.size() == APP_CONTENT_ADDCONT_MOUNT_MAXNUM) { + return APP_CONTENT_ERROR_MOUNT_FULL; + } + std::error_code ec; + if (!std::filesystem::is_directory(entry->mount_dir, ec)) { + return APP_CONTENT_ERROR_NOT_FOUND; + } + + uint32_t index = 0; + while ( + std::any_of(g_addcont_mounts.begin(), g_addcont_mounts.end(), [&](const AddcontMount& m) { + return m.point == "/addcont" + std::to_string(index) + "/"; + })) { + index++; + } + + AddcontMount mount {entry->entitlement_label, "/addcont" + std::to_string(index) + "/"}; + LibKernel::FileSystem::Mount(entry->mount_dir, mount.point); + LOGF("\t mounted %s -> %s\n", mount.point.c_str(), + Common::PathToString(entry->mount_dir).c_str()); + + std::strncpy(mount_point->data, mount.point.c_str(), sizeof(mount_point->data) - 1); + g_addcont_mounts.push_back(std::move(mount)); + + return OK; } int KYTY_SYSV_ABI AppContentAddcontUnmount(const AppContentMountPoint* mount_point) { @@ -196,6 +251,19 @@ int KYTY_SYSV_ABI AppContentAddcontUnmount(const AppContentMountPoint* mount_poi return APP_CONTENT_ERROR_NOT_MOUNTED; } + const std::string point(mount_point->data, + strnlen(mount_point->data, sizeof(mount_point->data))); + + std::lock_guard lock(g_addcont_mutex); + + const auto it = std::find_if(g_addcont_mounts.begin(), g_addcont_mounts.end(), + [&](const AddcontMount& m) { return m.point == point; }); + if (it == g_addcont_mounts.end()) { + return APP_CONTENT_ERROR_NOT_MOUNTED; + } + LibKernel::FileSystem::Umount(point); + g_addcont_mounts.erase(it); + return OK; } diff --git a/src/libs/libNpEntitlementAccess.cpp b/src/libs/libNpEntitlementAccess.cpp index ff521daef..b3b3e1d31 100644 --- a/src/libs/libNpEntitlementAccess.cpp +++ b/src/libs/libNpEntitlementAccess.cpp @@ -1,13 +1,16 @@ #include "common/abi.h" #include "common/logging/log.h" #include "libs/libs.h" +#include "loader/additionalContent.h" #include "loader/symbolDatabase.h" +#include #include #include #include #include #include +#include #include #include #include @@ -164,6 +167,16 @@ static constexpr NpEntitlementAccessAddcontEntitlementInfo NP_ENTITLEMENT_ACCESS {{{"_mtqu6"}, {}}, 3, 4}, // GTA V hash 0x9cd1bcad }; +static NpEntitlementAccessAddcontEntitlementInfo +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. + return info; +} + static int KYTY_SYSV_ABI NpEntitlementAccessInitialize( const NpEntitlementAccessInitParam* init_param, NpEntitlementAccessBootParam* boot_param) { PRINT_NAME(); @@ -404,19 +417,21 @@ static int KYTY_SYSV_ABI NpEntitlementAccessGetAddcontEntitlementInfoList( LOGF("\t list_num = %" PRIu32 "\n", list_num); LOGF("\t hit_num = 0x%016" PRIx64 "\n", reinterpret_cast(hit_num)); - if (hit_num == nullptr || (list == nullptr && list_num != 0)) { + if (hit_num == nullptr) { return NP_ENTITLEMENT_ACCESS_ERROR_PARAMETER; } - *hit_num = static_cast(sizeof(NP_ENTITLEMENT_ACCESS_ADDON_LIST) / - sizeof(NP_ENTITLEMENT_ACCESS_ADDON_LIST[0])); + constexpr auto legacy_count = std::size(NP_ENTITLEMENT_ACCESS_ADDON_LIST); + const auto& entries = Loader::AddContentGetAll(); + *hit_num = static_cast(legacy_count + entries.size()); if (list != nullptr && list_num != 0) { memset(list, 0, sizeof(*list) * list_num); - const auto copy_num = (list_num < *hit_num ? list_num : *hit_num); + const auto copy_num = std::min(list_num, *hit_num); for (uint32_t i = 0; i < copy_num; i++) { - list[i] = NP_ENTITLEMENT_ACCESS_ADDON_LIST[i]; + list[i] = i < legacy_count ? NP_ENTITLEMENT_ACCESS_ADDON_LIST[i] + : MakeAddcontInfo(entries[i - legacy_count]); } } @@ -447,6 +462,12 @@ static int KYTY_SYSV_ABI NpEntitlementAccessGetAddcontEntitlementInfo( } } + if (const auto* entry = + Loader::AddContentFind(entitlement_label->data, sizeof(entitlement_label->data))) { + *info = MakeAddcontInfo(*entry); + return 0; + } + return NP_ENTITLEMENT_ACCESS_ERROR_NO_ENTITLEMENT; } diff --git a/src/loader/additionalContent.cpp b/src/loader/additionalContent.cpp new file mode 100644 index 000000000..47fd34e1d --- /dev/null +++ b/src/loader/additionalContent.cpp @@ -0,0 +1,36 @@ +#include "loader/additionalContent.h" + +#include "common/logging/log.h" +#include "common/stringUtils.h" + +#include +#include + +namespace Loader { + +static std::vector g_add_content; + +void AddContentLoad(const std::filesystem::path& dir, const std::string& title_id) { + g_add_content = Common::FindAddContent(dir, title_id); + for (const auto& entry: g_add_content) { + LOGF("AddContent: registered %s -> %s\n", entry.content_id.c_str(), + Common::PathToString(entry.mount_dir).c_str()); + } +} + +const std::vector& AddContentGetAll() { + return g_add_content; +} + +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; }); + return it == g_add_content.end() ? nullptr : &*it; +} + +} // namespace Loader diff --git a/src/loader/additionalContent.h b/src/loader/additionalContent.h new file mode 100644 index 000000000..41803e0da --- /dev/null +++ b/src/loader/additionalContent.h @@ -0,0 +1,18 @@ +#ifndef EMULATOR_INCLUDE_EMULATOR_LOADER_ADDITIONAL_CONTENT_H_ +#define EMULATOR_INCLUDE_EMULATOR_LOADER_ADDITIONAL_CONTENT_H_ + +#include "common/additionalContent.h" + +#include + +namespace Loader { + +using AddContentEntry = Common::AddContentEntry; + +void AddContentLoad(const std::filesystem::path& dir, const std::string& title_id); +const std::vector& AddContentGetAll(); +const AddContentEntry* AddContentFind(const char* label, size_t label_size); + +} // namespace Loader + +#endif /* EMULATOR_INCLUDE_EMULATOR_LOADER_ADDITIONAL_CONTENT_H_ */ diff --git a/src/main.cpp b/src/main.cpp index 7f6d8f87a..aba07ef11 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -46,6 +46,7 @@ static void PrintUsage() { ::printf("Options:\n"); ::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(" --screen-width Window width. Default: 1280.\n"); ::printf(" --screen-height Window height. Default: 720.\n"); ::printf( @@ -326,6 +327,12 @@ static bool ParseArgs(int argc, char* argv[], RunOptions& options, bool& show_he return false; } options.game_patch = path; + } else if (arg == "--addcont-dir") { + if (!options.addcont_dir.empty()) { + ::printf("--addcont-dir can only be specified once\n"); + return false; + } + options.addcont_dir = Common::PathFromUtf8(Common::FixFilenameSlash(value)); } else if (arg == "--screen-width") { if (!ParseUint32(value, options.config.screen_width) || options.config.screen_width == 0) { diff --git a/tests/KernelFileSystemTests.cpp b/tests/KernelFileSystemTests.cpp index 2d5c10863..4d0ee396c 100644 --- a/tests/KernelFileSystemTests.cpp +++ b/tests/KernelFileSystemTests.cpp @@ -14,6 +14,7 @@ #include "kernel/fileSystem.h" #include "libs/errno.h" #include "libs/network.h" +#include "loader/additionalContent.h" #include "loader/symbolDatabase.h" #include @@ -69,6 +70,10 @@ namespace Libs::LibNpCommerce { void InitNet_1_NpCommerce(Loader::SymbolDatabase *symbols); } +namespace Libs::LibNpEntitlementAccess { +void InitNet_1_NpEntitlementAccess(Loader::SymbolDatabase *symbols); +} + namespace { namespace FileSystem = Libs::LibKernel::FileSystem; @@ -305,6 +310,143 @@ void TestSystemServiceEntitlementEvents() { "empty event queue reports NO_EVENT"); } +void TestAddContent(const std::filesystem::path& root) { + const auto folder = root / "add-ons"; + const auto write = [](const std::filesystem::path& path, const std::string& text) { + std::filesystem::create_directories(path.parent_path()); + Common::File file; + Check(file.Create(path), "create add-on fixture"); + uint32_t written = 0; + file.Write(text.data(), static_cast(text.size()), &written); + Check(written == text.size(), "write add-on fixture"); + }; + const auto label = [](unsigned index) { + std::array value {}; + std::snprintf(value.data(), value.size(), "DLC%013u", index); + return std::string(value.data()); + }; + const auto param = [](const std::string& title, const std::string& entitlement) { + return "{\"contentId\":\"UP0001-" + title + "_00-" + entitlement + "\"}"; + }; + write(folder / "foreign" / "sce_sys" / "param.json", param("PPSA22222", label(0))); + write(folder / "foreign-label" / "sce_sys" / "param.json", + param("PPSA22222", "PPSA11111XXXXXXX")); + write(folder / "outside" / "param.json", param("PPSA11111", label(66))); + write(folder / "invalid" / "sce_sys" / "param.json", "{\"contentId\":17}"); + write(folder / "malformed" / "sce_sys" / "param.json", "{broken"); + write(folder / "short-id" / "sce_sys" / "param.json", param("PPSA11111", "SHORT")); + write(folder / "base-game" / "sce_sys" / "param.json", + "{\"contentId\":\"UP0001-PPSA11111_00-BASE000000000000\",\"applicationCategoryType\":0}"); + for (unsigned i = 0; i < 65; ++i) { + write(folder / label(i) / "sce_sys" / "param.json", param("PPSA11111", label(i))); + } + write(folder / "duplicate" / "sce_sys" / "param.json", param("PPSA11111", label(1))); + write(folder / label(0) / "payload.txt", "DLC payload"); + + Loader::AddContentLoad(folder, ""); + Check(Loader::AddContentGetAll().empty(), "unknown title never exposes unrelated add-ons"); + Loader::AddContentLoad(folder, "PPSA11111"); + Check(Loader::AddContentGetAll().size() == 65, + "scan filters title before deduplication and ignores invalid/non-package metadata"); + + struct Label { char data[17]; char padding[3]; }; + struct Info { Label label; uint32_t package_type; uint32_t download_status; }; + struct MountPoint { char data[16]; }; + static_assert(sizeof(Label) == 20 && sizeof(Info) == 28 && sizeof(MountPoint) == 16); + Loader::SymbolDatabase symbols; + Libs::InitAppContent_1(&symbols); + Libs::LibNpEntitlementAccess::InitNet_1_NpEntitlementAccess(&symbols); + const auto resolve = [&](const char* nid) { + const auto* symbol = symbols.FindByNid(nid, Loader::SymbolType::Func); + Check(symbol != nullptr, "add-on export resolves"); + return symbol->vaddr; + }; + const auto initialize = reinterpret_cast( + resolve("jO8DM8oyego")); + const auto list = reinterpret_cast( + resolve("TFyU+KFBv54")); + const auto get = reinterpret_cast( + resolve("xddD23+8TfQ")); + const auto mount = reinterpret_cast( + resolve("VANhIWcqYak")); + const auto unmount = reinterpret_cast( + resolve("3rHWaV-1KC4")); + std::array init {}, boot {}; + Check(initialize(init.data(), boot.data()) == OK, "initialize add-on entitlements"); + uint32_t count = 0; + Check(list(0, nullptr, 0, &count) == OK && count == 68, + "query existing and detected add-ons together"); + Check(list(0, nullptr, 1, &count) == OK && count == 68, + "count-only query ignores output capacity"); + Check(list(1, nullptr, 0, &count) == OK && count == 68, + "existing service-label handling remains unchanged"); + std::array info {}; + info[4].package_type = 99; + Check(list(0, info.data(), 4, &count) == OK && count == 68 && info[4].package_type == 99, + "truncated entitlement list reports total and respects output capacity"); + constexpr std::array existing {"85y-je", "5d5c48", "_mtqu6"}; + Info single {}; + for (size_t i = 0; i < existing.size(); ++i) { + Check(std::strcmp(info[i].label.data, existing[i]) == 0 && + info[i].package_type == 3 && info[i].download_status == 4 && + get(1, &info[i].label, &single) == OK && + std::memcmp(&single, &info[i], sizeof(single)) == 0, + "GTA V entitlements retain their order, metadata, and individual lookups"); + } + Check(std::strcmp(info[3].label.data, label(0).c_str()) == 0 && + info[3].package_type == 2 && info[3].download_status == 4 && + get(0, &info[3].label, &single) == OK && + std::memcmp(&single, &info[3], sizeof(single)) == 0, + "detected add-ons follow existing entitlements with matching individual lookups"); + Label missing {}; + std::memcpy(missing.data, "MISSING000000000", 16); + MountPoint point {}; + Check(get(0, &missing, &single) == static_cast(0x817d0007u) && + mount(0, &missing, &point) == static_cast(0x80d90005u), + "unknown add-on returns entitlement and mount errors"); + Check(mount(0, nullptr, &point) == static_cast(0x80d90002u) && + list(0, nullptr, 0, nullptr) == static_cast(0x817d0002u), + "null add-on parameters return parameter errors"); + Check(mount(0, &info[3].label, &point) == OK, "mount owned add-on"); + const std::string mounted_file = std::string(point.data) + "/payload.txt"; + const int fd = FileSystem::KernelOpen(mounted_file.c_str(), 0, 0); + std::array payload {}; + Check(fd >= 3 && FileSystem::KernelRead(fd, payload.data(), payload.size()) == payload.size() && + std::string_view(payload.data(), payload.size()) == "DLC payload" && + FileSystem::KernelClose(fd) == OK, + "guest reads files from the selected title's add-on"); + MountPoint repeated {}; + Check(mount(0, &info[3].label, &repeated) == static_cast(0x80d90003u), + "duplicate mount returns BUSY"); + Check(unmount(&point) == OK && FileSystem::GetRealFilename(mounted_file).empty() && + unmount(&point) == static_cast(0x80d90004u), + "unmount removes mapping and repeated unmount reports NOT_MOUNTED"); + std::array mounts {}; + for (unsigned i = 0; i < mounts.size(); ++i) { + Label entitlement {}; + std::memcpy(entitlement.data, label(i).c_str(), 17); + Check(mount(0, &entitlement, &mounts[i]) == OK, "mount up to the simultaneous mount limit"); + } + Label extra {}; + std::memcpy(extra.data, label(64).c_str(), 17); + Check(mount(0, &extra, &point) == static_cast(0x80d90006u), + "65th simultaneous mount returns MOUNT_FULL"); + Check(unmount(&mounts[0]) == OK && mount(0, &extra, &point) == OK, + "freed mount slot can be reused"); + Check(unmount(&point) == OK, "unmount reused slot"); + for (size_t i = 1; i < mounts.size(); ++i) { + Check(unmount(&mounts[i]) == OK, "unmount fixture"); + } + Loader::AddContentLoad(folder / "missing", "PPSA11111"); + Check(list(0, info.data(), 4, &count) == OK && count == 3, + "missing add-ons folder retains GTA V entitlements"); + for (size_t i = 0; i < existing.size(); ++i) { + Check(std::strcmp(info[i].label.data, existing[i]) == 0 && + get(0, &info[i].label, &single) == OK, + "GTA V entitlement lookup works without detected add-ons"); + } +} + void TestSaveOpenVisibility() { constexpr char Path[] = "/savedata0/visible-save.dat"; constexpr char Payload[] = "saved progress"; @@ -2033,6 +2175,7 @@ int main(int, char**) { FileSystem::Initialize(); TestSysmoduleReferences(); TestSystemServiceEntitlementEvents(); + TestAddContent(temporary.Path()); TestRandomDevices(); TestFileDescriptorFlags(); CheckMountRoot(temporary.Path());