mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
Wire warm preboot readiness and cover cache invalidation contracts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user