diff --git a/configs/nids.csv b/configs/nids.csv index 5c32ea3..99ad27e 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 @@ -163,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 @@ -566,3 +568,7 @@ sceNetResolver,0x244172AF,sceNetResolverCreate sceNetResolver,0x224C5F44,sceNetResolverStartNtoA scePspNpDrm_user,0xA1336091,sceNpDrmSetLicenseeKey scePspNpDrm_user,0x08D98894,sceNpDrmEdataSetupKey +ThreadManForUser,0x8DAFF657,sceKernelCreateTlspl +ThreadManForUser,0x32BF938E,sceKernelDeleteTlspl +Kernel_Library,0xFA835CDE,sceKernelGetTlsAddr +scePspNpDrm_user,0x219EF5CC,sceNpDrmEdataGetDataSize 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/hle/hle_font.cpp b/host/hle/hle_font.cpp index 5ff3cdc..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); @@ -303,6 +308,30 @@ 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); + // 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); + }); + // 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); @@ -322,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", @@ -340,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). diff --git a/host/hle/hle_io.cpp b/host/hle/hle_io.cpp index 63173f8..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 @@ -217,6 +218,31 @@ 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 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; @@ -255,7 +281,21 @@ 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; + 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; + } + 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); @@ -427,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()); @@ -566,6 +627,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; @@ -808,6 +878,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) @@ -934,6 +1007,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); diff --git a/host/hle/hle_threadman.cpp b/host/hle/hle_threadman.cpp index f4ec4b2..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)); @@ -952,6 +984,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 +1822,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 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..05dba94 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; @@ -207,7 +242,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; @@ -354,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; diff --git a/host/platform/android_app.cpp b/host/platform/android_app.cpp index f5c6539..3423cc9 100644 --- a/host/platform/android_app.cpp +++ b/host/platform/android_app.cpp @@ -5,12 +5,14 @@ // stack the other platforms give their main thread. #include "app_paths.hpp" +#include "platform/android_jni.hpp" #include "profile.hpp" #include #include #include +#include #include #include @@ -36,16 +38,28 @@ 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 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) { + 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; + } + } + // 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().app_name + ".log"; - // The previous run's log survives one start, for reporting a crash. + const std::string log = std::string(storage) + "/" + portablekit::game().project_name + ".log"; std::rename(log.c_str(), - (std::string(storage) + "/" + portablekit::game().app_name + "-previous.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); } 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/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; diff --git a/host/platform/android_jni.hpp b/host/platform/android_jni.hpp index 08cb1cf..c8dc697 100644 --- a/host/platform/android_jni.hpp +++ b/host/platform/android_jni.hpp @@ -21,6 +21,9 @@ 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. +[[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(); diff --git a/host/profile.hpp b/host/profile.hpp index 09322ed..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 { @@ -199,6 +206,10 @@ 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; + // 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". 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; 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; 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;