From 7bcef13242337ee97b7ba2425c271a2467a36a85 Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 02:21:45 +0700 Subject: [PATCH 01/32] feat: accept extracted PSP game folders in installer --- docs/EXTRACTED_FOLDER_SUPPORT.md | 10 +++ host/install/installer.cpp | 133 ++++++++++++++++++---------- host/install/installer.hpp | 1 + host/install/user_data.hpp | 4 +- host/kernel/iso_image.cpp | 79 ++++++++++++++++- host/kernel/iso_image.hpp | 7 ++ host/main.cpp | 3 +- host/platform/android_documents.cpp | 84 ++++++++++++++++++ host/platform/android_documents.hpp | 8 ++ host/ui/setup_screens.cpp | 27 +++++- 10 files changed, 304 insertions(+), 52 deletions(-) create mode 100644 docs/EXTRACTED_FOLDER_SUPPORT.md diff --git a/docs/EXTRACTED_FOLDER_SUPPORT.md b/docs/EXTRACTED_FOLDER_SUPPORT.md new file mode 100644 index 0000000..639b95d --- /dev/null +++ b/docs/EXTRACTED_FOLDER_SUPPORT.md @@ -0,0 +1,10 @@ +# Extracted PSP game folders + +The installer can accept either an uncompressed PSP ISO or a folder whose top level contains: + +- `PSP_GAME/PARAM.SFO` +- `PSP_GAME/SYSDIR/EBOOT.BIN` + +It checks the same `DISC_ID` and executable SHA-256 as an ISO install. On desktop platforms the folder is selected from the setup file browser and is used in place. On Android, the system folder picker copies the selected tree into app-private storage first. + +The runtime exposes the extracted files through a file-backed virtual disc so ordinary `disc0:/PSP_GAME/...` file operations can use them. This is an initial compatibility path, not a byte-for-byte ISO image: raw disc-sector reads do not see ISO volume descriptors or real ISO directory records. A game that depends on those raw sectors may still require a real ISO. Folder-based boot must be tested per title before claiming compatibility. diff --git a/host/install/installer.cpp b/host/install/installer.cpp index 41bdf27..f3c68ea 100644 --- a/host/install/installer.cpp +++ b/host/install/installer.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -61,6 +62,38 @@ std::vector read_file(IsoImage &iso, const IsoImage::Entry &entry) return data; } +std::vector read_host_file(const std::filesystem::path &path) { + std::error_code ec; + const auto size = std::filesystem::file_size(path, ec); + if (ec || size > static_cast(std::numeric_limits::max())) + throw InstallError("Cannot determine the size of \"" + path_to_utf8(path) + "\"."); + std::vector data(static_cast(size)); + std::ifstream in(path, std::ios::binary); + if (!in || (!data.empty() && !in.read(reinterpret_cast(data.data()), static_cast(data.size())))) + throw InstallError("Cannot read \"" + path_to_utf8(path) + "\"."); + return data; +} + +std::uint64_t extracted_folder_size(const std::filesystem::path &root) { + std::uint64_t total = 0u; + std::error_code ec; + for (std::filesystem::recursive_directory_iterator it( + root, std::filesystem::directory_options::skip_permission_denied, ec), end; + it != end; it.increment(ec)) { + if (ec) { ec.clear(); continue; } + const auto status = it->symlink_status(ec); + if (ec || std::filesystem::is_symlink(status) || !std::filesystem::is_regular_file(status)) { + ec.clear(); continue; + } + const auto size = it->file_size(ec); + if (ec) { ec.clear(); continue; } + if (size > std::numeric_limits::max() - total) + throw InstallError("The extracted game folder is too large."); + total += size; + } + return total; +} + // String values of a PARAM.SFO (PSF) file. std::map parse_sfo(const std::vector &sfo) { std::map values; @@ -91,80 +124,88 @@ struct Inspection { }; Inspection inspect(const std::filesystem::path &path) { - const std::string name = display_name(path); + const std::string name = path_to_utf8(path.filename()); std::error_code ec; - if (!std::filesystem::is_regular_file(path, ec)) - throw InstallError("\"" + path_to_utf8(path) + "\" is not a file " + std::string(portablekit::game().project_name) + " can open."); - { - std::ifstream in(path, std::ios::binary); - std::array magic{}; - if (!in.read(magic.data(), magic.size())) - throw InstallError("\"" + name + "\" is empty or cannot be read."); - if (std::memcmp(magic.data(), "CISO", 4u) == 0 || std::memcmp(magic.data(), "ZISO", 4u) == 0) - throw InstallError("\"" + name + "\" is a compressed image. " + std::string(portablekit::game().project_name) + " needs an uncompressed .iso image of the " - "disc; decompress it first."); - } + const bool directory = std::filesystem::is_directory(path, ec); + if (ec || (!directory && !std::filesystem::is_regular_file(path, ec))) + throw InstallError("\"" + path_to_utf8(path) + "\" is neither a disc image nor an extracted game folder."); + std::vector sfo_bytes; + std::vector eboot_bytes; std::optional iso; - try { - iso.emplace(path); - } catch (const std::exception &) { - throw InstallError("\"" + name + "\" is not a disc image " + std::string(portablekit::game().project_name) + " can read. Choose an uncompressed .iso image " - "of the game's PSP disc."); + if (directory) { + const auto sfo_path = path / path_from_utf8(portablekit::game().param_sfo_path_on_disc); + const auto eboot_path = path / path_from_utf8(portablekit::game().executable_path_on_disc); + if (!std::filesystem::is_regular_file(sfo_path, ec) || ec || + !std::filesystem::is_regular_file(eboot_path, ec) || ec) + throw InstallError("\"" + name + "\" is not an extracted PSP game folder: it must contain PSP_GAME/PARAM.SFO and PSP_GAME/SYSDIR/EBOOT.BIN."); + sfo_bytes = read_host_file(sfo_path); + eboot_bytes = read_host_file(eboot_path); + } else { + { + std::ifstream in(path, std::ios::binary); + std::array magic{}; + if (!in.read(magic.data(), magic.size())) + throw InstallError("\"" + name + "\" is empty or cannot be read."); + if (std::memcmp(magic.data(), "CISO", 4u) == 0 || std::memcmp(magic.data(), "ZISO", 4u) == 0) + throw InstallError("\"" + name + "\" is a compressed image. " + std::string(portablekit::game().project_name) + " needs an uncompressed .iso image; decompress it first."); + } + try { + iso.emplace(path); + } catch (const std::exception &) { + throw InstallError("\"" + name + "\" is not a disc image " + std::string(portablekit::game().project_name) + " can read. Choose an uncompressed .iso image or an extracted PSP_GAME folder."); + } + const auto sfo_entry = iso->find(portablekit::game().param_sfo_path_on_disc); + if (!sfo_entry || sfo_entry->directory) { + if (iso->find("PS3_GAME/PARAM.SFO")) + throw InstallError("\"" + name + "\" is a PlayStation 3 disc image. " + std::string(portablekit::game().project_name) + " needs the PSP game."); + throw InstallError("\"" + name + "\" is not a PSP game disc image: it has no PSP_GAME/PARAM.SFO."); + } + sfo_bytes = read_file(*iso, *sfo_entry); } - const auto sfo_entry = iso->find(portablekit::game().param_sfo_path_on_disc); - if (!sfo_entry || sfo_entry->directory) { - if (iso->find("PS3_GAME/PARAM.SFO")) - throw InstallError("\"" + name + "\" is a PlayStation 3 disc image. " + std::string(portablekit::game().project_name) + " needs the PSP disc image of " + - portablekit::game().game_title + ": an .iso whose top level holds a PSP_GAME folder."); - throw InstallError("\"" + name + "\" is not a PSP game disc image: it has no PSP_GAME/PARAM.SFO."); - } - const auto sfo = parse_sfo(read_file(*iso, *sfo_entry)); + const auto sfo = parse_sfo(sfo_bytes); const auto disc_id = sfo.find("DISC_ID"); if (disc_id == sfo.end()) - throw InstallError("\"" + name + "\" has no disc id in PSP_GAME/PARAM.SFO, so it is not an image " + std::string(portablekit::game().project_name) + " " - "supports."); + throw InstallError("\"" + name + "\" has no disc id in PSP_GAME/PARAM.SFO, so it is not a game " + std::string(portablekit::game().project_name) + " supports."); if (disc_id->second != portablekit::game().disc_id) { const auto title = sfo.find("TITLE"); std::string what = "\"" + name + "\" is "; - what += title != sfo.end() && !title->second.empty() ? title->second + " (" + disc_id->second + ")" - : "disc " + disc_id->second; + what += title != sfo.end() && !title->second.empty() ? title->second + " (" + disc_id->second + ")" : "disc " + disc_id->second; const GameProfile &profile = portablekit::game(); what += ". " + std::string(profile.project_name) + " supports only " + std::string(profile.game_title) + - (profile.release_name != nullptr ? ", " + std::string(profile.release_name) + " with disc id " - : std::string(", disc id ")) + + (profile.release_name != nullptr ? ", " + std::string(profile.release_name) + " with disc id " : std::string(", disc id ")) + profile.disc_id_display + "."; const auto other = std::find_if(profile.other_releases.begin(), profile.other_releases.end(), [&](const OtherRelease &release) { return disc_id->second == release.disc_id; }); - what += other != profile.other_releases.end() ? " " + std::string(other->note) - : std::string(" Other releases and regions are not supported."); + what += other != profile.other_releases.end() ? " " + std::string(other->note) : std::string(" Other releases and regions are not supported."); throw InstallError(what); } - const auto eboot_entry = iso->find(portablekit::game().executable_path_on_disc); - if (!eboot_entry || eboot_entry->directory) - throw InstallError("\"" + name + "\" has the right disc id but no PSP_GAME/SYSDIR/EBOOT.BIN. The image is " - "incomplete or modified; make it again from your disc."); + if (!directory) { + const auto eboot_entry = iso->find(portablekit::game().executable_path_on_disc); + if (!eboot_entry || eboot_entry->directory) + throw InstallError("\"" + name + "\" has the right disc id but no PSP_GAME/SYSDIR/EBOOT.BIN."); + eboot_bytes = read_file(*iso, *eboot_entry); + } + Inspection result; - result.eboot_bin = read_file(*iso, *eboot_entry); + result.eboot_bin = std::move(eboot_bytes); const std::string eboot_sha256 = psprecomp::sha256_bytes(result.eboot_bin); const bool plain = result.eboot_bin.size() > 4u && result.eboot_bin[0] == 0x7Fu && result.eboot_bin[1] == 'E' && result.eboot_bin[2] == 'L' && result.eboot_bin[3] == 'F'; - // The release, an edition of it (ProfileVariant), or a plain executable - // the profile agrees to run without knowing it. const bool known = eboot_sha256 == portablekit::game().encrypted_executable_sha256 || portablekit::find_variant_by_encrypted(eboot_sha256) != nullptr || (plain && portablekit::is_known_executable(eboot_sha256)); if (!known && plain && portablekit::game().run_unknown_executables) std::cerr << "warning: " << name << " holds an edition of " << portablekit::game().game_title - << " this build does not know (executable SHA-256 " << eboot_sha256 - << "); it will run under the interpreter\n"; + << " this build does not know (executable SHA-256 " << eboot_sha256 << "); it will run under the interpreter\n"; else if (!known) throw InstallError("\"" + name + "\" is " + portablekit::game().game_title + " (" + portablekit::game().disc_id_display + - "), but its executable is not the version " + std::string(portablekit::game().project_name) + " supports. The image may be patched, " - "modified or damaged; make it again from an unmodified disc."); - result.info.size_bytes = iso->size_bytes(); + "), but its executable is not the version " + std::string(portablekit::game().project_name) + " supports. The image may be patched, modified or damaged."); + + result.info.is_directory = directory; + result.info.size_bytes = directory ? extracted_folder_size(path) : iso->size_bytes(); return result; } @@ -206,6 +247,8 @@ bool same_file(const std::filesystem::path &a, const std::filesystem::path &b) { void install_checked(const std::filesystem::path &image, ImageStorage storage, const std::filesystem::path &data_dir, const ProgressFn &progress) { Inspection inspection = inspect(image); + if (inspection.info.is_directory && storage == ImageStorage::Copy) + throw InstallError("An extracted game folder must be used in place. Choose the folder containing PSP_GAME and keep it at that location."); std::filesystem::create_directories(data_dir); const std::filesystem::path copied = data_dir / kCopiedImageFile; diff --git a/host/install/installer.hpp b/host/install/installer.hpp index 896f5ef..7b777a9 100644 --- a/host/install/installer.hpp +++ b/host/install/installer.hpp @@ -37,6 +37,7 @@ enum class ImageStorage { struct ImageInfo { std::uint64_t size_bytes{}; + bool is_directory{}; }; // Checks that path is an unmodified image of the supported release: the disc diff --git a/host/install/user_data.hpp b/host/install/user_data.hpp index 5d2eb39..2659a8a 100644 --- a/host/install/user_data.hpp +++ b/host/install/user_data.hpp @@ -11,7 +11,7 @@ namespace portablekit::install { // // EBOOT.ELF the executable prepared from the player's disc image // disc.iso the copied disc image (absent when the image is used in place) -// settings.ini where the disc image is, and the player's settings +// settings.ini where the disc image or extracted game folder is, and the player's settings // // Save data is not here yet: ms0 stays where the host has always kept it. inline constexpr const char *kExecutableFile = "EBOOT.ELF"; @@ -45,7 +45,7 @@ void save_settings(const std::filesystem::path &data_dir, const UserSettings &se struct Installation { std::filesystem::path executable; - std::filesystem::path disc_image; // absolute + std::filesystem::path disc_image; // absolute path to an ISO image or extracted PSP game folder bool image_copied{}; // disc image lives in the data directory }; diff --git a/host/kernel/iso_image.cpp b/host/kernel/iso_image.cpp index a0272e2..d584bc1 100644 --- a/host/kernel/iso_image.cpp +++ b/host/kernel/iso_image.cpp @@ -4,6 +4,9 @@ #include #include +#include +#include +#include namespace portablekit { namespace { @@ -15,7 +18,57 @@ std::uint32_t read_le32(const std::vector &data, std::size_t offse } // namespace -IsoImage::IsoImage(const std::filesystem::path &path) : file_(path, std::ios::binary) { +IsoImage::IsoImage(const std::filesystem::path &path) { + std::error_code ec; + if (std::filesystem::is_directory(path, ec)) { + host_directory_ = true; + host_root_ = std::filesystem::absolute(path, ec); + if (ec) host_root_ = path; + entries_[""] = Entry{0u, 0u, true, ""}; + std::vector paths; + for (std::filesystem::recursive_directory_iterator it( + host_root_, std::filesystem::directory_options::skip_permission_denied, ec), end; + it != end; it.increment(ec)) { + if (ec) { ec.clear(); continue; } + const auto status = it->symlink_status(ec); + if (ec || std::filesystem::is_symlink(status)) { + ec.clear(); + if (it->is_directory(ec)) it.disable_recursion_pending(); + continue; + } + if (std::filesystem::is_directory(status) || std::filesystem::is_regular_file(status)) + paths.push_back(it->path()); + } + std::sort(paths.begin(), paths.end(), [&](const auto &a, const auto &b) { + return a.lexically_relative(host_root_).generic_string() < + b.lexically_relative(host_root_).generic_string(); + }); + std::uint64_t next_lba = 32u; + for (const auto &host_path : paths) { + const std::string relative = host_path.lexically_relative(host_root_).generic_string(); + const std::string key = normalize(relative); + const std::string basename = host_path.filename().string(); + const auto status = std::filesystem::status(host_path, ec); + if (ec) { ec.clear(); continue; } + if (std::filesystem::is_directory(status)) { + entries_[key] = Entry{0u, 0u, true, basename}; + continue; + } + const std::uint64_t size = std::filesystem::file_size(host_path, ec); + if (ec) { ec.clear(); continue; } + if (size > std::numeric_limits::max() || + next_lba > std::numeric_limits::max()) + throw psprecomp::Error("Extracted game folder is too large for the PSP file interface"); + entries_[key] = Entry{static_cast(next_lba), static_cast(size), false, basename}; + host_files_[next_lba * kSectorSize] = HostFile{host_path, size}; + next_lba += std::max(1u, (size + kSectorSize - 1u) / kSectorSize); + } + size_bytes_ = next_lba * kSectorSize; + if (!entries_.contains("PSP_GAME/PARAM.SFO") || !entries_.contains("PSP_GAME/SYSDIR/EBOOT.BIN")) + throw psprecomp::Error("Extracted folder must contain PSP_GAME/PARAM.SFO and PSP_GAME/SYSDIR/EBOOT.BIN"); + return; + } + file_.open(path, std::ios::binary); if (!file_) throw psprecomp::Error("Cannot open disc image: " + path.string()); size_bytes_ = std::filesystem::file_size(path); std::vector primary(kSectorSize); @@ -74,6 +127,30 @@ std::optional IsoImage::find(std::string path) const { std::size_t IsoImage::read(std::uint64_t offset, std::span output) { if (offset >= size_bytes_) return 0u; const std::size_t count = static_cast(std::min(output.size(), size_bytes_ - offset)); + if (host_directory_) { + // File-backed virtual disc for ordinary IoFileMgr paths. Raw ISO-sector + // reads do not receive a byte-for-byte ISO representation. + std::fill(output.begin(), output.begin() + static_cast(count), 0u); + const std::uint64_t end = offset + count; + auto it = host_files_.upper_bound(offset); + if (it != host_files_.begin()) { + auto previous = std::prev(it); + if (previous->first + previous->second.size > offset) it = previous; + } + for (; it != host_files_.end() && it->first < end; ++it) { + const std::uint64_t file_start = it->first; + const std::uint64_t overlap_start = std::max(offset, file_start); + const std::uint64_t overlap_end = std::min(end, file_start + it->second.size); + if (overlap_start >= overlap_end) continue; + std::ifstream in(it->second.path, std::ios::binary); + if (!in) continue; + in.seekg(static_cast(overlap_start - file_start)); + const auto destination = static_cast(overlap_start - offset); + const auto length = static_cast(overlap_end - overlap_start); + in.read(reinterpret_cast(output.data() + destination), static_cast(length)); + } + return count; + } file_.clear(); file_.seekg(static_cast(offset)); file_.read(reinterpret_cast(output.data()), static_cast(count)); diff --git a/host/kernel/iso_image.hpp b/host/kernel/iso_image.hpp index e52d6a9..52f3770 100644 --- a/host/kernel/iso_image.hpp +++ b/host/kernel/iso_image.hpp @@ -39,6 +39,13 @@ class IsoImage { void scan_directory(std::uint32_t lba, std::uint32_t size, const std::string &prefix, int depth); [[nodiscard]] static std::string normalize(std::string path); + struct HostFile { + std::filesystem::path path; + std::uint64_t size{}; + }; + bool host_directory_{}; + std::map host_files_; + std::filesystem::path host_root_; std::ifstream file_; std::uint64_t size_bytes_{}; std::map entries_; diff --git a/host/main.cpp b/host/main.cpp index ebeb54d..1a404e0 100644 --- a/host/main.cpp +++ b/host/main.cpp @@ -207,7 +207,8 @@ std::optional locate_game(const Options &options) { if (!run_setup) { if (const auto installed = install::find_installation(data_dir)) { std::error_code ec; - if (std::filesystem::is_regular_file(installed->disc_image, ec)) { + if (std::filesystem::is_regular_file(installed->disc_image, ec) || + std::filesystem::is_directory(installed->disc_image, ec)) { GameFiles files; files.executable = installed->executable; files.disc_image = installed->disc_image; diff --git a/host/platform/android_documents.cpp b/host/platform/android_documents.cpp index 23b45d6..87e8426 100644 --- a/host/platform/android_documents.cpp +++ b/host/platform/android_documents.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -134,6 +135,32 @@ bool find_and_copy(const std::string &uri, const std::string &name, const fs::pa return true; } +bool copy_game_tree(const std::string &folder_uri, const fs::path &target, std::string &error, int depth = 0) { + if (depth > 64) { + error = "the selected game folder has too many nested directories"; + return false; + } + const auto entries = list_folder(folder_uri); + if (!entries) { + error = "cannot read the selected game folder"; + return false; + } + std::error_code ec; + fs::create_directories(target, ec); + if (ec) { + error = "cannot create " + target.string(); + return false; + } + for (const Entry &entry : *entries) { + if (entry.directory) { + if (!copy_game_tree(entry.uri, target / entry.name, error, depth + 1)) return false; + } else if (!copy_document_to_file(entry.uri, target / entry.name, error)) { + return false; + } + } + return true; +} + bool copy_tree(const fs::path &local, const std::string &folder_uri, std::string &error) { const std::optional made = create(folder_uri, local.filename().string(), true); if (!made) { @@ -178,6 +205,63 @@ std::optional pick_saves_to_import(const fs::path &staging) { return picked; } +std::optional pick_game_folder_to_copy(const fs::path &staging) { + const std::optional tree = pick_folder(); + if (!tree) return std::nullopt; + PickedGameFolder picked; + const std::string root_uri = tree_root(*tree); + const auto root_entries = list_folder(root_uri); + if (!root_entries) { + picked.error = "Android would not let the app read the selected folder."; + return picked; + } + const auto psp_game = std::find_if(root_entries->begin(), root_entries->end(), + [](const Entry &entry) { return entry.directory && entry.name == "PSP_GAME"; }); + if (psp_game == root_entries->end()) { + picked.error = "Choose the extracted game folder whose top level contains PSP_GAME."; + return picked; + } + const auto psp_entries = list_folder(psp_game->uri); + if (!psp_entries || std::none_of(psp_entries->begin(), psp_entries->end(), + [](const Entry &entry) { return !entry.directory && entry.name == "PARAM.SFO"; })) { + picked.error = "The selected folder has no PSP_GAME/PARAM.SFO."; + return picked; + } + const auto sysdir = std::find_if(psp_entries->begin(), psp_entries->end(), + [](const Entry &entry) { return entry.directory && entry.name == "SYSDIR"; }); + if (sysdir == psp_entries->end()) { + picked.error = "The selected folder has no PSP_GAME/SYSDIR directory."; + return picked; + } + const auto sys_entries = list_folder(sysdir->uri); + if (!sys_entries || std::none_of(sys_entries->begin(), sys_entries->end(), + [](const Entry &entry) { return !entry.directory && entry.name == "EBOOT.BIN"; })) { + picked.error = "The selected folder has no PSP_GAME/SYSDIR/EBOOT.BIN."; + return picked; + } + + std::error_code ec; + fs::create_directories(staging, ec); + const fs::path partial = staging / "extracted-game.part"; + const fs::path target = staging / "extracted-game"; + fs::remove_all(partial, ec); + std::string error; + if (!copy_game_tree(root_uri, partial, error)) { + fs::remove_all(partial, ec); + picked.error = "Copying the extracted game failed: " + error + "."; + return picked; + } + fs::remove_all(target, ec); + fs::rename(partial, target, ec); + if (ec) { + fs::remove_all(partial, ec); + picked.error = "Could not keep the extracted game in app storage."; + return picked; + } + picked.staged = target; + return picked; +} + std::optional pick_folder_and_copy(const fs::path &local) { const std::optional tree = pick_folder(); if (!tree) return std::nullopt; diff --git a/host/platform/android_documents.hpp b/host/platform/android_documents.hpp index 0215b08..097b210 100644 --- a/host/platform/android_documents.hpp +++ b/host/platform/android_documents.hpp @@ -24,6 +24,14 @@ struct PickedImport { // work. `staging` is emptied first. Nothing when the player cancels. [[nodiscard]] std::optional pick_saves_to_import(const std::filesystem::path &staging); +struct PickedGameFolder { + std::filesystem::path staged; + std::string error; +}; +// Opens Android's folder picker and copies a selected extracted PSP game into +// app-private storage. Cancelling returns nullopt so setup can offer ISO picking. +[[nodiscard]] std::optional pick_game_folder_to_copy(const std::filesystem::path &staging); + struct PickedExport { std::string where; // the picked folder, for the player std::string error; // empty: the copy worked diff --git a/host/ui/setup_screens.cpp b/host/ui/setup_screens.cpp index d440b66..855a648 100644 --- a/host/ui/setup_screens.cpp +++ b/host/ui/setup_screens.cpp @@ -16,6 +16,7 @@ #include "settings/settings.hpp" #if defined(PORTABLEKIT_ANDROID_APP) #include "platform/android_jni.hpp" +#include "platform/android_documents.hpp" #endif #include "imgui.h" @@ -146,6 +147,15 @@ bool SetupScreens::introduce(const fs::path &data_dir) { #if defined(PORTABLEKIT_ANDROID_APP) std::optional SetupScreens::choose_image() { for (;;) { + // Android's SAF picker can choose a folder as well as a single ISO. + // A selected folder is copied into app-private storage before checks. + const auto picked_folder = android::pick_game_folder_to_copy(data_dir_); + if (picked_folder) { + if (picked_folder->error.empty()) return picked_folder->staged; + std::cerr << "Setup: " << picked_folder->error << "\n"; + if (!offer_retry(picked_folder->error)) return std::nullopt; + continue; + } const std::optional uri = android::pick_document(); if (!uri) return std::nullopt; std::cout << "[setup] picked " << *uri << std::endl; @@ -173,9 +183,19 @@ std::optional SetupScreens::choose_image() { if (dropped_) return std::exchange(dropped_, std::nullopt); Layer &layer = Layer::get(); layer.set_interactive(true); + FileBrowser::Options options; + options.filter_name = ".iso or extracted game folders"; + options.listed_name = ".iso images and extracted game folders"; + options.empty_note = "No folders or .iso images here."; + options.choose_on_open = [](const fs::path &folder) { + std::error_code ec; + return fs::is_regular_file(folder / "PSP_GAME" / "PARAM.SFO", ec) && !ec && + fs::is_regular_file(folder / "PSP_GAME" / "SYSDIR" / "EBOOT.BIN", ec) && !ec; + }; FileBrowser browser(settings::current().last_folder.empty() ? FileBrowser::home() - : install::path_from_utf8(settings::current().last_folder)); + : install::path_from_utf8(settings::current().last_folder), + std::move(options)); std::optional chosen; layer.run( [&] { @@ -186,10 +206,10 @@ std::optional SetupScreens::choose_image() { const bool back = layer.take_back(); const ImGuiKey cancel = layer.confirm_south() ? ImGuiKey_GamepadFaceRight : ImGuiKey_GamepadFaceDown; const bool pad_back = ImGui::IsKeyPressed(cancel, false); - begin_panel("##browser", "Choose the disc image", kSubtitle, false); + begin_panel("##browser", "Choose game image or extracted folder", kSubtitle, false); begin_content(); const FileBrowser::Result result = browser.frame(back || pad_back); - layer.set_description(std::string("Look for the .iso image of ") + portablekit::game().disc_id_display + + layer.set_description(std::string("Choose an .iso image or open a folder containing PSP_GAME/PARAM.SFO and PSP_GAME/SYSDIR/EBOOT.BIN for ") + portablekit::game().disc_id_display + ". Removable drives and SD cards are listed next to Home."); begin_footer(); if (layer.input_device() == InputDevice::Gamepad) @@ -213,6 +233,7 @@ std::optional SetupScreens::choose_image() { std::optional SetupScreens::choose_storage(const fs::path &image, const install::ImageInfo &info, const fs::path &data_dir) { + if (info.is_directory) return install::ImageStorage::InPlace; #if defined(PORTABLEKIT_ANDROID_APP) // Already copied into the data folder by choose_image(). (void)image; From c1c7af2795e787944ecc2b36403e3147f010bfb0 Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 03:55:39 +0700 Subject: [PATCH 02/32] Add persistent Android log folder picker --- .../teamgdb/portablekit/GameActivity.java | 84 ++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/packaging/android/java/io/github/teamgdb/portablekit/GameActivity.java b/packaging/android/java/io/github/teamgdb/portablekit/GameActivity.java index cec636d..8ec1bc3 100644 --- a/packaging/android/java/io/github/teamgdb/portablekit/GameActivity.java +++ b/packaging/android/java/io/github/teamgdb/portablekit/GameActivity.java @@ -3,6 +3,7 @@ import android.content.ContentResolver; import android.content.Intent; import android.content.pm.ActivityInfo; +import android.content.SharedPreferences; import android.database.Cursor; import android.graphics.Insets; import android.net.Uri; @@ -16,6 +17,8 @@ import org.libsdl.app.SDLActivity; import java.util.ArrayList; +import java.io.InputStream; +import java.io.OutputStream; /** * SDL's activity with what the host needs from Android and SDL does not @@ -72,7 +75,8 @@ public static int[] cutoutInsets() { /** Asks the player for a folder; its tree URI, or null when cancelled. */ public static String pickFolder() { Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION | + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); return pick(intent); } @@ -123,12 +127,90 @@ private static void finishPick(String result) { protected void onActivityResult(int requestCode, int resultCode, Intent data) { if (requestCode == kPickTree) { Uri uri = resultCode == RESULT_OK && data != null ? data.getData() : null; + if (uri != null && data != null && + (data.getFlags() & Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) != 0) { + try { + int flags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | + Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + getContentResolver().takePersistableUriPermission(uri, flags); + } catch (SecurityException ignored) { + // Keep the current session usable; the folder may need to be picked again later. + } + } finishPick(uri != null ? uri.toString() : null); return; } super.onActivityResult(requestCode, resultCode, data); } + /** + * Opens the app log in a folder chosen through Android's system picker. + * The tree URI is kept in private preferences so permission survives + * process death. Returns an owned file descriptor, or -1 if unavailable. + */ + public static int openCrashLog(String appName) { + if (mSingleton == null) return -1; + try { + SharedPreferences preferences = mSingleton.getSharedPreferences("portablekit_storage", 0); + String saved = preferences.getString("log_tree_uri", null); + Uri tree = saved == null ? null : Uri.parse(saved); + if (tree == null) { + String chosen = pickFolder(); + if (chosen == null) return -1; + tree = Uri.parse(chosen); + preferences.edit().putString("log_tree_uri", chosen).apply(); + } + + Uri root = DocumentsContract.buildDocumentUriUsingTree( + tree, DocumentsContract.getTreeDocumentId(tree)); + ContentResolver resolver = mSingleton.getContentResolver(); + String currentName = appName + ".log"; + String previousName = appName + "-previous.log"; + Uri current = findChild(root, currentName); + if (current != null) { + Uri previous = findChild(root, previousName); + if (previous == null) { + previous = DocumentsContract.createDocument(resolver, root, "text/plain", previousName); + } + if (previous != null) { + try (InputStream in = resolver.openInputStream(current); + OutputStream out = resolver.openOutputStream(previous, "wt")) { + if (in != null && out != null) { + byte[] buffer = new byte[16384]; + int count; + while ((count = in.read(buffer)) != -1) out.write(buffer, 0, count); + out.flush(); + } + } + } + } else { + current = DocumentsContract.createDocument(resolver, root, "text/plain", currentName); + } + if (current == null) return -1; + ParcelFileDescriptor descriptor = resolver.openFileDescriptor(current, "wt"); + return descriptor != null ? descriptor.detachFd() : -1; + } catch (Exception e) { + return -1; + } + } + + private static Uri findChild(Uri folder, String wantedName) { + Uri children = DocumentsContract.buildChildDocumentsUriUsingTree( + folder, DocumentsContract.getDocumentId(folder)); + String[] columns = {DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME}; + try (Cursor cursor = mSingleton.getContentResolver().query(children, columns, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + if (wantedName.equals(cursor.getString(1))) { + return DocumentsContract.buildDocumentUriUsingTree(folder, cursor.getString(0)); + } + } + } catch (Exception ignored) { + return null; + } + return null; + } + /** Starts the app afresh: this process ends and a new one opens the activity. */ public static void relaunch() { if (mSingleton == null) return; From eaf78584be80f84877de00a8d197a7b01dfc88ab Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 03:55:50 +0700 Subject: [PATCH 03/32] Declare Android crash log export descriptor --- host/platform/android_jni.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/host/platform/android_jni.hpp b/host/platform/android_jni.hpp index 08cb1cf..b6633b1 100644 --- a/host/platform/android_jni.hpp +++ b/host/platform/android_jni.hpp @@ -21,7 +21,7 @@ struct Insets { // The display cutout, in window pixels; zero where there is none. [[nodiscard]] Insets cutout_insets(); -// Ends this process and starts the app again; returns only if it cannot. +// Opens the persistent user-selected log folder and returns an owned fd, or -1.\n[[nodiscard]] int open_crash_log(const std::string &app_name);\n\n// Ends this process and starts the app again; returns only if it cannot. void relaunch(); // A folder the player picks, as a tree URI; nothing when cancelled. From 41ee62ec807c6e489b5a9a0c885fa94b717e3942 Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 03:55:56 +0700 Subject: [PATCH 04/32] Call Android crash log folder bridge --- host/platform/android_jni.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/host/platform/android_jni.cpp b/host/platform/android_jni.cpp index 5073494..d67d757 100644 --- a/host/platform/android_jni.cpp +++ b/host/platform/android_jni.cpp @@ -69,6 +69,18 @@ Insets cutout_insets() { return {values[0], values[1], values[2], values[3]}; } +int open_crash_log(const std::string &app_name) { + Call call; + if (!call.ok()) return -1; + jmethodID id = call.method("openCrashLog", "(Ljava/lang/String;)I"); + if (id == nullptr) return -1; + jstring name = call.string(app_name); + const jint fd = call.env->CallStaticIntMethod(call.type, id, name); + call.env->DeleteLocalRef(name); + if (call.failed()) return -1; + return static_cast(fd); +} + void relaunch() { Call call; if (!call.ok()) return; From 6b228db0263b3a7330b9d5c31267a06cf8c0e6f5 Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 03:56:04 +0700 Subject: [PATCH 05/32] Write Android crash logs to chosen document folder --- host/platform/android_app.cpp | 70 +++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/host/platform/android_app.cpp b/host/platform/android_app.cpp index f5c6539..79fb2f0 100644 --- a/host/platform/android_app.cpp +++ b/host/platform/android_app.cpp @@ -11,6 +11,76 @@ #include #include +#include +#include + +#include +#include +#include +#include +#include + +int portablekit_main(int argc, char **argv); + +namespace { + +constexpr std::size_t kGameStackBytes = 64u * 1024u * 1024u; + +struct Launch { + int argc; + char **argv; + int result; +}; + +void *run_game(void *argument) { + auto *launch = static_cast(argument); + launch->result = portablekit_main(launch->argc, launch->argv); + return nullptr; +} + +// An app has no terminal: what the program prints goes to .log in +// its internal storage, where `adb shell run-as` and the menu's Save the log +// reach it; the one before is kept as -previous.log. +void redirect_output() { + // Prefer the folder the player chose in Android's system picker. The + // Java bridge retains its SAF permission across restarts, so the log is + // available from Documents without ADB even after a native crash. + const int picked = portablekit::android::open_crash_log(portablekit::game().project_name); + if (picked >= 0) { + if (::dup2(picked, STDOUT_FILENO) >= 0 && ::dup2(picked, STDERR_FILENO) >= 0) { + ::close(picked); + std::setvbuf(stdout, nullptr, _IOLBF, 0); + std::setvbuf(stderr, nullptr, _IONBF, 0); + std::fprintf(stderr, "\\n=== %s startup ===\\n", portablekit::game().project_name); + return; + } + ::close(picked); + } + + // A cancelled picker or an unavailable document provider must not prevent + // the app from launching; retain the private-storage fallback. + const char *storage = SDL_GetAndroidInternalStoragePath(); + if (storage == nullptr) return; + const std::string log = std::string(storage) + "/" + portablekit::game().project_name + ".log"; + std::rename(log.c_str(), + (std::string(storage) + "/" + portablekit::game().project_name + "-previous.log").c_str()); + if (std::freopen(log.c_str(), "w", stdout) != nullptr) std::setvbuf(stdout, nullptr, _IOLBF, 0); + if (std::freopen(log.c_str(), "a", stderr) != nullptr) std::setvbuf(stderr, nullptr, _IONBF, 0); +}/ The entry point of the Android app. SDLActivity loads libmain.so and calls +// SDL_main on a Java thread whose stack is far too small for chained AOT +// calls, and an app cannot restart itself with a larger one the way the Linux +// executable does. So the game runs on a thread of its own with the 64 MiB +// stack the other platforms give their main thread. + +#include "app_paths.hpp" +#include "profile.hpp" + +#include +#include + +#include +#include +#include #include #include From dd5f8eb8c546313be98b596df47f2efa01ccecf2 Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 03:56:29 +0700 Subject: [PATCH 06/32] Write Android crash logs to chosen document folder --- host/platform/android_app.cpp | 73 ++++------------------------------- 1 file changed, 8 insertions(+), 65 deletions(-) diff --git a/host/platform/android_app.cpp b/host/platform/android_app.cpp index 79fb2f0..9401152 100644 --- a/host/platform/android_app.cpp +++ b/host/platform/android_app.cpp @@ -11,7 +11,6 @@ #include #include -#include #include #include @@ -38,27 +37,23 @@ void *run_game(void *argument) { return nullptr; } -// An app has no terminal: what the program prints goes to .log in -// its internal storage, where `adb shell run-as` and the menu's Save the log -// reach it; the one before is kept as -previous.log. +// An app has no terminal: write output to a user-selected folder when available. void redirect_output() { - // Prefer the folder the player chose in Android's system picker. The - // Java bridge retains its SAF permission across restarts, so the log is - // available from Documents without ADB even after a native crash. + // Prefer the folder selected through Android's document picker. The Java + // bridge retains the SAF permission across process death. const int picked = portablekit::android::open_crash_log(portablekit::game().project_name); if (picked >= 0) { - if (::dup2(picked, STDOUT_FILENO) >= 0 && ::dup2(picked, STDERR_FILENO) >= 0) { - ::close(picked); + const int out_ok = ::dup2(picked, STDOUT_FILENO); + const int err_ok = ::dup2(picked, STDERR_FILENO); + ::close(picked); + if (out_ok >= 0 && err_ok >= 0) { std::setvbuf(stdout, nullptr, _IOLBF, 0); std::setvbuf(stderr, nullptr, _IONBF, 0); std::fprintf(stderr, "\\n=== %s startup ===\\n", portablekit::game().project_name); return; } - ::close(picked); } - - // A cancelled picker or an unavailable document provider must not prevent - // the app from launching; retain the private-storage fallback. + // If the picker was cancelled or unavailable, keep the private fallback. const char *storage = SDL_GetAndroidInternalStoragePath(); if (storage == nullptr) return; const std::string log = std::string(storage) + "/" + portablekit::game().project_name + ".log"; @@ -66,58 +61,6 @@ void redirect_output() { (std::string(storage) + "/" + portablekit::game().project_name + "-previous.log").c_str()); if (std::freopen(log.c_str(), "w", stdout) != nullptr) std::setvbuf(stdout, nullptr, _IOLBF, 0); if (std::freopen(log.c_str(), "a", stderr) != nullptr) std::setvbuf(stderr, nullptr, _IONBF, 0); -}/ The entry point of the Android app. SDLActivity loads libmain.so and calls -// SDL_main on a Java thread whose stack is far too small for chained AOT -// calls, and an app cannot restart itself with a larger one the way the Linux -// executable does. So the game runs on a thread of its own with the 64 MiB -// stack the other platforms give their main thread. - -#include "app_paths.hpp" -#include "profile.hpp" - -#include -#include - -#include -#include -#include - -#include -#include -#include -#include -#include - -int portablekit_main(int argc, char **argv); - -namespace { - -constexpr std::size_t kGameStackBytes = 64u * 1024u * 1024u; - -struct Launch { - int argc; - char **argv; - int result; -}; - -void *run_game(void *argument) { - auto *launch = static_cast(argument); - launch->result = portablekit_main(launch->argc, launch->argv); - return nullptr; -} - -// An app has no terminal: what the program prints goes to .log in -// its internal storage, where `adb shell run-as` and the menu's Save the log -// reach it; the one before is kept as -previous.log. -void redirect_output() { - const char *storage = SDL_GetAndroidInternalStoragePath(); - if (storage == nullptr) return; - const std::string log = std::string(storage) + "/" + portablekit::game().app_name + ".log"; - // The previous run's log survives one start, for reporting a crash. - std::rename(log.c_str(), - (std::string(storage) + "/" + portablekit::game().app_name + "-previous.log").c_str()); - if (std::freopen(log.c_str(), "w", stdout) != nullptr) std::setvbuf(stdout, nullptr, _IOLBF, 0); - if (std::freopen(log.c_str(), "a", stderr) != nullptr) std::setvbuf(stderr, nullptr, _IONBF, 0); } // The APK carries the fallback font a release ships in fonts/, as an asset. From eb9dba90e8722c22e7fe0d5d967dcf658e7a40c6 Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 03:56:39 +0700 Subject: [PATCH 07/32] Include Android log bridge declaration --- host/platform/android_app.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/host/platform/android_app.cpp b/host/platform/android_app.cpp index 9401152..d9e4c91 100644 --- a/host/platform/android_app.cpp +++ b/host/platform/android_app.cpp @@ -5,6 +5,7 @@ // stack the other platforms give their main thread. #include "app_paths.hpp" +#include "platform/android_jni.hpp" #include "profile.hpp" #include From 990a4466568a1ccca9753348ab267c82dd6e8e47 Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 03:57:38 +0700 Subject: [PATCH 08/32] Fix Android log bridge declaration formatting --- host/platform/android_jni.hpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/host/platform/android_jni.hpp b/host/platform/android_jni.hpp index b6633b1..c8dc697 100644 --- a/host/platform/android_jni.hpp +++ b/host/platform/android_jni.hpp @@ -21,7 +21,10 @@ struct Insets { // The display cutout, in window pixels; zero where there is none. [[nodiscard]] Insets cutout_insets(); -// Opens the persistent user-selected log folder and returns an owned fd, or -1.\n[[nodiscard]] int open_crash_log(const std::string &app_name);\n\n// Ends this process and starts the app again; returns only if it cannot. +// Opens the persistent user-selected log folder and returns an owned fd, or -1. +[[nodiscard]] int open_crash_log(const std::string &app_name); + +// Ends this process and starts the app again; returns only if it cannot. void relaunch(); // A folder the player picks, as a tree URI; nothing when cancelled. From 19177a16593c46b0fd7d37cf45100092a06b33c0 Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 03:57:44 +0700 Subject: [PATCH 09/32] Fix newline escapes in Android log header From 51fad7b2b50891500b8d07724fe34f44b1c3b70b Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 03:57:52 +0700 Subject: [PATCH 10/32] Fix newline escapes in Android log header --- host/platform/android_app.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/host/platform/android_app.cpp b/host/platform/android_app.cpp index d9e4c91..3423cc9 100644 --- a/host/platform/android_app.cpp +++ b/host/platform/android_app.cpp @@ -50,7 +50,7 @@ void redirect_output() { if (out_ok >= 0 && err_ok >= 0) { std::setvbuf(stdout, nullptr, _IOLBF, 0); std::setvbuf(stderr, nullptr, _IONBF, 0); - std::fprintf(stderr, "\\n=== %s startup ===\\n", portablekit::game().project_name); + std::fprintf(stderr, "\n=== %s startup ===\n", portablekit::game().project_name); return; } } From bb0c2bc611ec5becfcba66ef1f8785dd7de09822 Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 04:07:54 +0700 Subject: [PATCH 11/32] Implement PSP TLS pool functions --- host/hle/hle_threadman.cpp | 110 +++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/host/hle/hle_threadman.cpp b/host/hle/hle_threadman.cpp index f4ec4b2..dc34ddd 100644 --- a/host/hle/hle_threadman.cpp +++ b/host/hle/hle_threadman.cpp @@ -952,6 +952,115 @@ void register_lightweight_mutexes(HleRegistrar &hle) { }); } +struct TlsPool { + SceUID memory_block{}; + std::uint32_t base{}; + std::uint32_t block_size{}; + std::uint32_t block_count{}; + std::map thread_slots; +}; + +std::map &tls_pools() { + static std::map pools; + return pools; +} + +void register_tls_pools(HleRegistrar &hle) { + // PSP TLS pools provide one fixed-size block per thread. The pool's guest + // memory is allocated once; each thread receives its own zeroed slot on + // the first sceKernelGetTlsAddr call. + hle.add("ThreadManForUser", "sceKernelCreateTlspl", [](Runtime &rt, AllegrexContext &ctx) { + constexpr std::uint32_t kTlsOptionsSize = 8u; + const std::string name = read_cstring(rt.memory(), arg(ctx, 0), 32u); + const std::uint32_t requested_size = arg(ctx, 3); + const std::uint32_t count = arg(ctx, 4); + if (requested_size == 0u || count == 0u) { + kernel().finish(ctx, error::kIllegalArgument); + return; + } + + std::uint32_t alignment = 4u; + const std::uint32_t options = arg(ctx, 5); + if (rt.memory().contains(options, kTlsOptionsSize) && rt.memory().load32(options) >= kTlsOptionsSize) { + const std::uint32_t requested_alignment = rt.memory().load32(options + 4u); + if (requested_alignment != 0u) { + if ((requested_alignment & (requested_alignment - 1u)) != 0u) { + kernel().finish(ctx, error::kIllegalArgument); + return; + } + alignment = std::max(4u, requested_alignment); + } + } + + const std::uint64_t block_size = + (static_cast(requested_size) + alignment - 1u) & ~static_cast(alignment - 1u); + const std::uint64_t total_size = block_size * count; + if (block_size == 0u || total_size > 0xFFFFFFFFu) { + kernel().finish(ctx, error::kNoMemory); + return; + } + + const std::int32_t memory_block = + kernel().allocate_block("tls:" + name, 1u, static_cast(total_size), alignment); + if (memory_block < 0) { + kernel().finish(ctx, static_cast(memory_block)); + return; + } + const MemoryBlock *block = kernel().find_block(memory_block); + if (block == nullptr) { + kernel().free_block(memory_block); + kernel().finish(ctx, error::kNoMemory); + return; + } + for (std::uint32_t i = 0; i < total_size; ++i) rt.memory().store8(block->address + i, 0u); + + const SceUID uid = kernel().allocate_uid(); + tls_pools().emplace(uid, TlsPool{memory_block, block->address, static_cast(block_size), count, {}}); + kernel().finish(ctx, static_cast(uid)); + }); + + hle.add("ThreadManForUser", "sceKernelDeleteTlspl", [](Runtime &, AllegrexContext &ctx) { + constexpr std::uint32_t kUnknownTlspl = 0x800201D0u; + const SceUID uid = static_cast(arg(ctx, 0)); + auto found = tls_pools().find(uid); + if (found == tls_pools().end()) { + kernel().finish(ctx, kUnknownTlspl); + return; + } + kernel().free_block(found->second.memory_block); + tls_pools().erase(found); + kernel().finish(ctx, 0u); + }); + + hle.add("Kernel_Library", "sceKernelGetTlsAddr", [](Runtime &, AllegrexContext &ctx) { + constexpr std::uint32_t kUnknownTlspl = 0x800201D0u; + constexpr std::uint32_t kTlsPoolFull = 0x800201D1u; + const SceUID uid = static_cast(arg(ctx, 0)); + auto found = tls_pools().find(uid); + if (found == tls_pools().end()) { + kernel().finish(ctx, kUnknownTlspl); + return; + } + + TlsPool &pool = found->second; + const SceUID thread = kernel().current_uid(); + if (const auto slot = pool.thread_slots.find(thread); slot != pool.thread_slots.end()) { + kernel().finish(ctx, pool.base + slot->second * pool.block_size); + return; + } + + for (std::uint32_t candidate = 0; candidate < pool.block_count; ++candidate) { + const bool occupied = std::any_of(pool.thread_slots.begin(), pool.thread_slots.end(), + [candidate](const auto &entry) { return entry.second == candidate; }); + if (occupied) continue; + pool.thread_slots.emplace(thread, candidate); + kernel().finish(ctx, pool.base + candidate * pool.block_size); + return; + } + kernel().finish(ctx, kTlsPoolFull); + }); +} + void register_kernel_library(HleRegistrar &hle) { // Bytes left on the current thread's stack, which a game checks before it // recurses. A stub returning 0 says the stack is exhausted, and the game @@ -1681,6 +1790,7 @@ void register_threadman(HleRegistrar &hle) { register_msg_pipes(hle); register_callbacks_and_timers(hle); register_kernel_library(hle); + register_tls_pools(hle); } } // namespace portablekit From 8b1dcc0324739308442235a4c772768dc25e4f9a Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 04:07:57 +0700 Subject: [PATCH 12/32] Register PSP TLS function NIDs --- configs/nids.csv | 3 +++ 1 file changed, 3 insertions(+) diff --git a/configs/nids.csv b/configs/nids.csv index 5c32ea3..ed7aae9 100644 --- a/configs/nids.csv +++ b/configs/nids.csv @@ -566,3 +566,6 @@ sceNetResolver,0x244172AF,sceNetResolverCreate sceNetResolver,0x224C5F44,sceNetResolverStartNtoA scePspNpDrm_user,0xA1336091,sceNpDrmSetLicenseeKey scePspNpDrm_user,0x08D98894,sceNpDrmEdataSetupKey +ThreadManForUser,0x8DAFF657,sceKernelCreateTlspl +ThreadManForUser,0x32BF938E,sceKernelDeleteTlspl +Kernel_Library,0xFA835CDE,sceKernelGetTlsAddr From dc7dce0fa639e7d152a857bcba7f8eb857c6f363 Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 04:22:21 +0700 Subject: [PATCH 13/32] Report Allegrex BREAK as a guest exception with registers --- src/interpreter.cpp | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/interpreter.cpp b/src/interpreter.cpp index 80387d4..dbc35a2 100644 --- a/src/interpreter.cpp +++ b/src/interpreter.cpp @@ -248,6 +248,25 @@ bool execute_simple(Runtime &rt, AllegrexContext &ctx, const DecodedInstruction const std::uint32_t rt_value = ctx.gpr[d.rt]; switch (d.kind) { + case OpcodeKind::Break: { + // BREAK raises a guest exception on a real Allegrex CPU. Until the + // runtime has a guest exception-vector implementation, stop explicitly + // with the breakpoint code and live registers instead of reporting a + // generic unsupported instruction. This is especially useful for + // guest assertions, which commonly terminate by executing BREAK. + const std::uint32_t break_code = (d.word >> 6u) & 0x000FFFFFu; + ctx.pc = pc; + rt.stop("Guest BREAK exception at " + hex32(pc) + + " code=" + hex32(break_code) + + " instruction=" + hex32(d.word) + + " ra=" + hex32(ctx.gpr[31]) + + " v0=" + hex32(ctx.gpr[2]) + + " a0=" + hex32(ctx.gpr[4]) + + " a1=" + hex32(ctx.gpr[5]) + + " a2=" + hex32(ctx.gpr[6]) + + " a3=" + hex32(ctx.gpr[7])); + return false; + } case OpcodeKind::Nop: break; // PSP CACHE is a hint against a cache the host mapping does not have. case OpcodeKind::Cache: break; From 16e6acde75d53d5d283d394893f5701d2b6fa5cf Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 04:23:03 +0700 Subject: [PATCH 14/32] Implement non-blocking PSP lightweight mutex try-lock --- host/hle/hle_threadman.cpp | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/host/hle/hle_threadman.cpp b/host/hle/hle_threadman.cpp index dc34ddd..a8a48bf 100644 --- a/host/hle/hle_threadman.cpp +++ b/host/hle/hle_threadman.cpp @@ -925,6 +925,38 @@ void register_lightweight_mutexes(HleRegistrar &hle) { }; hle.add("Kernel_Library", "sceKernelLockLwMutex", lock); hle.add("Kernel_Library", "sceKernelLockLwMutexCB", lock); + // Non-blocking counterpart to sceKernelLockLwMutex. Unlike the generic + // logging stub, this must report contention so callers can decide whether + // to retry or take their file-system error path. + hle.add("Kernel_Library", "sceKernelTryLockLwMutex_600", [](Runtime &rt, AllegrexContext &ctx) { + const std::uint32_t work_area = arg(ctx, 0); + const std::int32_t count = as_signed(arg(ctx, 1)); + SceUID uid = 0; + Mutex *mutex = lwmutex::find(work_area, uid); + if (mutex == nullptr) { + kernel().finish(ctx, error::kMutexNotFound); + return; + } + if (count <= 0) { + kernel().finish(ctx, error::kIllegalCount); + return; + } + if (mutex->lock_count == 0) { + mutex->owner = kernel().current_uid(); + mutex->lock_count = count; + } else if (mutex->owner == kernel().current_uid()) { + if ((mutex->attributes & kMutexAttrRecursive) == 0u) { + kernel().finish(ctx, error::kMutexRecursiveNotAllowed); + return; + } + mutex->lock_count += count; + } else { + kernel().finish(ctx, error::kMutexLocked); + return; + } + lwmutex::write_state(rt.memory(), work_area, *mutex); + kernel().finish(ctx, 0u); + }); hle.add("Kernel_Library", "sceKernelUnlockLwMutex", [](Runtime &rt, AllegrexContext &ctx) { const std::uint32_t work_area = arg(ctx, 0); const std::int32_t count = as_signed(arg(ctx, 1)); From 2825c481aa4d183126e020d28463b6b419b4511d Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 04:23:06 +0700 Subject: [PATCH 15/32] Register sceKernelTryLockLwMutex_600 NID --- configs/nids.csv | 1 + 1 file changed, 1 insertion(+) diff --git a/configs/nids.csv b/configs/nids.csv index ed7aae9..da90e04 100644 --- a/configs/nids.csv +++ b/configs/nids.csv @@ -39,6 +39,7 @@ Kernel_Library,0x1839852A,sceKernelMemcpy Kernel_Library,0x1FC64E09,sceKernelLockLwMutexCB Kernel_Library,0x293B45B8,sceKernelGetThreadId Kernel_Library,0x5F10D406,sceKernelCpuResumeIntr +Kernel_Library,0x37431849,sceKernelTryLockLwMutex_600 Kernel_Library,0x3B84732D,sceKernelCpuResumeIntrWithSync Kernel_Library,0xA089ECA4,sceKernelMemset Kernel_Library,0xB55249D2,sceKernelIsCpuIntrEnable From 731d117c5b28e2f4327cba841b2a1d765f52fb99 Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 04:36:54 +0700 Subject: [PATCH 16/32] Implement sceFontOpenUserMemory for game startup --- host/hle/hle_font.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/host/hle/hle_font.cpp b/host/hle/hle_font.cpp index 5ff3cdc..60c466e 100644 --- a/host/hle/hle_font.cpp +++ b/host/hle/hle_font.cpp @@ -303,6 +303,21 @@ void register_font(HleRegistrar &hle) { if (arg(ctx, 3) != 0u) rt.memory().store32(arg(ctx, 3), 0u); kernel().finish(ctx, fonts::ready() ? kFontHandle : 0u); }); + // The game embeds a font in guest memory and opens it through this API. + // Glyphs are rendered by the existing host font backend, so the PSP font + // bytes are not parsed here; however, returning the logging-stub value 0 + // causes the game's font initialization assertion to fail. + hle.add("sceLibFont", "sceFontOpenUserMemory", [](Runtime &rt, AllegrexContext &ctx) { + const std::uint32_t library = arg(ctx, 0); + const std::uint32_t font_data = arg(ctx, 1); + const std::uint32_t font_size = arg(ctx, 2); + const std::uint32_t mode = arg(ctx, 3); + const std::uint32_t error_code = arg(ctx, 4); + trace("OpenUserMemory lib=%08X data=%08X size=%08X mode=%08X error=%08X ra=%08X", + library, font_data, font_size, mode, error_code, ctx.gpr[31]); + if (error_code != 0u) rt.memory().store32(error_code, 0u); + kernel().finish(ctx, fonts::ready() && font_data != 0u && font_size != 0u ? kFontHandle : 0u); + }); hle.add("sceLibFont", "sceFontClose", [](Runtime &, AllegrexContext &ctx) { trace("Close font=%08X", arg(ctx, 0)); kernel().finish(ctx, 0u); From 64f7b32a3475319a6c3b237486b2318aa27af4d7 Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 04:45:55 +0700 Subject: [PATCH 17/32] Fix sceFontOpenUserMemory error pointer argument --- host/hle/hle_font.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/host/hle/hle_font.cpp b/host/hle/hle_font.cpp index 60c466e..a7d84de 100644 --- a/host/hle/hle_font.cpp +++ b/host/hle/hle_font.cpp @@ -311,10 +311,11 @@ void register_font(HleRegistrar &hle) { const std::uint32_t library = arg(ctx, 0); const std::uint32_t font_data = arg(ctx, 1); const std::uint32_t font_size = arg(ctx, 2); - const std::uint32_t mode = arg(ctx, 3); - const std::uint32_t error_code = arg(ctx, 4); - trace("OpenUserMemory lib=%08X data=%08X size=%08X mode=%08X error=%08X ra=%08X", - library, font_data, font_size, mode, error_code, ctx.gpr[31]); + // PSP SDK signature is (library, fontData, fontSize, errorCodePtr): + // the fourth argument is a3, not t0/a fifth pseudo-argument. + const std::uint32_t error_code = arg(ctx, 3); + trace("OpenUserMemory lib=%08X data=%08X size=%08X error=%08X ra=%08X", + library, font_data, font_size, error_code, ctx.gpr[31]); if (error_code != 0u) rt.memory().store32(error_code, 0u); kernel().finish(ctx, fonts::ready() && font_data != 0u && font_size != 0u ? kFontHandle : 0u); }); From 611868f3b46d08de65170d5528f14f5fbd2f5e24 Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 04:50:54 +0700 Subject: [PATCH 18/32] Mirror extracted-game patch folder to virtual PSP memory stick --- host/main.cpp | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/host/main.cpp b/host/main.cpp index 1a404e0..34e4ed5 100644 --- a/host/main.cpp +++ b/host/main.cpp @@ -143,6 +143,41 @@ struct GameFiles { std::filesystem::path memory_stick; }; +// Some games ship an install/patch folder beside PSP_GAME in the extracted +// game root, but address it through ms0:/PSP/GAME/. Mirror that folder +// into the virtual memory stick before booting so the guest's PSP path resolves. +void sync_memory_stick_patch(const GameFiles &files) { + const char *folder_name = portablekit::game().memory_stick_patch_folder; + if (folder_name == nullptr || *folder_name == '\0' || + files.disc_image.empty() || !std::filesystem::is_directory(files.disc_image)) + return; + + const std::filesystem::path source = files.disc_image / folder_name; + std::error_code ec; + if (!std::filesystem::is_directory(source, ec) || ec) { + std::cerr << "[patch] no patch folder at " << source.string() << "\\n"; + return; + } + + const std::filesystem::path target = files.memory_stick / "PSP" / "GAME" / folder_name; + std::filesystem::create_directories(target.parent_path(), ec); + if (ec) { + std::cerr << "[patch] cannot create " << target.parent_path().string() << ": " << ec.message() << "\\n"; + return; + } + std::filesystem::copy(source, target, + std::filesystem::copy_options::recursive | + std::filesystem::copy_options::overwrite_existing, + ec); + if (ec) { + std::cerr << "[patch] failed to copy " << source.string() << " to " << target.string() + << ": " << ec.message() << "\\n"; + return; + } + std::cerr << "[patch] copied " << source.string() << " to virtual ms0:/" << "PSP/GAME/" + << folder_name << "\\n"; +} + // The layout a game_dir has always had; the profile's game directory by default. GameFiles files_in_game_directory(const std::filesystem::path &game_dir) { GameFiles files; @@ -355,6 +390,7 @@ int main(int argc, char **argv) { const std::optional files = locate_game(options); if (!files) return 1; + sync_memory_stick_patch(*files); const std::filesystem::path &executable = files->executable; portablekit::ProfilePaths paths; paths.disc_image = files->disc_image; From 42f90fe416e32544e85f6fd605a1d56bebc6c162 Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 04:50:58 +0700 Subject: [PATCH 19/32] Add optional memory-stick patch folder to game profile --- host/profile.hpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/host/profile.hpp b/host/profile.hpp index 09322ed..a15c5db 100644 --- a/host/profile.hpp +++ b/host/profile.hpp @@ -199,6 +199,8 @@ struct GameProfile { // --- Save data --------------------------------------------------------- const char *save_game_name; // the main folder, e.g. "ULJM05800" std::span save_folders; + // Optional patch/install folder beside PSP_GAME in an extracted game root, mirrored to ms0:/PSP/GAME/. + const char *memory_stick_patch_folder = nullptr; // --- What the player is called ----------------------------------------- // How the interface names the name the game asks for: "Hunter name". From 2f87e242814c664e8defde304b9229a87e47201a Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 04:54:08 +0700 Subject: [PATCH 20/32] Fix patch sync log line endings --- host/main.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/host/main.cpp b/host/main.cpp index 34e4ed5..05dba94 100644 --- a/host/main.cpp +++ b/host/main.cpp @@ -155,14 +155,14 @@ void sync_memory_stick_patch(const GameFiles &files) { const std::filesystem::path source = files.disc_image / folder_name; std::error_code ec; if (!std::filesystem::is_directory(source, ec) || ec) { - std::cerr << "[patch] no patch folder at " << source.string() << "\\n"; + std::cerr << "[patch] no patch folder at " << source.string() << "\n"; return; } const std::filesystem::path target = files.memory_stick / "PSP" / "GAME" / folder_name; std::filesystem::create_directories(target.parent_path(), ec); if (ec) { - std::cerr << "[patch] cannot create " << target.parent_path().string() << ": " << ec.message() << "\\n"; + std::cerr << "[patch] cannot create " << target.parent_path().string() << ": " << ec.message() << "\n"; return; } std::filesystem::copy(source, target, @@ -171,11 +171,11 @@ void sync_memory_stick_patch(const GameFiles &files) { ec); if (ec) { std::cerr << "[patch] failed to copy " << source.string() << " to " << target.string() - << ": " << ec.message() << "\\n"; + << ": " << ec.message() << "\n"; return; } std::cerr << "[patch] copied " << source.string() << " to virtual ms0:/" << "PSP/GAME/" - << folder_name << "\\n"; + << folder_name << "\n"; } // The layout a game_dir has always had; the profile's game directory by default. From 22b80419f34d3c923473565665b82b85124848bd Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 05:04:15 +0700 Subject: [PATCH 21/32] Implement sceFontSetAltCharacterCode --- host/hle/hle_font.cpp | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/host/hle/hle_font.cpp b/host/hle/hle_font.cpp index a7d84de..698b04d 100644 --- a/host/hle/hle_font.cpp +++ b/host/hle/hle_font.cpp @@ -256,6 +256,11 @@ void blit_glyph(psprecomp::GuestMemory &memory, std::uint32_t image_address, std } // namespace +std::uint32_t &alternate_character_code() { + static std::uint32_t code = 0u; + return code; +} + void register_font(HleRegistrar &hle) { fonts::ready(); fonts::set_reload_hook(forget_game_glyphs); @@ -319,6 +324,14 @@ void register_font(HleRegistrar &hle) { if (error_code != 0u) rt.memory().store32(error_code, 0u); kernel().finish(ctx, fonts::ready() && font_data != 0u && font_size != 0u ? kFontHandle : 0u); }); + // The game uses this fallback when a requested glyph is absent from the host font. + hle.add("sceLibFont", "sceFontSetAltCharacterCode", [](Runtime &, AllegrexContext &ctx) { + alternate_character_code() = arg(ctx, 1) & 0xFFFFu; + trace("SetAltCharacterCode lib=%08X code=%04X ra=%08X", arg(ctx, 0), + alternate_character_code(), ctx.gpr[31]); + kernel().finish(ctx, 0u); + }); + hle.add("sceLibFont", "sceFontClose", [](Runtime &, AllegrexContext &ctx) { trace("Close font=%08X", arg(ctx, 0)); kernel().finish(ctx, 0u); @@ -338,7 +351,12 @@ void register_font(HleRegistrar &hle) { kernel().finish(ctx, 0u); return; } - const fonts::GlyphMetrics glyph = fonts::ready() ? fonts::metrics(arg(ctx, 1)) : fonts::GlyphMetrics{}; + std::uint32_t code = arg(ctx, 1); + fonts::GlyphMetrics glyph = fonts::ready() ? fonts::metrics(code) : fonts::GlyphMetrics{}; + if (fonts::ready() && !glyph.found && alternate_character_code() != 0u) { + code = alternate_character_code(); + glyph = fonts::metrics(code); + } write_char_info(rt.memory(), address, glyph); trace("GetCharInfo code=%04X w=%d h=%d left=%d top=%d adv=%.2f%s ra=%08X", arg(ctx, 1), glyph.width, glyph.height, glyph.left, glyph.top, static_cast(glyph.advance), glyph.found ? "" : " missing", @@ -356,7 +374,12 @@ void register_font(HleRegistrar &hle) { m.load16(image + 16u), m.load32(image + 20u), ctx.gpr[31]); } note_game_cache(rt.memory(), ctx); - if (image != 0u && fonts::ready()) blit_glyph(rt.memory(), image, arg(ctx, 1)); + if (image != 0u && fonts::ready()) { + std::uint32_t code = arg(ctx, 1); + if (!fonts::metrics(code).found && alternate_character_code() != 0u) + code = alternate_character_code(); + blit_glyph(rt.memory(), image, code); + } kernel().finish(ctx, 0u); }); // The same, drawing only inside the rectangle (a3, t0) sized (t1, t2). From 7dc453786e00354d7f4363a7e10152840b7b34a1 Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 05:04:18 +0700 Subject: [PATCH 22/32] Name sceFontSetAltCharacterCode NID --- configs/nids.csv | 1 + 1 file changed, 1 insertion(+) diff --git a/configs/nids.csv b/configs/nids.csv index da90e04..d72543d 100644 --- a/configs/nids.csv +++ b/configs/nids.csv @@ -164,6 +164,7 @@ sceLibFont,0x67F17ED7,sceFontNewLib sceLibFont,0x980F4895,sceFontGetCharGlyphImage sceLibFont,0xA834319D,sceFontOpen sceLibFont,0xDCC80C2F,sceFontGetCharInfo +sceLibFont,0xEE232411,sceFontSetAltCharacterCode sceLibFont,0x02D7F94B,sceFontFlush sceLibFont,0x57FCB733,sceFontOpenUserFile sceLibFont,0xBB8E7FE6,sceFontOpenUserMemory From c730540404cf38a00f88ea4a55ca15d68791dd76 Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 05:06:28 +0700 Subject: [PATCH 23/32] Return EDATA file size from NPDRM size query --- host/hle/hle_io.cpp | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/host/hle/hle_io.cpp b/host/hle/hle_io.cpp index 63173f8..8988f43 100644 --- a/host/hle/hle_io.cpp +++ b/host/hle/hle_io.cpp @@ -934,6 +934,26 @@ void register_io(HleRegistrar &hle, const std::filesystem::path &disc_image, con kernel().finish(ctx, ioctl_op(rt, ctx)); }); + // sceNpDrmEdataGetDataSize delegates to IoIoctl(0x04100010) on PSP. + // Until EDAT/NPDRM decryption is implemented, report the opened file's + // current size rather than the generated stub's default zero. If a file + // filter has already been installed, file.size is its visible data size. + hle.add("scePspNpDrm_user", "sceNpDrmEdataGetDataSize", [](Runtime &, AllegrexContext &ctx) { + const std::uint32_t fd = arg(ctx, 0); + const auto found = io().files.find(fd); + if (found == io().files.end()) { + kernel().finish(ctx, io_error::kBadFileDescriptor); + return; + } + const OpenFile &file = found->second; + const std::uint32_t size = static_cast( + std::min(file.size, std::numeric_limits::max())); + std::cerr << "[npdrm] sceNpDrmEdataGetDataSize fd=" << fd << " path=" << file.path + << " size=" << size << (file.filter ? " (filtered)" : " (raw; decryption not applied)") << "\\n"; + kernel().finish(ctx, size); + }); + + hle.add("IoFileMgrForUser", "sceIoDevctl", [](Runtime &rt, AllegrexContext &ctx) { const std::string device = read_cstring(rt.memory(), arg(ctx, 0), 64u); const std::uint32_t command = arg(ctx, 1); From 07f590e74befb043477aa16cd591f40980c7c100 Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 05:06:49 +0700 Subject: [PATCH 24/32] Fix NPDRM size log line ending --- host/hle/hle_io.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/host/hle/hle_io.cpp b/host/hle/hle_io.cpp index 8988f43..43cf7ea 100644 --- a/host/hle/hle_io.cpp +++ b/host/hle/hle_io.cpp @@ -949,7 +949,7 @@ void register_io(HleRegistrar &hle, const std::filesystem::path &disc_image, con const std::uint32_t size = static_cast( std::min(file.size, std::numeric_limits::max())); std::cerr << "[npdrm] sceNpDrmEdataGetDataSize fd=" << fd << " path=" << file.path - << " size=" << size << (file.filter ? " (filtered)" : " (raw; decryption not applied)") << "\\n"; + << " size=" << size << (file.filter ? " (filtered)" : " (raw; decryption not applied)") << "\n"; kernel().finish(ctx, size); }); From b85f75a552c5289dbf749c438710c05b362b490e Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 05:18:55 +0700 Subject: [PATCH 25/32] Register sceNpDrmEdataGetDataSize NID --- configs/nids.csv | 1 + 1 file changed, 1 insertion(+) diff --git a/configs/nids.csv b/configs/nids.csv index d72543d..99ad27e 100644 --- a/configs/nids.csv +++ b/configs/nids.csv @@ -571,3 +571,4 @@ scePspNpDrm_user,0x08D98894,sceNpDrmEdataSetupKey ThreadManForUser,0x8DAFF657,sceKernelCreateTlspl ThreadManForUser,0x32BF938E,sceKernelDeleteTlspl Kernel_Library,0xFA835CDE,sceKernelGetTlsAddr +scePspNpDrm_user,0x219EF5CC,sceNpDrmEdataGetDataSize From 05df8db7aeba582804fb0a383bc5eae88037e165 Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 05:35:49 +0700 Subject: [PATCH 26/32] Add per-game disc file aliases --- host/profile.hpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/host/profile.hpp b/host/profile.hpp index a15c5db..741d287 100644 --- a/host/profile.hpp +++ b/host/profile.hpp @@ -38,6 +38,13 @@ struct SaveFolder { bool exported; // included when the player exports or backs up }; + // A file physically stored on the disc but opened by the game through ms0:. + // Paths are relative to their device (no "ms0:/" or "disc0:/"). +struct DiscFileAlias { + const char *memory_stick_path; + const char *disc_path; +}; + // A choice of what LT/RT (L2/R2) press past the trigger point, beside the // framework's own: L and R, like the shoulders. struct TriggerProfile { @@ -201,6 +208,8 @@ struct GameProfile { std::span save_folders; // Optional patch/install folder beside PSP_GAME in an extracted game root, mirrored to ms0:/PSP/GAME/. const char *memory_stick_patch_folder = nullptr; + // Optional disc files exposed at game-requested memory-stick paths. + std::span disc_file_aliases = {}; // --- What the player is called ----------------------------------------- // How the interface names the name the game asks for: "Hunter name". From eb831674c78e95961146d90d5d64c90fe6f15bee Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 05:35:53 +0700 Subject: [PATCH 27/32] Resolve memory-stick paths to aliased disc files --- host/hle/hle_io.cpp | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/host/hle/hle_io.cpp b/host/hle/hle_io.cpp index 43cf7ea..51cbb22 100644 --- a/host/hle/hle_io.cpp +++ b/host/hle/hle_io.cpp @@ -217,6 +217,26 @@ std::optional> parse_lbn_path(const std: std::filesystem::path host_path(const std::string &path) { return io().memory_stick / path; } +std::string normalized_path(std::string path) { + std::replace(path.begin(), path.end(), '\\\\', '/'); + std::transform(path.begin(), path.end(), path.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + while (!path.empty() && path.front() == '/') path.erase(path.begin()); + while (!path.empty() && path.back() == '/') path.pop_back(); + return path; +} + +std::optional aliased_disc_file(const std::string &memory_stick_path) { + if (!io().disc) return std::nullopt; + const std::string requested = normalized_path(memory_stick_path); + for (const DiscFileAlias &alias : game().disc_file_aliases) { + if (normalized_path(alias.memory_stick_path) != requested) continue; + const auto entry = io().disc->find(alias.disc_path); + if (entry && !entry->directory) return entry; + } + return std::nullopt; +} + bool trace_io() { static const bool enabled = portablekit::env("TRACE_IO") != nullptr; return enabled; @@ -255,7 +275,19 @@ std::int64_t open_file(const std::string &full_path, std::uint32_t flags) { const auto path = host_path(split.path); std::error_code ec; const bool exists = std::filesystem::exists(path, ec); - if (!exists && (flags & kOpenCreate) == 0u) return static_cast(io_error::kFileNotFound); + if (!exists && (flags & kOpenCreate) == 0u) { + // Some titles store their RDP archives on the UMD but address them + // through an install/save-data path on the memory stick. + if (const auto entry = aliased_disc_file(split.path)) { + file.kind = OpenFile::Kind::Disc; + file.disc_offset = static_cast(entry->lba) * IsoImage::kSectorSize; + file.size = entry->size; + const std::uint32_t fd = io().next_fd++; + io().files.emplace(fd, std::move(file)); + return fd; + } + return static_cast(io_error::kFileNotFound); + } std::ios::openmode mode = std::ios::binary | std::ios::in; if ((flags & kOpenWrite) != 0u) { std::filesystem::create_directories(path.parent_path(), ec); @@ -808,6 +840,9 @@ void register_io(HleRegistrar &hle, const std::filesystem::path &disc_image, con const bool directory = std::filesystem::is_directory(host, ec); write_stat(rt.memory(), arg(ctx, 1), directory, directory ? 0u : std::filesystem::file_size(host, ec), 0u); result = 0u; + } else if (const auto entry = aliased_disc_file(split.path)) { + write_stat(rt.memory(), arg(ctx, 1), false, entry->size, entry->lba); + result = 0u; } } if (trace_io() || result != 0u) From 558945b19f334af458f26bb26bf8d85ffbbf5ed7 Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 05:36:55 +0700 Subject: [PATCH 28/32] Fix path separator normalization for disc aliases --- host/hle/hle_io.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/host/hle/hle_io.cpp b/host/hle/hle_io.cpp index 51cbb22..4023d12 100644 --- a/host/hle/hle_io.cpp +++ b/host/hle/hle_io.cpp @@ -218,7 +218,7 @@ std::optional> parse_lbn_path(const std: std::filesystem::path host_path(const std::string &path) { return io().memory_stick / path; } std::string normalized_path(std::string path) { - std::replace(path.begin(), path.end(), '\\\\', '/'); + std::replace(path.begin(), path.end(), '\\', '/'); std::transform(path.begin(), path.end(), path.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); while (!path.empty() && path.front() == '/') path.erase(path.begin()); From 0300cbbf0c23838ff8532e5bc5b46518e53445ad Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 05:37:54 +0700 Subject: [PATCH 29/32] Log when a disc archive is opened through its memory-stick alias --- host/hle/hle_io.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/host/hle/hle_io.cpp b/host/hle/hle_io.cpp index 4023d12..0739b9d 100644 --- a/host/hle/hle_io.cpp +++ b/host/hle/hle_io.cpp @@ -282,6 +282,8 @@ std::int64_t open_file(const std::string &full_path, std::uint32_t flags) { file.kind = OpenFile::Kind::Disc; file.disc_offset = static_cast(entry->lba) * IsoImage::kSectorSize; file.size = entry->size; + std::cerr << "[io] alias open " << full_path << " -> disc0:/" + << entry->name << " size=" << entry->size << "\\n"; const std::uint32_t fd = io().next_fd++; io().files.emplace(fd, std::move(file)); return fd; From 5eca10bc6e08d59ab2c0a78124dbb9501e32d50c Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 05:44:43 +0700 Subject: [PATCH 30/32] Fix newline in RDP alias diagnostic --- host/hle/hle_io.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/host/hle/hle_io.cpp b/host/hle/hle_io.cpp index 0739b9d..e14f37a 100644 --- a/host/hle/hle_io.cpp +++ b/host/hle/hle_io.cpp @@ -283,7 +283,7 @@ std::int64_t open_file(const std::string &full_path, std::uint32_t flags) { file.disc_offset = static_cast(entry->lba) * IsoImage::kSectorSize; file.size = entry->size; std::cerr << "[io] alias open " << full_path << " -> disc0:/" - << entry->name << " size=" << entry->size << "\\n"; + << entry->name << " size=" << entry->size << "\n"; const std::uint32_t fd = io().next_fd++; io().files.emplace(fd, std::move(file)); return fd; From 12b845b19de5039fe948de1cecfc7de638a84db7 Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 05:51:03 +0700 Subject: [PATCH 31/32] Allow raw RDP disc aliases through PGD-key ioctl --- host/hle/hle_io.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/host/hle/hle_io.cpp b/host/hle/hle_io.cpp index e14f37a..8782418 100644 --- a/host/hle/hle_io.cpp +++ b/host/hle/hle_io.cpp @@ -237,6 +237,11 @@ std::optional aliased_disc_file(const std::string &memory_stick return std::nullopt; } +bool is_disc_file_alias(const OpenFile &file) { + const SplitPath split = split_path(file.path); + return split.device == Device::MemoryStick && aliased_disc_file(split.path).has_value(); +} + bool trace_io() { static const bool enabled = portablekit::env("TRACE_IO") != nullptr; return enabled; @@ -600,6 +605,15 @@ std::uint32_t ioctl_op(Runtime &rt, AllegrexContext &ctx) { std::string error; auto opened = pgd::PgdFile::open(header, vkey, raw_size, pgd::player_keys(), error); if (!opened) { + // GOD EATER 2 opens its disc-resident RDP archives through ms0: + // with the PGD-key flag, but these RDP files are not PGD containers. + // Preserve the raw archive view instead of failing the whole open. + if (error == "not a PGD file" && is_disc_file_alias(file)) { + log_once("rdp-raw-" + file.path, "[pgd] " + file.path + + ": raw disc archive, skipping PGD decryption"); + file.position = 0u; + return 0u; + } log_once("pgd-" + file.path, "[pgd] " + file.path + ": cannot decrypt: " + error + " (see docs/DESKTOP_APP.md, \"PGD data\")"); return kPgdUnusable; From b43124d0860e891248d4493bc84c61510bae8223 Mon Sep 17 00:00:00 2001 From: Eld Axe Date: Wed, 14 Oct 2026 06:26:46 +0700 Subject: [PATCH 32/32] Add bounded RDP header read diagnostics --- host/hle/hle_io.cpp | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/host/hle/hle_io.cpp b/host/hle/hle_io.cpp index 8782418..37a8510 100644 --- a/host/hle/hle_io.cpp +++ b/host/hle/hle_io.cpp @@ -75,6 +75,7 @@ struct OpenFile { std::uint64_t disc_offset{}; // absolute image offset of byte 0 std::uint64_t size{}; std::uint64_t position{}; + std::uint32_t alias_diagnostic_reads{}; // The raw disc device counts 2048-byte sectors, not bytes: a seek moves // to a sector, a read asks for that many sectors and answers with how // many it got. Everything else here counts bytes, so size and position @@ -466,6 +467,27 @@ std::uint32_t read_file_op(Runtime &rt, std::uint32_t fd, std::uint32_t address, result = static_cast(buffer.size()); } rt.memory().copy_in(address, buffer); + + // GOD EATER 2 asserts while parsing DATA.RDP. Log a bounded sample of + // reads from the disc-backed aliases to reveal offsets, read sizes, and + // the bytes returned to the guest. This does not modify the file data. + if (is_disc_file_alias(file) && file.alias_diagnostic_reads < 8u) { + std::cerr << "[rdp-debug] path=" << file.path + << " offset=" << file.position + << " requested=" << requested + << " got=" << buffer.size() + << " first_bytes="; + const std::size_t byte_count = std::min(buffer.size(), 16u); + constexpr char hex[] = "0123456789ABCDEF"; + for (std::size_t i = 0; i < byte_count; ++i) { + const unsigned byte = buffer[i]; + std::cerr << hex[(byte >> 4u) & 0x0Fu] << hex[byte & 0x0Fu] << ' '; + } + if (buffer.empty()) std::cerr << ""; + std::cerr << '\n'; + ++file.alias_diagnostic_reads; + } + // A disc read is what tells a load from play (kernel/fast_loading.hpp). if (file.kind == OpenFile::Kind::Disc) { load_trace::note_disc_read(buffer.size());