diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index eacbe427..3da3beca 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -843,6 +843,22 @@ if(BUILD_TESTING) endif() add_test(NAME mod_runtime_test COMMAND mod_runtime_test) + get_target_property(mod_runtime_sources mod_runtime_test SOURCES) + add_executable(mod_runtime_cache_test ${mod_runtime_sources}) + target_include_directories(mod_runtime_cache_test PRIVATE + include ../recompiler/include ../recompiler/lib/toml11) + target_compile_definitions(mod_runtime_cache_test PRIVATE + PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE=1) + target_link_libraries(mod_runtime_cache_test PRIVATE chdr-static) + if(WIN32) + if(MSVC) + target_link_options(mod_runtime_cache_test PRIVATE /STACK:16777216) + else() + target_link_options(mod_runtime_cache_test PRIVATE -Wl,--stack,16777216) + endif() + endif() + add_test(NAME mod_runtime_cache_test COMMAND mod_runtime_cache_test) + add_executable(mod_resident_test tests/test_mod_resident.cpp src/mod_resident.cpp diff --git a/runtime/include/host_file_identity.h b/runtime/include/host_file_identity.h index 5e6c68e8..2cb5e193 100644 --- a/runtime/include/host_file_identity.h +++ b/runtime/include/host_file_identity.h @@ -2,6 +2,7 @@ #include #include #include +#include #if defined(_WIN32) #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN @@ -20,19 +21,36 @@ inline bool host_file_identity(const std::filesystem::path& path, std::string& i FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, nullptr); if (file == INVALID_HANDLE_VALUE) return false; + struct HandleScope { HANDLE value; ~HandleScope() { CloseHandle(value); } } handle{file}; + WCHAR filesystem_name[32]{}; + // NTFS/ReFS expose change time and native file identity. For a remote + // handle also require SMB3+, rather than assuming every NAS is unsupported. + FILE_REMOTE_PROTOCOL_INFO remote{}; + const bool is_remote = GetFileInformationByHandleEx(file, FileRemoteProtocolInfo, + &remote, sizeof remote) != FALSE; + const DWORD protocol_error = is_remote ? ERROR_SUCCESS : GetLastError(); + const bool reliable_protocol = is_remote + ? remote.Protocol == 0x00020000 && remote.ProtocolMajorVersion >= 3 // WNNC_NET_LANMAN + : protocol_error == ERROR_INVALID_PARAMETER || protocol_error == ERROR_NOT_SUPPORTED; + const bool reliable_fs = reliable_protocol && + GetVolumeInformationByHandleW(file, nullptr, 0, nullptr, nullptr, nullptr, + filesystem_name, 32) && + (wcscmp(filesystem_name, L"NTFS") == 0 || wcscmp(filesystem_name, L"ReFS") == 0); BY_HANDLE_FILE_INFORMATION info{}; FILE_BASIC_INFO basic{}; - const bool ok = GetFileInformationByHandle(file, &info) && + const bool ok = reliable_fs && GetFileInformationByHandle(file, &info) && GetFileInformationByHandleEx(file, FileBasicInfo, &basic, sizeof basic) && - !(info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY); - CloseHandle(file); + !(info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) && + (info.nFileIndexHigh != 0 || info.nFileIndexLow != 0) && + info.dwVolumeSerialNumber != 0 && basic.ChangeTime.QuadPart != 0 && + basic.LastWriteTime.QuadPart != 0; if (!ok) return false; out << "win1:" << info.dwVolumeSerialNumber << ':' << info.nFileIndexHigh << ':' << info.nFileIndexLow << ':' << info.nFileSizeHigh << ':' << info.nFileSizeLow << ':' << basic.LastWriteTime.QuadPart << ':' << basic.ChangeTime.QuadPart; #else struct stat info{}; - if (stat(path.c_str(), &info) || !S_ISREG(info.st_mode)) return false; + if (stat(path.c_str(), &info) || !S_ISREG(info.st_mode) || info.st_ino == 0) return false; out << "posix1:" << info.st_dev << ':' << info.st_ino << ':' << info.st_size; #if defined(__APPLE__) out << ':' << info.st_mtimespec.tv_sec << ':' << info.st_mtimespec.tv_nsec diff --git a/runtime/include/mod_runtime.h b/runtime/include/mod_runtime.h index 314282a2..eae30ba7 100644 --- a/runtime/include/mod_runtime.h +++ b/runtime/include/mod_runtime.h @@ -20,6 +20,10 @@ bool mod_runtime_initialize(const std::filesystem::path& root, bool mod_runtime_commit(const std::filesystem::path& disc_path = {}, std::string* error = nullptr, bool save_selection = true); +/* Prepare a preboot launcher selection; the audited runtime retains a ticket. */ +bool mod_runtime_prepare_for_launcher(const std::filesystem::path& disc_path, + std::string* error = nullptr); + /* Audited preboot only: load and verify a plan using existing receipts. * A miss never invokes a media converter or computes a disc digest. */ bool mod_runtime_try_prepare_cached(const std::filesystem::path& disc_path); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 77907729..97c1b0ac 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -16131,6 +16131,12 @@ int main(int argc, char** argv) { SDL_Quit(); return qrc; } +#endif +#if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE) + // Once per offline preboot: verified receipts make the selected plan + // ready before the launcher opens. A miss leaves preparation to UI. + if (!net_cfg.enabled && !ls.netplay_launch.enabled && !rui_initial_disc.empty()) + PSXRecompV4::mod_runtime_try_prepare_cached(rui_initial_disc); #endif char rui_out_disc[1024] = {0}; launcher_boot_timing_mark("host:before_run_window"); diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index d73f69cb..f9c1e433 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -92,6 +92,8 @@ struct RuntimeMods { uint64_t ticket_revision = 0; std::map ticket_files; bool ticket_ready = false; + bool catalog_stamped = false; + std::map loaded_catalog; bool cached_prepare = false; bool provider_preparing = false; bool initialized = false; @@ -153,6 +155,22 @@ bool dependency_files(RuntimeMods& s, const std::filesystem::path& disc, return add(s.effective_disc_path); } +bool catalog_files(RuntimeMods& s, std::map& files) { + files.clear(); + std::error_code ec; + if (!std::filesystem::exists(s.manager.root(), ec)) return !ec; + size_t entries = 0; + for (std::filesystem::recursive_directory_iterator it(s.manager.root(), ec), end; + !ec && it != end; it.increment(ec)) { + if (++entries > 4096) return false; + if (it->path().filename() != "manifest.toml") continue; + std::string stamp; + if (!host_file_identity(it->path(), stamp)) return false; + files[it->path()] = std::move(stamp); + } + return !ec; +} + bool prepared_ticket_matches(const std::filesystem::path& disc) { auto& s = state(); if (!s.ticket_ready || s.main_applied || !mod_runtime_session_plan_fp().empty() || @@ -160,9 +178,28 @@ bool prepared_ticket_matches(const std::filesystem::path& disc) { std::map files; if (!dependency_files(s, disc, files) || files != s.ticket_files) return false; auto manager = s.manager; - return manager.prepare_resources(s.game_id, disc, media_cache_root(), nullptr, true); + if (!manager.prepare_resources(s.game_id, disc, media_cache_root(), nullptr, true)) return false; + for (const auto& [id, versions] : s.manager.packages()) { + (void)versions; + const auto* package = s.manager.selected_package(id); + if (!package) continue; + for (const auto& resource : package->resources) + if (manager.feature_resource_path(id, resource.feature_id, resource.id) != + s.manager.feature_resource_path(id, resource.feature_id, resource.id)) return false; + } + return true; } +class ScopedFlag { + bool& flag; + const bool previous; +public: + explicit ScopedFlag(bool& value) : flag(value), previous(value) { flag = true; } + ~ScopedFlag() { flag = previous; } + ScopedFlag(const ScopedFlag&) = delete; + ScopedFlag& operator=(const ScopedFlag&) = delete; +}; + /* Guest calls made from an entry hook can deliver VBlank callbacks before * returning. Every callback owns its resource/completion context; restoring * only entry hooks would let VBlank erase the interrupted plugin or complete @@ -470,7 +507,21 @@ bool sha256_file(const std::filesystem::path& path, std::string& out, return true; } -// Offline only: the digest semantics match sha256_file (CUE data / CHD raw sectors). +bool measured_disc_digest(const std::filesystem::path& path, std::string& digest, std::string* error) { + if (!path.empty()) psx_mod_counter_add("startup.disc_hash", 1); + return sha256_file(path, digest, error); +} + +std::string sha256_text(const std::string& value) { + psx_sha256_ctx hash; psx_sha256_init(&hash); + psx_sha256_update(&hash, reinterpret_cast(value.data()), value.size()); + uint8_t bytes[32]; psx_sha256_final(&hash, bytes); + std::ostringstream text; + for (uint8_t byte : bytes) text << std::hex << std::setw(2) << std::setfill('0') << unsigned(byte); + return text.str(); +} + +// Offline only: semantics match sha256_file (CUE data / CHD raw sectors). bool offline_disc_digest(const std::filesystem::path& path, std::string& digest, std::string* error, bool cached_only) { digest.clear(); @@ -479,30 +530,32 @@ bool offline_disc_digest(const std::filesystem::path& path, std::string& digest, std::string stamp, mount_stamp; const bool reliable = host_file_identity(resolved.data, stamp) && host_file_identity(resolved.mount, mount_stamp); - const std::string key = "disc-sha-v1\n" + resolved.mount.generic_string() + "\n" + - resolved.data.generic_string() + "\n" + stamp + "\n" + mount_stamp; - psx_sha256_ctx hash; - psx_sha256_init(&hash); - psx_sha256_update(&hash, reinterpret_cast(key.data()), key.size()); - uint8_t bytes[32]; psx_sha256_final(&hash, bytes); - std::ostringstream filename; - for (const auto byte : bytes) filename << std::hex << std::setw(2) << std::setfill('0') << unsigned(byte); - const auto directory = media_cache_root() / "disc-digests-v1"; - const auto receipt = directory / (filename.str() + ".txt"); - if (reliable) { + const std::string key = "disc-sha-v2\n" + resolved.mount.generic_string() + "\n" + + resolved.data.generic_string() + "\n" + stamp + "\n" + mount_stamp + "\n"; + const bool cacheable = reliable && key.size() <= 4096; + const auto directory = media_cache_root() / "disc-digests-v2"; + const auto receipt = directory / (sha256_text(key) + ".txt"); + if (cacheable) { std::ifstream file(receipt, std::ios::binary); - std::string version, value, extra; - if (std::getline(file, version) && std::getline(file, value) && - !std::getline(file, extra) && version == "disc-sha-v1" && value.size() == 64 && - std::all_of(value.begin(), value.end(), [](char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); })) { - digest = value; - return true; + std::array buffer{}; + file.read(buffer.data(), buffer.size()); + const std::string record(buffer.data(), size_t(file.gcount())); + if (file.eof() && record.size() == key.size() + 130 && + record.compare(0, key.size(), key) == 0) { + const std::string value = record.substr(key.size(), 64); + const std::string body = key + value + "\n"; + if (std::all_of(value.begin(), value.end(), [](char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); }) && + record == body + sha256_text(body) + "\n") { + digest = value; + psx_mod_counter_add("startup.disc_digest_hit", 1); + return true; + } } } if (cached_only) return false; - if (!sha256_file(path, digest, error)) return false; + if (!measured_disc_digest(path, digest, error)) return false; std::string after, mount_after; - if (!reliable || !host_file_identity(resolved.data, after) || after != stamp || + if (!cacheable || !host_file_identity(resolved.data, after) || after != stamp || !host_file_identity(resolved.mount, mount_after) || mount_after != mount_stamp) return true; std::error_code ec; std::filesystem::create_directories(directory, ec); @@ -512,8 +565,9 @@ bool offline_disc_digest(const std::filesystem::path& path, std::string& digest, #else const auto temporary = receipt.string() + "." + std::to_string(getpid()) + ".tmp"; #endif + const std::string body = key + digest + "\n"; { std::ofstream file(temporary, std::ios::binary | std::ios::trunc); - if (!(file << "disc-sha-v1\n" << digest << '\n')) return true; } + if (!(file << body << sha256_text(body) << '\n')) return true; } #if defined(_WIN32) MoveFileExW(std::filesystem::path(temporary).c_str(), receipt.c_str(), MOVEFILE_REPLACE_EXISTING); #else @@ -1247,6 +1301,9 @@ int provider_install(void*, const char* path) { std::string id, version; if (!state().manager.install_archive(path, &id, &version, &error)) return false; if (!state().manager.scan(&error)) return false; +#if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE) + state().catalog_stamped = catalog_files(state(), state().loaded_catalog); +#endif return state().manager.select_version(id, version, &error); }); } @@ -1254,7 +1311,11 @@ int provider_install(void*, const char* path) { int provider_remove(void*, const char* id, const char* version) { if (!id || !version) return 0; return mutate([&](std::string& error) { - return state().manager.remove_version(id, version, &error); + if (!state().manager.remove_version(id, version, &error)) return false; +#if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE) + state().catalog_stamped = catalog_files(state(), state().loaded_catalog); +#endif + return true; }); } @@ -1281,10 +1342,8 @@ int provider_set_option(void*, const char* id, const char* option, const char* v int provider_commit(void*, const char* image_path) { std::string error; - state().provider_preparing = true; - const bool ok = mod_runtime_commit(image_path ? std::filesystem::path(image_path) : - std::filesystem::path(), &error); - state().provider_preparing = false; + const bool ok = mod_runtime_prepare_for_launcher( + image_path ? std::filesystem::path(image_path) : std::filesystem::path(), &error); if (!ok) { set_error(error); return 0; @@ -1391,6 +1450,10 @@ bool mod_runtime_initialize(const std::filesystem::path& root, s.manager.set_root(root); s.game_id = game_id; s.entry_phys = game_entry_pc & 0x1FFFFFFFu; +#if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE) + std::map catalog_before; + const bool catalog_before_stamped = catalog_files(s, catalog_before); +#endif if (!s.manager.scan(&s.error) || !s.manager.load_state(&s.error)) { if (error) *error = s.error; return false; @@ -1414,6 +1477,10 @@ bool mod_runtime_initialize(const std::filesystem::path& root, s.exe_sha256.clear(); s.error.clear(); } +#if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE) + s.catalog_stamped = catalog_before_stamped && catalog_files(s, s.loaded_catalog) && + catalog_before == s.loaded_catalog; +#endif s.initialized = true; return true; } @@ -1670,28 +1737,43 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err RuntimeMods& s = state(); if (!s.initialized) return true; #if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE) + std::map catalog; + if (s.catalog_stamped && (!catalog_files(s, catalog) || catalog != s.loaded_catalog)) { + s.ticket_ready = false; + s.error = "Mod catalog changed after loading; reopen the launcher to rescan it."; + if (error) *error = s.error; + return false; + } if (save_selection && prepared_ticket_matches(disc_path)) { if (!s.provider_preparing) s.ticket_ready = false; clear_function_entry_hooks(); s.disc_extents.clear(); s.audio_tracks.clear(); s.extent_start = 0; s.main_applied = false; s.error.clear(); + psx_mod_counter_add("startup.plan_reuse", 1); return true; } #endif s.ticket_ready = false; std::string disc_before, data_before; +#if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE) const bool disc_stamped = disc_path.empty() || (host_file_identity(disc_path, disc_before) && host_file_identity(resolve_disc_path(disc_path).data, data_before)); +#else + const bool disc_stamped = false; +#endif +#if !defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE) + if (disc_path != s.disc_path) +#endif { std::string hash_error, digest; #if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE) const bool hashed = save_selection ? offline_disc_digest(disc_path, digest, &hash_error, s.cached_prepare) - : sha256_file(disc_path, digest, &hash_error); + : measured_disc_digest(disc_path, digest, &hash_error); #else - const bool hashed = sha256_file(disc_path, digest, &hash_error); + const bool hashed = measured_disc_digest(disc_path, digest, &hash_error); #endif if (!hashed && s.cached_prepare) return false; if (!hashed) digest.clear(); @@ -1750,6 +1832,7 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err if (error) *error = s.error; return false; } +#if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE) std::map after_resolve; std::string disc_after, data_after; const bool unchanged_disc = disc_path.empty() || @@ -1757,6 +1840,7 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err host_file_identity(resolve_disc_path(disc_path).data, data_after) && data_before == data_after); const bool unchanged_inputs = unchanged_disc && stable_inputs && dependency_files(s, disc_path, after_resolve) && before_resolve == after_resolve; +#endif if (save_selection && !s.manager.save_state(&s.error)) { if (error) *error = s.error; return false; @@ -1774,7 +1858,7 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err s.error.clear(); #if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE) if (save_selection && (s.provider_preparing || s.cached_prepare) && - unchanged_inputs && session_plan_fp().empty()) { + s.catalog_stamped && unchanged_inputs && session_plan_fp().empty()) { s.ticket_revision = s.preparation_revision; s.ticket_ready = dependency_files(s, disc_path, s.ticket_files); } @@ -1782,16 +1866,21 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err return true; } +bool mod_runtime_prepare_for_launcher(const std::filesystem::path& disc_path, std::string* error) { + ScopedFlag flag(state().provider_preparing); + return mod_runtime_commit(disc_path, error, true); +} + bool mod_runtime_try_prepare_cached(const std::filesystem::path& disc_path) { #if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE) auto& s = state(); if (!s.initialized || s.main_applied || !session_plan_fp().empty()) return false; auto probe = s.manager; if (!probe.prepare_resources(s.game_id, disc_path, media_cache_root(), nullptr, true)) return false; - s.cached_prepare = true; + ScopedFlag flag(s.cached_prepare); const bool ok = mod_runtime_commit(disc_path, nullptr, true); - s.cached_prepare = false; - return ok; + if (ok && s.ticket_ready) psx_mod_counter_add("startup.preload_hit", 1); + return ok && s.ticket_ready; #else (void)disc_path; return false; diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index a3f941a6..9304872b 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -1,4 +1,5 @@ #include "mod_runtime.h" +#include "host_file_identity.h" #include "mod_packages.h" #include "mod_plugins.h" #include "psx_sha256.h" @@ -10,6 +11,8 @@ #include #include #include +#include +#include #include #include #include @@ -288,6 +291,15 @@ static std::string sha256_hex(const std::vector& bytes) { return out; } +extern "C" int psx_mod_counters_snapshot(const char**, uint64_t*, uint64_t*, int, uint64_t*); +static uint64_t startup_counter(const char* requested) { + const char* names[128]{}; uint64_t counts[128]{}, frames[128]{}; + const int count = psx_mod_counters_snapshot(names, counts, frames, 128, nullptr); + for (int i = 0; i < count && i < 128; ++i) + if (std::string(names[i]) == requested) return counts[i]; + return 0; +} + int main() { const fs::path root = fs::temp_directory_path() / "psxrecomp-mod-runtime-test"; std::error_code ec; @@ -1325,6 +1337,204 @@ int main() { check(psx_mod_read_disc_file("S0/LEVEL.NSF", nullptr, 0, &bytes) && bytes == 69400576u, "files grown past 64 MiB keep their effective size"); } + // Reliable identity detects byte replacement even when size/mtime are restored. + { + const auto identity_path = root / "identity.bin"; + write_bytes(identity_path, {1, 2, 3, 4}); + const auto original_time = fs::last_write_time(identity_path); + std::string before, after; + const bool reliable = PSXRecompV4::host_file_identity(identity_path, before); + write_bytes(identity_path, {4, 3, 2, 1}); + fs::last_write_time(identity_path, original_time); + check(!reliable || (PSXRecompV4::host_file_identity(identity_path, after) && before != after), + "file identity rejects same-size restored-mtime replacement"); + check(!PSXRecompV4::host_file_identity(root, after), "directories are never file cache identities"); + check(!PSXRecompV4::host_file_identity(root / "absent.bin", after), "missing identity is a cache miss"); + } +#if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE) + { + const auto cached_root = root / "audited-cache"; + const auto output_path = root / "audited-output.z64"; + const auto input_path = root / "audited-input.bin"; + const auto disc = root / "audited-disc.iso"; + fs::copy_file(iso_path, disc, fs::copy_options::overwrite_existing); + write_bytes(input_path, {1,2,3,4}); + std::string input_receipt, output_receipt; + int prepare_calls = 0; + bool throw_probe = false, mutate_on_resolve = false, throw_resolver = false; + const std::string manifest = + "format_version = 9\nid = \"cache.test\"\nversion = \"1.0.0\"\nname = \"Cache\"\nprepare = \"test.cache.prepare\"\nresolver = \"builtin:test.cache.resolve\"\n" + "[[target]]\ngame_id = \"READER\"\n" + "[[feature]]\nid = \"active\"\nname = \"Active\"\ndefault_enabled = true\n" + "[[resource]]\nfeature = \"active\"\nid = \"source\"\nlabel = \"Source\"\ninput_only = true\nshared_source = \"cache.original\"\nrequired = true\n" + "[[resource]]\nfeature = \"active\"\nid = \"rom\"\nlabel = \"ROM\"\nformat = \"n64-rom\"\nhidden = true\nrequired = true\nsize = 64\nsha256 = \"" + sha256_hex(rom) + "\"\n" + "[[feature]]\nid = \"second\"\nname = \"Second\"\ndefault_enabled = false\n" + "[[resource]]\nfeature = \"second\"\nid = \"rom\"\nlabel = \"ROM\"\nformat = \"n64-rom\"\nhidden = true\nrequired = true\nsize = 64\nsha256 = \"" + sha256_hex(rom) + "\"\n"; + check(PSXRecompV4::mod_register_builtin_resolver("test.cache.resolve", + [&](const auto&, const auto&, const auto&, auto&, auto&) { + if (throw_resolver) throw std::runtime_error("resolver fixture exception"); + if (mutate_on_resolve) write_bytes(input_path, {7,7,7,7}); + return true; + }), "register resolver for mutation and unwind regression"); + const auto manifest_path = cached_root / "packages/cache.test/1.0.0/manifest.toml"; + write_text(manifest_path, manifest); + check(PSXRecompV4::mod_register_media_preparer("test.cache.prepare", + [&](const PSXRecompV4::ModPrepareContext&, std::map& outputs, std::string&) { + ++prepare_calls; + write_bytes(output_path, rom); + PSXRecompV4::host_file_identity(input_path, input_receipt); + PSXRecompV4::host_file_identity(output_path, output_receipt); + outputs["rom"] = output_path; + return true; + }, + [&](const PSXRecompV4::ModPrepareContext& context, std::map& outputs, std::string&) { + if (throw_probe) throw std::runtime_error("probe fixture exception"); + std::string input, output; + if (context.feature_id == "second" || + !PSXRecompV4::host_file_identity(input_path, input) || input != input_receipt || + !PSXRecompV4::host_file_identity(output_path, output) || output != output_receipt) return false; + outputs["rom"] = output_path; return true; + }), "register audited preparation and cheap identity probe"); + PSXRecompV4::ModPackageManager source_selection(cached_root); + check(source_selection.scan(&error) && + source_selection.set_feature_resource_path("cache.test", "active", "source", input_path, &error) && + source_selection.save_state(&error), "persist shared input selection for audited fixture"); + PSXRecompV4::mod_runtime_end_netplay(); test_match_caps = {}; + check(PSXRecompV4::mod_runtime_initialize(cached_root, "READER", 0, {}, &error), "audited initialize"); + check(!PSXRecompV4::mod_runtime_try_prepare_cached(disc) && prepare_calls == 0, + "cold cached-only launch does not prepare missing receipt"); + check(PSXRecompV4::mod_runtime_prepare_for_launcher(disc, &error) && prepare_calls == 1, + "cold launcher creates verified preparation"); + const auto reused = startup_counter("startup.plan_reuse"); + const auto hashed = startup_counter("startup.disc_hash"); + check(PSXRecompV4::mod_runtime_prepare_for_launcher(disc, &error) && + PSXRecompV4::mod_runtime_commit(disc, &error) && prepare_calls == 1 && + startup_counter("startup.plan_reuse") == reused + 2 && startup_counter("startup.disc_hash") == hashed, + "duplicate provider and main commit reuse a prepared plan without converter or hash"); + check(PSXRecompV4::mod_runtime_initialize(cached_root, "READER", 0, {}, &error) && + PSXRecompV4::mod_runtime_try_prepare_cached(disc) && prepare_calls == 1 && + startup_counter("startup.disc_hash") == hashed, + "warm initialize preloads receipts with no preparer or raw hash"); + mod_runtime_activate_plugins(); + check(PSXRecompV4::mod_runtime_commit(disc, &error) && prepare_calls == 2, + "activation invalidates the preboot ticket"); + check(PSXRecompV4::mod_runtime_initialize(cached_root, "READER", 0, {}, &error) && + PSXRecompV4::mod_runtime_try_prepare_cached(disc), "restore warm plan for source invalidation"); + const auto input_time = fs::last_write_time(input_path); + write_bytes(input_path, {4,3,2,1}); fs::last_write_time(input_path, input_time); + check(!PSXRecompV4::mod_runtime_try_prepare_cached(disc), "external shared input replacement invalidates title receipt"); + check(PSXRecompV4::mod_runtime_prepare_for_launcher(disc, &error) && prepare_calls == 3, + "changed source goes through full preparation"); + auto corrupt_rom = rom; corrupt_rom[32] ^= 1; write_bytes(output_path, corrupt_rom); + // Even if the probe claims the file is current, the first warm resolve + // must verify output bytes against the declared SHA. + PSXRecompV4::host_file_identity(output_path, output_receipt); + check(PSXRecompV4::mod_runtime_initialize(cached_root, "READER", 0, {}, &error) && + !PSXRecompV4::mod_runtime_try_prepare_cached(disc) && prepare_calls == 3, + "warm output corruption fails immutable-byte verification"); + fs::remove(output_path); + check(!PSXRecompV4::mod_runtime_try_prepare_cached(disc) && prepare_calls == 3, + "missing output fails without invoking converter"); + check(PSXRecompV4::mod_runtime_prepare_for_launcher(disc, &error) && prepare_calls == 4, + "full preparation repairs missing output"); + throw_probe = true; + check(!PSXRecompV4::mod_runtime_try_prepare_cached(disc), "throwing cached probe fails safely"); + throw_probe = false; + check(PSXRecompV4::mod_runtime_prepare_for_launcher(disc, &error), "cached flag restored after failure"); + // Probe results are transactional across multiple enabled features. + PSXRecompV4::ModPackageManager transactional(cached_root); + check(transactional.scan(&error) && transactional.load_state(&error) && + transactional.set_feature_enabled("cache.test", "second", true, &error) && + transactional.set_feature_resource_path("cache.test", "active", "rom", rom_path, &error), "enable second probe fixture"); + const auto original_output = transactional.feature_resource_path("cache.test", "active", "rom"); + check(!transactional.prepare_resources("READER", disc, root / "cache", &error, true) && + transactional.feature_resource_path("cache.test", "active", "rom") == original_output, + "later cache miss discards earlier pending output selections"); + // A throw inside resolve must restore cached-only/provider modes. + check(PSXRecompV4::mod_runtime_initialize(cached_root, "READER", 0, {}, &error), "invalidate ticket for resolver exception fixture"); + throw_resolver = true; + bool threw = false; + try { PSXRecompV4::mod_runtime_try_prepare_cached(disc); } + catch (const std::runtime_error&) { threw = true; } + throw_resolver = false; + check(threw, "resolver exception reaches caller for unwind fixture"); + fs::remove(output_path); + const auto before_unwind = prepare_calls; + check(PSXRecompV4::mod_runtime_prepare_for_launcher(disc, &error) && prepare_calls == before_unwind + 1, + "cached-only mode restored after resolve exception"); + // A dependency changed during resolution must never publish readiness. + check(PSXRecompV4::mod_runtime_initialize(cached_root, "READER", 0, {}, &error), "invalidate ticket for resolver mutation fixture"); + const auto before_mutation = prepare_calls; + mutate_on_resolve = true; + check(PSXRecompV4::mod_runtime_prepare_for_launcher(disc, &error), "full resolution can finish with concurrent input mutation"); + mutate_on_resolve = false; + check(PSXRecompV4::mod_runtime_prepare_for_launcher(disc, &error) && prepare_calls == before_mutation + 2, + "unstable resolve never publishes a reusable ticket"); + // Altering a syntactically valid digest must fail its receipt checksum. + std::filesystem::path cache_dir; +#if defined(_WIN32) + if (const char* local = std::getenv("LOCALAPPDATA")) cache_dir = local; +#else + if (const char* cache = std::getenv("XDG_CACHE_HOME")) cache_dir = cache; + else if (const char* home_dir = std::getenv("HOME")) cache_dir = fs::path(home_dir) / ".cache"; +#endif + if (cache_dir.empty()) cache_dir = fs::temp_directory_path(); + cache_dir /= "psxrecomp/imports/disc-digests-v2"; + bool corrupted_receipt = false; + for (const auto& entry : fs::directory_iterator(cache_dir)) { + std::ifstream saved(entry.path(), std::ios::binary); + std::string receipt(std::istreambuf_iterator(saved), {}); + if (receipt.find(disc.generic_string()) == std::string::npos || receipt.size() < 130) continue; + const size_t digest_offset = receipt.size() - 130; + receipt[digest_offset] = receipt[digest_offset] == '0' ? '1' : '0'; + write_text(entry.path(), receipt); corrupted_receipt = true; + } + check(corrupted_receipt && PSXRecompV4::mod_runtime_initialize(cached_root, "READER", 0, {}, &error) && + !PSXRecompV4::mod_runtime_try_prepare_cached(disc), "corrupt well-formed digest receipt misses warm startup"); + const auto before_corrupt_hash = startup_counter("startup.disc_hash"); + check(PSXRecompV4::mod_runtime_prepare_for_launcher(disc, &error) && + startup_counter("startup.disc_hash") == before_corrupt_hash + 1, + "full launch repairs corrupted digest with authoritative source hash"); + // Catalog changes cannot certify stale in-memory parsed manifests. + write_text(manifest_path, manifest + "\n# external catalog mutation\n"); + check(!PSXRecompV4::mod_runtime_prepare_for_launcher(disc, &error) && + error.find("catalog changed") != std::string::npos, + "changed manifest requires rescan instead of resealing stale package"); + check(PSXRecompV4::mod_runtime_initialize(cached_root, "READER", 0, {}, &error), "explicit initialize rescans changed catalog"); + const int before_netplay = prepare_calls; + const auto net_hash = startup_counter("startup.disc_hash"); + check(PSXRecompV4::mod_runtime_commit_for_direct_netplay(disc, &error) && + prepare_calls == before_netplay + 1 && startup_counter("startup.disc_hash") > net_hash, + "netplay ignores preboot ticket and hashes the source fully"); + check(!PSXRecompV4::mod_runtime_try_prepare_cached(disc), "active netplay never preloads an offline ticket"); + PSXRecompV4::mod_runtime_end_netplay(); + // Legacy preparers have no cheap receipt and must never claim warm readiness. + const auto legacy_root = root / "legacy-cache"; + int legacy_calls = 0; + check(PSXRecompV4::mod_register_media_preparer("test.legacy.prepare", + [&](const PSXRecompV4::ModPrepareContext&, std::map& outputs, std::string&) { + ++legacy_calls; outputs["rom"] = output_path; return true; + }), "register legacy preparer without probe"); + std::string legacy_manifest = manifest; + legacy_manifest.replace(legacy_manifest.find("test.cache.prepare"), std::string("test.cache.prepare").size(), "test.legacy.prepare"); + write_text(legacy_root / "packages/cache.test/1.0.0/manifest.toml", legacy_manifest); + check(PSXRecompV4::mod_runtime_initialize(legacy_root, "READER", 0, {}, &error) && + !PSXRecompV4::mod_runtime_try_prepare_cached(disc) && legacy_calls == 0, + "legacy preparer always misses cached-only startup"); + } +#else + { + const auto defaults = root / "default-hashing"; + fs::create_directories(defaults); + check(PSXRecompV4::mod_runtime_initialize(defaults, "READER", 0, {}, &error), "default hashing fixture initialize"); + const auto hash_count = startup_counter("startup.disc_hash"); + check(PSXRecompV4::mod_runtime_commit(iso_path, &error) && + PSXRecompV4::mod_runtime_commit(iso_path, &error) && + startup_counter("startup.disc_hash") == hash_count + 1, + "default runtime preserves hash-only-on-selected-path-change behavior"); + check(!PSXRecompV4::mod_runtime_try_prepare_cached(iso_path), "default runtime does not opt into cached startup"); + } +#endif fs::remove_all(root, ec); if (failures) return 1; std::cout << "mod runtime tests passed\n";