Wire warm preboot readiness and cover cache invalidation contracts

This commit is contained in:
Matthew Stanley
2026-10-07 09:25:56 -07:00
parent 0fb60d2617
commit c124f6ba52
6 changed files with 380 additions and 37 deletions
+16
View File
@@ -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
+22 -4
View File
@@ -2,6 +2,7 @@
#include <filesystem>
#include <string>
#include <sstream>
#include <cwchar>
#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
+4
View File
@@ -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);
+6
View File
@@ -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");
+122 -33
View File
@@ -92,6 +92,8 @@ struct RuntimeMods {
uint64_t ticket_revision = 0;
std::map<std::filesystem::path, std::string> ticket_files;
bool ticket_ready = false;
bool catalog_stamped = false;
std::map<std::filesystem::path, std::string> 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<std::filesystem::path, std::string>& 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<std::filesystem::path, std::string> 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<const uint8_t*>(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<const uint8_t*>(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<char, 4227> 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<std::filesystem::path, std::string> 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<std::filesystem::path, std::string> 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<std::filesystem::path, std::string> 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;
+210
View File
@@ -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 <array>
#include <algorithm>
#include <cstdint>
#include <cstdlib>
#include <stdexcept>
#include <filesystem>
#include <fstream>
#include <iostream>
@@ -288,6 +291,15 @@ static std::string sha256_hex(const std::vector<uint8_t>& 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<std::string, fs::path>& 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<std::string, fs::path>& 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<char>(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<std::string, fs::path>& 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";