From 9324d27c6bb3cf134bd3e50984a42e225afc66d4 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 22 Jul 2026 22:26:43 -0700 Subject: [PATCH 01/12] feat: add versioned runtime mod packages --- README.md | 3 +- docs/MOD_PACKAGES.md | 113 +++ runtime/CMakeLists.txt | 21 + runtime/include/mod_packages.h | 154 ++++ runtime/include/mod_runtime.h | 43 + runtime/include/psx_sha256.h | 24 + runtime/runtime.cmake | 11 + runtime/src/fntrace.c | 4 + runtime/src/iso_reader_c.cpp | 9 +- runtime/src/main.cpp | 34 +- runtime/src/mod_packages.cpp | 1227 +++++++++++++++++++++++++++ runtime/src/mod_runtime.cpp | 467 ++++++++++ runtime/src/psx_sha256.c | 89 ++ runtime/tests/test_mod_packages.cpp | 182 ++++ runtime/tests/test_mod_runtime.cpp | 91 ++ tools/psxmod_pack.py | 33 + 16 files changed, 2498 insertions(+), 7 deletions(-) create mode 100644 docs/MOD_PACKAGES.md create mode 100644 runtime/include/mod_packages.h create mode 100644 runtime/include/mod_runtime.h create mode 100644 runtime/include/psx_sha256.h create mode 100644 runtime/src/mod_packages.cpp create mode 100644 runtime/src/mod_runtime.cpp create mode 100644 runtime/src/psx_sha256.c create mode 100644 runtime/tests/test_mod_packages.cpp create mode 100644 runtime/tests/test_mod_runtime.cpp create mode 100644 tools/psxmod_pack.py diff --git a/README.md b/README.md index 467c3a12d..8b12a13c0 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,8 @@ repositories and link this one in as a **git submodule** to build a game binary. [`docs/EXECUTION_MODEL.md`](docs/EXECUTION_MODEL.md) (how a game actually runs — static / native-overlay / interpreter), then [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md), -[`docs/BUILDING.md`](docs/BUILDING.md), and +[`docs/BUILDING.md`](docs/BUILDING.md), +[`docs/MOD_PACKAGES.md`](docs/MOD_PACKAGES.md) (versioned runtime mods), [`CONTRIBUTING.md`](CONTRIBUTING.md). ## Games diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md new file mode 100644 index 000000000..74da2b1c8 --- /dev/null +++ b/docs/MOD_PACKAGES.md @@ -0,0 +1,113 @@ +# PSXRecomp mod packages + +PSXRecomp games may expose a shared Dear ImGui **Mods** view backed by versioned +`.psxmod` packages. A package is a ZIP archive with `manifest.toml` at its root. +Packages are installed under `mods/packages///`; the selected +versions and option values live in `mods/state.toml`. + +Mods are resolved and fingerprinted before boot. They never rewrite the user's +disc or the recomp executable. + +## Minimal manifest + +```toml +format_version = 1 +id = "example.faster-charge" +version = "1.2.0" +name = "Faster Charge" +author = "Example Author" +description = "Shortens the charge delay." +license = "MIT" +resolver = "declarative" +save_compatibility = "shared" # or "isolated" +conflicts = ["example.incompatible"] + +[[target]] +game_id = "SLUS-00000" +# Optional. When present, the selected image must have this digest. +exe_sha256 = "..." +disc_sha256 = "..." + +[[dependency]] +id = "example.core" +version = "^1.0.0" + +[[option]] +id = "delay" +label = "Charge delay" +description = "Delay in frames." +group = "Game balance" +type = "choice" +default = "normal" + +[[option.choice]] +value = "normal" +label = "Normal" + +[[option.choice]] +value = "fast" +label = "Fast" + +[[patch]] +target = "main_exe" +address = 0x80041234 +expected = "2a 00 02 24" +replace = "0e 00 02 24" +when_option = "delay" +when_value = "fast" +order = 10 +``` + +Option types are `boolean`, `choice`, and bounded `integer`. `when_option` / +`when_value` are optional; an unconditional patch omits both. + +## Patch targets + +- `main_exe`: `address` is a PSX guest virtual address. All expected bytes are + checked after the BIOS loads the PS-X EXE. The complete main-EXE plan is then + applied before the configured entry point executes. +- `disc_raw`: `offset` is in the canonical 2352-byte raw-sector stream + (`lba * 2352 + byte_in_sector`). +- `disc_user`: `offset` is in the canonical 2048-byte user-data stream + (`lba * 2048 + byte_in_sector`). + +A disc operation may not cross a sector boundary. Use multiple operations. +Expected and replacement data are equal-length hexadecimal byte strings. + +Changed main-EXE code is deliberately not represented by a precompiled +permutation. PSXRecomp's exact text-image guard sees the changed live RAM and +routes that code through the existing dirty-RAM interpreter/native overlay +cache. Untouched functions stay on the static native path. This makes runtime +cost proportional to the code actually changed, not to the number of possible +option combinations. + +## Resolution rules + +- Installed versions are side-by-side. The launcher can select an older version + to roll back. +- Enabled packages are topologically ordered by dependencies, then by stable + package/patch order. +- Missing dependencies, version mismatches, declared conflicts, dependency + cycles, overlapping writes, unavailable trusted resolvers, invalid option + values, and target mismatches prevent launch. +- The resolved package versions, option values, and writes produce a canonical + SHA-256 plan fingerprint suitable for diagnostics and multiplayer agreement. +- Package and state changes apply on the next launch. There is no mid-frame + mutation. + +## Trusted adapters + +`resolver = "builtin:"` selects a resolver statically registered by the game +executable. This is for legacy patch systems whose dependency and composition +rules cannot be expressed as independent declarative writes. A package cannot +load native code or choose an arbitrary symbol: unregistered IDs fail closed. +The adapter emits the same expected-byte-guarded resolved writes as a +declarative package, so validation, overlap checks, fingerprinting, and runtime +execution remain shared. + +## Archive safety + +The installer accepts stored or DEFLATE-compressed ZIP entries, validates CRCs, +rejects encrypted entries and unsafe/absolute paths, limits archives to 4096 +files and 256 MiB expanded size, stages extraction, validates the manifest, and +publishes the version with an atomic rename. diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 56d97100c..25d242287 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -79,6 +79,27 @@ if(BUILD_TESTING) target_include_directories(overlay_path_canon_test PRIVATE include) add_test(NAME overlay_path_canon_test COMMAND overlay_path_canon_test) + add_executable(mod_packages_test + tests/test_mod_packages.cpp + src/mod_packages.cpp + src/crc32.c + src/psx_sha256.c) + target_include_directories(mod_packages_test PRIVATE + include + ../recompiler/lib/toml11) + add_test(NAME mod_packages_test COMMAND mod_packages_test) + + add_executable(mod_runtime_test + tests/test_mod_runtime.cpp + src/mod_runtime.cpp + src/mod_packages.cpp + src/crc32.c + src/psx_sha256.c) + target_include_directories(mod_runtime_test PRIVATE + include + ../recompiler/lib/toml11) + add_test(NAME mod_runtime_test COMMAND mod_runtime_test) + if(WIN32) add_executable(autocompile_publication_test tests/test_autocompile_publication.c diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h new file mode 100644 index 000000000..31958948b --- /dev/null +++ b/runtime/include/mod_packages.h @@ -0,0 +1,154 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace PSXRecompV4 { + +enum class ModOptionType { + Boolean, + Choice, + Integer, +}; + +struct ModChoice { + std::string value; + std::string label; +}; + +struct ModOption { + std::string id; + std::string label; + std::string description; + std::string group; + ModOptionType type = ModOptionType::Boolean; + std::string default_value; + int64_t min_value = 0; + int64_t max_value = 0; + int64_t step = 1; + std::vector choices; +}; + +struct ModRequirement { + std::string id; + std::string version; +}; + +struct ModTarget { + std::string game_id; + std::string exe_sha256; + std::string disc_sha256; +}; + +enum class ModPatchTarget { + MainExe, + DiscRaw, + DiscUser, +}; + +struct ModPatch { + ModPatchTarget target = ModPatchTarget::MainExe; + uint64_t location = 0; /* guest address or canonical disc-stream byte offset */ + std::vector expected; + std::vector replacement; + std::string when_option; + std::string when_value; + int64_t order = 0; +}; + +struct ModPackage { + uint32_t format_version = 0; + std::string id; + std::string version; + std::string name; + std::string author; + std::string description; + std::string license; + std::string resolver = "declarative"; + std::string save_compatibility = "shared"; + std::filesystem::path root; + std::vector targets; + std::vector dependencies; + std::vector conflicts; + std::vector options; + std::vector patches; +}; + +struct ModSelection { + bool enabled = false; + std::string version; + std::map values; +}; + +struct ModResolution { + bool ok = false; + std::string fingerprint; + std::vector ordered; + struct Write { + ModPatchTarget target = ModPatchTarget::MainExe; + uint64_t location = 0; + std::vector expected; + std::vector replacement; + std::string package_id; + }; + std::vector writes; + std::vector errors; +}; + +using ModBuiltinResolver = std::function& writes, + std::vector& errors)>; + +class ModPackageManager { +public: + explicit ModPackageManager(std::filesystem::path mods_root = {}); + + void set_root(std::filesystem::path mods_root); + const std::filesystem::path& root() const { return root_; } + + bool scan(std::string* error = nullptr); + bool load_state(std::string* error = nullptr); + bool save_state(std::string* error = nullptr) const; + + bool install_archive(const std::filesystem::path& archive, + std::string* installed_id = nullptr, + std::string* installed_version = nullptr, + std::string* error = nullptr); + bool remove_version(const std::string& id, const std::string& version, + std::string* error = nullptr); + + bool set_enabled(const std::string& id, bool enabled, std::string* error = nullptr); + bool select_version(const std::string& id, const std::string& version, + std::string* error = nullptr); + bool set_option(const std::string& id, const std::string& option, + const std::string& value, std::string* error = nullptr); + + const std::map>& packages() const { + return packages_; + } + const std::map& selections() const { return selections_; } + const ModPackage* selected_package(const std::string& id) const; + + ModResolution resolve(const std::string& game_id, + const std::string& exe_sha256 = {}, + const std::string& disc_sha256 = {}) const; + + static bool read_manifest(const std::filesystem::path& path, ModPackage& out, + std::string* error = nullptr); + +private: + std::filesystem::path root_; + std::map> packages_; + std::map selections_; +}; + +bool mod_register_builtin_resolver(const std::string& id, ModBuiltinResolver resolver); +void mod_clear_builtin_resolvers_for_tests(); + +} // namespace PSXRecompV4 diff --git a/runtime/include/mod_runtime.h b/runtime/include/mod_runtime.h new file mode 100644 index 000000000..c97afed28 --- /dev/null +++ b/runtime/include/mod_runtime.h @@ -0,0 +1,43 @@ +#pragma once + +#include + +#ifdef __cplusplus +#include +#include +#if defined(RECOMP_LAUNCHER) +#include "recomp_launcher.h" +#endif + +namespace PSXRecompV4 { + +bool mod_runtime_initialize(const std::filesystem::path& root, + const std::string& game_id, + uint32_t game_entry_pc, + const std::filesystem::path& exe_path = {}, + std::string* error = nullptr); +bool mod_runtime_commit(const std::filesystem::path& disc_path = {}, + std::string* error = nullptr); +const std::string& mod_runtime_fingerprint(); + +#if defined(RECOMP_LAUNCHER) +const ::RecompLauncherCModProvider* mod_runtime_launcher_provider(); +#endif + +} // namespace PSXRecompV4 +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +/* Called before a guest dispatch. Applies the complete main-EXE plan + * transactionally on the first dispatch to the configured entry point. */ +void mod_runtime_on_dispatch(uint32_t target); +void mod_runtime_patch_disc_sector(uint32_t lba, int raw_sector, + uint8_t* bytes, uint32_t size); +void mod_runtime_enable_disc_patches(void); + +#ifdef __cplusplus +} +#endif diff --git a/runtime/include/psx_sha256.h b/runtime/include/psx_sha256.h new file mode 100644 index 000000000..0b3e5abc1 --- /dev/null +++ b/runtime/include/psx_sha256.h @@ -0,0 +1,24 @@ +#pragma once +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct psx_sha256_ctx { + uint32_t h[8]; + uint64_t total; + uint8_t buffer[64]; + size_t buffered; +} psx_sha256_ctx; + +/* Self-contained public-domain SHA-256 implementation. */ +void psx_sha256_init(psx_sha256_ctx* ctx); +void psx_sha256_update(psx_sha256_ctx* ctx, const uint8_t* data, size_t len); +void psx_sha256_final(psx_sha256_ctx* ctx, uint8_t out[32]); +void psx_sha256_compute(const uint8_t *data, size_t len, uint8_t out[32]); + +#ifdef __cplusplus +} +#endif diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 206f08ab3..122aecdb6 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -141,6 +141,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/freeze_heartbeat.c ${PSXRECOMP_ROOT}/runtime/src/gte.cpp ${PSXRECOMP_ROOT}/runtime/src/crc32.c + ${PSXRECOMP_ROOT}/runtime/src/psx_sha256.c ${PSXRECOMP_ROOT}/runtime/src/disc_identity.cpp ${PSXRECOMP_ROOT}/runtime/src/cdrom.c ${PSXRECOMP_ROOT}/runtime/src/spu.c @@ -167,6 +168,8 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/code_provider.c ${PSXRECOMP_ROOT}/runtime/src/event_ring.c ${PSXRECOMP_ROOT}/runtime/src/game_options.c + ${PSXRECOMP_ROOT}/runtime/src/mod_packages.cpp + ${PSXRECOMP_ROOT}/runtime/src/mod_runtime.cpp ${PSXRECOMP_ROOT}/runtime/src/psx_keybinds.c ${PSXRECOMP_ROOT}/runtime/src/psx_netplay.c ${PSXRECOMP_ROOT}/runtime/src/psx_lobby_client.c @@ -290,6 +293,8 @@ function(psxrecomp_add_runtime_target target) DEFAULT_BIOS_PATH DEFAULT_GAME_CONFIG_PATH LAUNCHER_BOXART + LAUNCHER_PAD + LAUNCHER_BRAND EXE_NAME GAME_VERSION ) @@ -560,6 +565,12 @@ function(psxrecomp_add_runtime_target target) if(PSXRT_LAUNCHER_BOXART) list(APPEND _psx_recomp_ui_args BOXART "${PSXRT_LAUNCHER_BOXART}") endif() + if(PSXRT_LAUNCHER_PAD) + list(APPEND _psx_recomp_ui_args PAD "${PSXRT_LAUNCHER_PAD}") + endif() + if(PSXRT_LAUNCHER_BRAND) + list(APPEND _psx_recomp_ui_args BRAND "${PSXRT_LAUNCHER_BRAND}") + endif() recomp_target_launcher_ui(${target} ${_psx_recomp_ui_args}) endif() diff --git a/runtime/src/fntrace.c b/runtime/src/fntrace.c index bb0f5e069..45d44bfd5 100644 --- a/runtime/src/fntrace.c +++ b/runtime/src/fntrace.c @@ -3,6 +3,7 @@ #include "fntrace.h" #include "text_xlate.h" /* on-the-fly string translation hook (framework) */ #include "parity_trace.h" /* general control-flow parity ring (native producer) */ +#include "mod_runtime.h" #include #include @@ -86,6 +87,9 @@ DispTailEntry g_disp_tail[DISP_TAIL_CAP]; uint64_t g_disp_tail_seq = 0; void fntrace_record(CPUState* cpu, uint32_t target) { + /* The BIOS has completed the PS-X EXE load by the time it dispatches the + * entry point. Apply the validated plan before the first guest instruction. */ + mod_runtime_on_dispatch(target); { extern uint64_t psx_get_cycle_count(void); DispTailEntry *t = &g_disp_tail[g_disp_tail_seq % DISP_TAIL_CAP]; diff --git a/runtime/src/iso_reader_c.cpp b/runtime/src/iso_reader_c.cpp index f73c548ae..13c0beb27 100644 --- a/runtime/src/iso_reader_c.cpp +++ b/runtime/src/iso_reader_c.cpp @@ -5,6 +5,7 @@ */ #include "iso_reader.h" +#include "mod_runtime.h" #include extern "C" { @@ -22,13 +23,17 @@ int iso_read_sector(void* handle, uint32_t lba, uint8_t* buffer, int size) { if (!handle) return 0; auto* reader = static_cast(handle); (void)size; /* ReadSector always reads 2048 bytes */ - return reader->ReadSector(lba, buffer) ? 1 : 0; + if (!reader->ReadSector(lba, buffer)) return 0; + mod_runtime_patch_disc_sector(lba, 0, buffer, 2048); + return 1; } int iso_read_raw_sector(void* handle, uint32_t lba, uint8_t* buffer, int size) { if (!handle || size < 2352) return 0; auto* reader = static_cast(handle); - return reader->ReadRawSector(lba, buffer) ? 1 : 0; + if (!reader->ReadRawSector(lba, buffer)) return 0; + mod_runtime_patch_disc_sector(lba, 1, buffer, 2352); + return 1; } uint32_t iso_sector_count(void* handle) { diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 2aef1fa27..a6a0de3d8 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -54,6 +54,7 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "freeze_heartbeat.h" #include "config_loader.h" #include "game_options.h" +#include "mod_runtime.h" #include "crc32.h" #include "disc_identity.h" #include "iso_reader.h" /* text-image guard: extract the boot EXE from the disc */ @@ -4048,12 +4049,16 @@ namespace { #endif } - int ae_np_create(void*, const char* lobby_name, const char* host_port, + int ae_np_create(void*, const char* lobby_name, char* host_endpoint, const char* password, - const RecompLauncherCSettings* settings) { + const RecompLauncherCSettings* settings, int lan_only) { PsxLobbyMatchCaps caps = ae_netplay_caps_from_settings(settings); - const char* endpoint = host_port && host_port[0] ? host_port : "0.0.0.0:7777"; - ae_np_write_lan_lobby(lobby_name, endpoint, password); + const char* endpoint = host_endpoint && host_endpoint[0] + ? host_endpoint : "0.0.0.0:7777"; + if (lan_only) { + ae_np_write_lan_lobby(lobby_name, endpoint, password); + return 0; + } return psx_lobby_create(lobby_name && lobby_name[0] ? lobby_name : "Netplay Lobby", g_lnch_netplay_game_name.c_str(), PSX_GAME_VERSION, password ? password : "", endpoint, &caps); @@ -5032,6 +5037,16 @@ int main(int argc, char** argv) { } } + { + std::string mod_error; + if (!PSXRecompV4::mod_runtime_initialize( + exe_dir_from_argv(argv[0]) / "mods", game_id, + game_entry_pc, text_guard_exe_path, &mod_error)) { + std::fprintf(stderr, "psxrecomp: mods unavailable: %s\n", + mod_error.c_str()); + } + } + #if defined(RECOMP_LAUNCHER) /* Integrated recomp-ui launcher: shown in its own GL window before the emulator * boots. Seeded with the effective settings (game.toml ∪ settings.toml); @@ -5235,6 +5250,7 @@ int main(int argc, char** argv) { g_lnch_has_crc = game_has_disc_crc; gi.disc_verify = ae_disc_verify; gi.memcard_inspect = ae_memcard_inspect; + gi.mods = PSXRecompV4::mod_runtime_launcher_provider(); #if defined(PSX_HAS_RECOMP_NET) && defined(PSX_HAS_LOBBY_CLIENT) g_lnch_netplay_game_name = game_name.empty() ? "PSX" : game_name; gi.netplay_supported = (game_players == 2) ? 1 : 0; @@ -5393,6 +5409,15 @@ int main(int argc, char** argv) { } #endif + { + std::string mod_error; + if (!PSXRecompV4::mod_runtime_commit(resolved_disc, &mod_error)) { + std::fprintf(stderr, "psxrecomp: cannot launch with selected mods: %s\n", + mod_error.c_str()); + return 1; + } + } + /* Re-apply the resolved language to the translation layer. text_xlate_init * (at config load) only saw the game.toml default; this folds in the * settings.toml override and the launcher's choice. No-op when unchanged. */ @@ -5564,6 +5589,7 @@ int main(int argc, char** argv) { if (game_config_path) arm_text_image_guard(text_guard_exe_path, text_guard_load_addr, disc_path_str); + mod_runtime_enable_disc_patches(); { int divisor = 1; /* default: authentic 1x timing */ if (disc_speed == "instant") divisor = 0; diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp new file mode 100644 index 000000000..996c4007e --- /dev/null +++ b/runtime/src/mod_packages.cpp @@ -0,0 +1,1227 @@ +#include "mod_packages.h" + +#include "crc32.h" +#include "psx_sha256.h" +#include "toml.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace PSXRecompV4 { +namespace { + +constexpr uint32_t kFormatVersion = 1; +constexpr uint64_t kMaxArchiveBytes = 256ull * 1024ull * 1024ull; +constexpr uint32_t kMaxArchiveFiles = 4096; + +std::map& builtin_resolvers() { + static std::map value; + return value; +} + +void set_error(std::string* out, const std::string& value) { + if (out) *out = value; +} + +bool valid_id(const std::string& value) { + if (value.empty() || value.size() > 96) return false; + for (unsigned char c : value) { + if (!(std::islower(c) || std::isdigit(c) || c == '.' || c == '-' || c == '_')) + return false; + } + return value.front() != '.' && value.back() != '.'; +} + +bool parse_hex_bytes(const std::string& text, std::vector& out) { + std::string compact; + compact.reserve(text.size()); + for (unsigned char c : text) { + if (!std::isspace(c) && c != '_') compact.push_back((char)c); + } + if (compact.size() % 2 != 0) return false; + out.clear(); + out.reserve(compact.size() / 2); + auto nibble = [](unsigned char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + c = (unsigned char)std::tolower(c); + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + return -1; + }; + for (size_t i = 0; i < compact.size(); i += 2) { + const int hi = nibble((unsigned char)compact[i]); + const int lo = nibble((unsigned char)compact[i + 1]); + if (hi < 0 || lo < 0) return false; + out.push_back((uint8_t)((hi << 4) | lo)); + } + return true; +} + +std::string hex_bytes(const std::vector& bytes) { + std::ostringstream out; + for (uint8_t byte : bytes) + out << std::hex << std::setw(2) << std::setfill('0') << (unsigned)byte; + return out.str(); +} + +struct SemVer { + int64_t major = 0, minor = 0, patch = 0; + std::string suffix; + bool valid = false; +}; + +SemVer parse_semver(const std::string& text) { + SemVer out; + std::string core = text; + const size_t dash = core.find('-'); + if (dash != std::string::npos) { + out.suffix = core.substr(dash + 1); + core.resize(dash); + } + std::array parts = {&out.major, &out.minor, &out.patch}; + size_t at = 0; + for (size_t i = 0; i < parts.size(); ++i) { + const size_t end = core.find('.', at); + const std::string token = core.substr(at, end == std::string::npos ? end : end - at); + if (token.empty() || + !std::all_of(token.begin(), token.end(), [](unsigned char c) { return std::isdigit(c); })) + return out; + try { + *parts[i] = std::stoll(token); + } catch (...) { + return out; + } + if (end == std::string::npos) { + if (i != 2) return out; + at = core.size(); + } else { + at = end + 1; + } + } + if (at != core.size()) return out; + out.valid = true; + return out; +} + +int compare_semver(const std::string& a, const std::string& b) { + const SemVer av = parse_semver(a), bv = parse_semver(b); + if (!av.valid || !bv.valid) return a.compare(b); + if (av.major != bv.major) return av.major < bv.major ? -1 : 1; + if (av.minor != bv.minor) return av.minor < bv.minor ? -1 : 1; + if (av.patch != bv.patch) return av.patch < bv.patch ? -1 : 1; + if (av.suffix.empty() != bv.suffix.empty()) return av.suffix.empty() ? 1 : -1; + return av.suffix.compare(bv.suffix); +} + +bool version_satisfies(const std::string& actual, const std::string& requirement) { + if (requirement.empty() || requirement == "*") return true; + if (requirement.rfind(">=", 0) == 0) + return compare_semver(actual, requirement.substr(2)) >= 0; + if (requirement.rfind("<=", 0) == 0) + return compare_semver(actual, requirement.substr(2)) <= 0; + if (requirement.rfind(">", 0) == 0) + return compare_semver(actual, requirement.substr(1)) > 0; + if (requirement.rfind("<", 0) == 0) + return compare_semver(actual, requirement.substr(1)) < 0; + if (requirement.rfind("^", 0) == 0) { + const SemVer base = parse_semver(requirement.substr(1)); + const SemVer got = parse_semver(actual); + return base.valid && got.valid && got.major == base.major && + compare_semver(actual, requirement.substr(1)) >= 0; + } + return actual == requirement; +} + +std::string quote_toml(const std::string& value) { + std::string out = "\""; + for (unsigned char c : value) { + if (c == '\\' || c == '"') out.push_back('\\'); + if (c == '\n') out += "\\n"; + else if (c == '\r') out += "\\r"; + else out.push_back((char)c); + } + out.push_back('"'); + return out; +} + +bool read_file(const fs::path& path, std::vector& out, std::string* error) { + std::ifstream in(path, std::ios::binary); + if (!in) { + set_error(error, "cannot open " + path.string()); + return false; + } + in.seekg(0, std::ios::end); + const std::streamoff size = in.tellg(); + if (size < 0 || (uint64_t)size > kMaxArchiveBytes) { + set_error(error, "archive is too large"); + return false; + } + in.seekg(0); + out.resize((size_t)size); + if (!out.empty() && !in.read((char*)out.data(), size)) { + set_error(error, "cannot read " + path.string()); + return false; + } + return true; +} + +uint16_t le16(const uint8_t* p) { + return (uint16_t)(p[0] | ((uint16_t)p[1] << 8)); +} + +uint32_t le32(const uint8_t* p) { + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | + ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); +} + +struct ZipEntry { + std::string name; + uint16_t method = 0; + uint32_t crc = 0; + uint32_t compressed_size = 0; + uint32_t size = 0; + uint32_t local_offset = 0; + bool directory = false; +}; + +bool safe_archive_name(const std::string& name) { + if (name.empty() || name.size() > 512 || name[0] == '/' || name[0] == '\\') + return false; + if (name.size() >= 2 && std::isalpha((unsigned char)name[0]) && name[1] == ':') + return false; + fs::path p = fs::path(name).lexically_normal(); + for (const auto& part : p) { + const std::string s = part.string(); + if (s == ".." || s == "." || s.empty()) return false; + } + return true; +} + +bool parse_zip(const std::vector& bytes, std::vector& entries, + std::string* error) { + if (bytes.size() < 22) { + set_error(error, "not a ZIP archive"); + return false; + } + size_t eocd = std::string::npos; + const size_t floor = bytes.size() > 65557 ? bytes.size() - 65557 : 0; + for (size_t pos = bytes.size() - 22;; --pos) { + if (le32(bytes.data() + pos) == 0x06054b50u) { eocd = pos; break; } + if (pos == floor) break; + } + if (eocd == std::string::npos) { + set_error(error, "ZIP end record is missing"); + return false; + } + const uint16_t count = le16(bytes.data() + eocd + 10); + const uint32_t central_size = le32(bytes.data() + eocd + 12); + const uint32_t central_offset = le32(bytes.data() + eocd + 16); + if (count > kMaxArchiveFiles || (uint64_t)central_offset + central_size > bytes.size()) { + set_error(error, "ZIP central directory is invalid"); + return false; + } + size_t at = central_offset; + uint64_t expanded = 0; + for (uint32_t i = 0; i < count; ++i) { + if (at + 46 > bytes.size() || le32(bytes.data() + at) != 0x02014b50u) { + set_error(error, "ZIP entry record is invalid"); + return false; + } + const uint16_t flags = le16(bytes.data() + at + 8); + ZipEntry e; + e.method = le16(bytes.data() + at + 10); + e.crc = le32(bytes.data() + at + 16); + e.compressed_size = le32(bytes.data() + at + 20); + e.size = le32(bytes.data() + at + 24); + const uint16_t name_len = le16(bytes.data() + at + 28); + const uint16_t extra_len = le16(bytes.data() + at + 30); + const uint16_t comment_len = le16(bytes.data() + at + 32); + e.local_offset = le32(bytes.data() + at + 42); + if (flags & 1u) { + set_error(error, "encrypted ZIP entries are not supported"); + return false; + } + if (e.method != 0 && e.method != 8) { + set_error(error, "ZIP compression method is not supported"); + return false; + } + if (at + 46ull + name_len + extra_len + comment_len > bytes.size()) { + set_error(error, "ZIP entry name is truncated"); + return false; + } + e.name.assign((const char*)bytes.data() + at + 46, name_len); + std::replace(e.name.begin(), e.name.end(), '\\', '/'); + e.directory = !e.name.empty() && e.name.back() == '/'; + if (!safe_archive_name(e.directory ? e.name.substr(0, e.name.size() - 1) : e.name)) { + set_error(error, "unsafe ZIP path: " + e.name); + return false; + } + expanded += e.size; + if (expanded > kMaxArchiveBytes) { + set_error(error, "expanded archive exceeds the size limit"); + return false; + } + entries.push_back(std::move(e)); + at += 46ull + name_len + extra_len + comment_len; + } + return true; +} + +struct DeflateBits { + const uint8_t* at = nullptr; + const uint8_t* end = nullptr; + uint64_t hold = 0; + unsigned bits = 0; + + bool read(unsigned count, uint32_t& out) { + while (bits < count) { + if (at == end) return false; + hold |= (uint64_t)*at++ << bits; + bits += 8; + } + out = count == 32 ? (uint32_t)hold : + (uint32_t)(hold & ((1ull << count) - 1)); + hold >>= count; + bits -= count; + return true; + } + void align_byte() { + const unsigned drop = bits & 7u; + hold >>= drop; + bits -= drop; + } +}; + +struct DeflateHuffman { + std::array count{}; + std::vector symbols; +}; + +bool build_huffman(const std::vector& lengths, DeflateHuffman& out) { + out = {}; + for (uint8_t length : lengths) { + if (length > 15) return false; + out.count[length]++; + } + if (out.count[0] == lengths.size()) return false; + int left = 1; + for (int length = 1; length <= 15; ++length) { + left <<= 1; + left -= out.count[(size_t)length]; + if (left < 0) return false; + } + std::array offsets{}; + for (size_t length = 1; length < 15; ++length) + offsets[length + 1] = offsets[length] + out.count[length]; + out.symbols.resize(lengths.size() - out.count[0]); + for (uint16_t symbol = 0; symbol < lengths.size(); ++symbol) + if (lengths[symbol]) + out.symbols[offsets[lengths[symbol]]++] = symbol; + return true; +} + +bool decode_symbol(DeflateBits& bits, const DeflateHuffman& table, uint16_t& symbol) { + uint32_t code = 0, first = 0, index = 0; + for (uint32_t length = 1; length <= 15; ++length) { + uint32_t bit = 0; + if (!bits.read(1, bit)) return false; + code |= bit; + const uint32_t count = table.count[length]; + if (code < first + count) { + const uint32_t slot = index + code - first; + if (slot >= table.symbols.size()) return false; + symbol = table.symbols[slot]; + return true; + } + index += count; + first = (first + count) << 1; + code <<= 1; + } + return false; +} + +bool inflate_deflate(const uint8_t* data, size_t size, size_t expected, + std::vector& out) { + static const uint16_t length_base[29] = { + 3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99, + 115,131,163,195,227,258}; + static const uint8_t length_extra[29] = { + 0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0}; + static const uint16_t distance_base[30] = { + 1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769, + 1025,1537,2049,3073,4097,6145,8193,12289,16385,24577}; + static const uint8_t distance_extra[30] = { + 0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11, + 12,12,13,13}; + DeflateBits input{data, data + size}; + out.clear(); + out.reserve(expected); + bool final = false; + while (!final) { + uint32_t final_bit = 0, type = 0; + if (!input.read(1, final_bit) || !input.read(2, type)) return false; + final = final_bit != 0; + if (type == 0) { + input.align_byte(); + uint32_t length = 0, complement = 0; + if (!input.read(16, length) || !input.read(16, complement) || + (length ^ 0xffffu) != complement || + out.size() + length > expected) return false; + for (uint32_t i = 0; i < length; ++i) { + uint32_t byte = 0; + if (!input.read(8, byte)) return false; + out.push_back((uint8_t)byte); + } + continue; + } + if (type == 3) return false; + + std::vector literal_lengths; + std::vector distance_lengths; + if (type == 1) { + literal_lengths.resize(288); + for (size_t i = 0; i <= 143; ++i) literal_lengths[i] = 8; + for (size_t i = 144; i <= 255; ++i) literal_lengths[i] = 9; + for (size_t i = 256; i <= 279; ++i) literal_lengths[i] = 7; + for (size_t i = 280; i <= 287; ++i) literal_lengths[i] = 8; + distance_lengths.assign(32, 5); + } else { + uint32_t hlit = 0, hdist = 0, hclen = 0; + if (!input.read(5, hlit) || !input.read(5, hdist) || + !input.read(4, hclen)) return false; + hlit += 257; hdist += 1; hclen += 4; + static const uint8_t order[19] = + {16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15}; + std::vector code_lengths(19, 0); + for (uint32_t i = 0; i < hclen; ++i) { + uint32_t value = 0; + if (!input.read(3, value)) return false; + code_lengths[order[i]] = (uint8_t)value; + } + DeflateHuffman code_table; + if (!build_huffman(code_lengths, code_table)) return false; + std::vector lengths; + lengths.reserve(hlit + hdist); + while (lengths.size() < hlit + hdist) { + uint16_t symbol = 0; + if (!decode_symbol(input, code_table, symbol)) return false; + if (symbol <= 15) { + lengths.push_back((uint8_t)symbol); + continue; + } + uint32_t repeat = 0, extra = 0; + uint8_t value = 0; + if (symbol == 16) { + if (lengths.empty() || !input.read(2, extra)) return false; + repeat = extra + 3; + value = lengths.back(); + } else if (symbol == 17) { + if (!input.read(3, extra)) return false; + repeat = extra + 3; + } else if (symbol == 18) { + if (!input.read(7, extra)) return false; + repeat = extra + 11; + } else return false; + if (lengths.size() + repeat > hlit + hdist) return false; + lengths.insert(lengths.end(), repeat, value); + } + literal_lengths.assign(lengths.begin(), lengths.begin() + hlit); + distance_lengths.assign(lengths.begin() + hlit, lengths.end()); + } + DeflateHuffman literals, distances; + if (!build_huffman(literal_lengths, literals) || + !build_huffman(distance_lengths, distances)) return false; + for (;;) { + uint16_t symbol = 0; + if (!decode_symbol(input, literals, symbol)) return false; + if (symbol < 256) { + if (out.size() >= expected) return false; + out.push_back((uint8_t)symbol); + continue; + } + if (symbol == 256) break; + if (symbol < 257 || symbol > 285) return false; + const unsigned length_index = symbol - 257; + uint32_t extra = 0; + if (!input.read(length_extra[length_index], extra)) return false; + const size_t length = length_base[length_index] + extra; + uint16_t distance_symbol = 0; + if (!decode_symbol(input, distances, distance_symbol) || + distance_symbol >= 30) return false; + if (!input.read(distance_extra[distance_symbol], extra)) return false; + const size_t distance = distance_base[distance_symbol] + extra; + if (distance == 0 || distance > out.size() || + out.size() + length > expected) return false; + for (size_t i = 0; i < length; ++i) + out.push_back(out[out.size() - distance]); + } + } + return out.size() == expected; +} + +bool extract_zip(const std::vector& bytes, + const std::vector& entries, + const fs::path& target, std::string* error) { + std::error_code ec; + fs::create_directories(target, ec); + if (ec) { + set_error(error, "cannot create staging directory: " + ec.message()); + return false; + } + for (const ZipEntry& e : entries) { + const fs::path out = target / fs::path(e.name); + if (e.directory) { + fs::create_directories(out, ec); + if (ec) { + set_error(error, "cannot create archive directory: " + ec.message()); + return false; + } + continue; + } + if ((uint64_t)e.local_offset + 30 > bytes.size() || + le32(bytes.data() + e.local_offset) != 0x04034b50u) { + set_error(error, "ZIP local entry is invalid"); + return false; + } + const uint16_t name_len = le16(bytes.data() + e.local_offset + 26); + const uint16_t extra_len = le16(bytes.data() + e.local_offset + 28); + const uint64_t data_at = (uint64_t)e.local_offset + 30 + name_len + extra_len; + if (data_at + e.compressed_size > bytes.size()) { + set_error(error, "ZIP entry payload is invalid"); + return false; + } + const uint8_t* compressed = bytes.data() + data_at; + std::vector expanded; + const uint8_t* data = compressed; + if (e.method == 0) { + if (e.compressed_size != e.size) { + set_error(error, "stored ZIP entry has inconsistent size"); + return false; + } + } else { + if (!inflate_deflate(compressed, e.compressed_size, e.size, expanded)) { + set_error(error, "cannot inflate ZIP entry: " + e.name); + return false; + } + data = expanded.data(); + } + if (crc32_compute(data, e.size) != e.crc) { + set_error(error, "ZIP entry checksum failed: " + e.name); + return false; + } + fs::create_directories(out.parent_path(), ec); + if (ec) { + set_error(error, "cannot create archive parent directory: " + ec.message()); + return false; + } + std::ofstream file(out, std::ios::binary | std::ios::trunc); + if (!file || (e.size && !file.write((const char*)data, e.size))) { + set_error(error, "cannot extract archive entry: " + e.name); + return false; + } + } + return true; +} + +const ModPackage* find_selected( + const std::map>& packages, + const std::string& id, const ModSelection& selection) { + const auto p = packages.find(id); + if (p == packages.end() || p->second.empty()) return nullptr; + if (!selection.version.empty()) { + const auto v = p->second.find(selection.version); + return v == p->second.end() ? nullptr : &v->second; + } + const ModPackage* best = nullptr; + for (const auto& [version, package] : p->second) + if (!best || compare_semver(version, best->version) > 0) best = &package; + return best; +} + +bool target_matches(const ModPackage& package, const std::string& game, + const std::string& exe, const std::string& disc) { + if (package.targets.empty()) return false; + for (const ModTarget& target : package.targets) { + if (target.game_id != game) continue; + if (!target.exe_sha256.empty() && target.exe_sha256 != exe) continue; + if (!target.disc_sha256.empty() && target.disc_sha256 != disc) continue; + return true; + } + return false; +} + +std::string canonical_resolution(const std::vector& ordered, + const std::map& selections, + const std::vector& writes) { + std::ostringstream out; + for (const ModPackage* package : ordered) { + out << package->id << '@' << package->version << '\n'; + const auto sit = selections.find(package->id); + if (sit == selections.end()) continue; + for (const auto& [key, value] : sit->second.values) + out << key << '=' << value << '\n'; + } + for (const ModResolution::Write& write : writes) { + out << (write.target == ModPatchTarget::MainExe ? "main_exe" : + write.target == ModPatchTarget::DiscRaw ? "disc_raw" : "disc_user") + << '@' << std::hex << write.location << std::dec << ':' + << hex_bytes(write.expected) << '>' << hex_bytes(write.replacement) + << ':' << write.package_id << '\n'; + } + return out.str(); +} + +std::string effective_option_value(const ModPackage& package, + const ModSelection& selection, + const std::string& id) { + const auto selected = selection.values.find(id); + if (selected != selection.values.end()) return selected->second; + const auto option = std::find_if(package.options.begin(), package.options.end(), + [&](const ModOption& item) { return item.id == id; }); + return option == package.options.end() ? std::string() : option->default_value; +} + +bool writes_overlap(const ModResolution::Write& a, const ModResolution::Write& b) { + if (a.target != b.target) return false; + const uint64_t a_end = a.location + a.replacement.size(); + const uint64_t b_end = b.location + b.replacement.size(); + return a.location < b_end && b.location < a_end; +} + +std::string fingerprint_text(const std::string& text) { + uint8_t digest[32]; + psx_sha256_compute((const uint8_t*)text.data(), text.size(), digest); + std::ostringstream out; + for (uint8_t byte : digest) + out << std::hex << std::setw(2) << std::setfill('0') << (unsigned)byte; + return out.str(); +} + +} // namespace + +bool mod_register_builtin_resolver(const std::string& id, ModBuiltinResolver resolver) { + if (!valid_id(id) || !resolver) return false; + return builtin_resolvers().emplace(id, std::move(resolver)).second; +} + +void mod_clear_builtin_resolvers_for_tests() { + builtin_resolvers().clear(); +} + +ModPackageManager::ModPackageManager(fs::path mods_root) : root_(std::move(mods_root)) {} + +void ModPackageManager::set_root(fs::path mods_root) { + root_ = std::move(mods_root); + packages_.clear(); + selections_.clear(); +} + +bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, + std::string* error) { + try { + const toml::value cfg = toml::parse(path.string()); + out = {}; + out.format_version = (uint32_t)toml::find(cfg, "format_version"); + out.id = toml::find(cfg, "id"); + out.version = toml::find(cfg, "version"); + out.name = toml::find(cfg, "name"); + out.author = cfg.contains("author") ? toml::find(cfg, "author") : ""; + out.description = + cfg.contains("description") ? toml::find(cfg, "description") : ""; + out.license = cfg.contains("license") ? toml::find(cfg, "license") : ""; + out.resolver = + cfg.contains("resolver") ? toml::find(cfg, "resolver") : "declarative"; + out.save_compatibility = cfg.contains("save_compatibility") + ? toml::find(cfg, "save_compatibility") : "shared"; + out.root = path.parent_path(); + if (out.format_version != kFormatVersion) + throw std::runtime_error("unsupported format_version"); + if (!valid_id(out.id)) throw std::runtime_error("invalid package id"); + if (!parse_semver(out.version).valid) throw std::runtime_error("invalid semantic version"); + if (out.name.empty()) throw std::runtime_error("package name is empty"); + if (out.resolver != "declarative" && out.resolver.rfind("builtin:", 0) != 0) + throw std::runtime_error("resolver must be declarative or builtin:"); + if (out.save_compatibility != "shared" && out.save_compatibility != "isolated") + throw std::runtime_error("save_compatibility must be shared or isolated"); + + if (cfg.contains("target")) { + for (const toml::value& v : toml::find(cfg, "target").as_array()) { + ModTarget target; + target.game_id = toml::find(v, "game_id"); + target.exe_sha256 = + v.contains("exe_sha256") ? toml::find(v, "exe_sha256") : ""; + target.disc_sha256 = + v.contains("disc_sha256") ? toml::find(v, "disc_sha256") : ""; + if (target.game_id.empty()) throw std::runtime_error("target game_id is empty"); + out.targets.push_back(std::move(target)); + } + } + if (out.targets.empty()) throw std::runtime_error("package has no [[target]] entries"); + + if (cfg.contains("dependency")) { + for (const toml::value& v : toml::find(cfg, "dependency").as_array()) { + ModRequirement dep; + dep.id = toml::find(v, "id"); + dep.version = v.contains("version") ? toml::find(v, "version") : "*"; + if (!valid_id(dep.id)) throw std::runtime_error("invalid dependency id"); + out.dependencies.push_back(std::move(dep)); + } + } + if (cfg.contains("conflicts")) + out.conflicts = toml::find>(cfg, "conflicts"); + for (const std::string& id : out.conflicts) + if (!valid_id(id)) throw std::runtime_error("invalid conflict id"); + + if (cfg.contains("option")) { + std::set option_ids; + for (const toml::value& v : toml::find(cfg, "option").as_array()) { + ModOption option; + option.id = toml::find(v, "id"); + option.label = toml::find(v, "label"); + option.description = + v.contains("description") ? toml::find(v, "description") : ""; + option.group = v.contains("group") ? toml::find(v, "group") : "General"; + const std::string type = toml::find(v, "type"); + if (!valid_id(option.id) || !option_ids.insert(option.id).second) + throw std::runtime_error("invalid or duplicate option id"); + if (type == "boolean") { + option.type = ModOptionType::Boolean; + option.default_value = toml::find_or(v, "default", "false"); + if (option.default_value != "true" && option.default_value != "false") + throw std::runtime_error("boolean default must be true or false"); + } else if (type == "choice") { + option.type = ModOptionType::Choice; + option.default_value = toml::find(v, "default"); + for (const toml::value& c : toml::find(v, "choice").as_array()) { + ModChoice choice; + choice.value = toml::find(c, "value"); + choice.label = toml::find(c, "label"); + option.choices.push_back(std::move(choice)); + } + const auto found = std::find_if(option.choices.begin(), option.choices.end(), + [&](const ModChoice& c) { return c.value == option.default_value; }); + if (found == option.choices.end()) + throw std::runtime_error("choice default is not declared"); + } else if (type == "integer") { + option.type = ModOptionType::Integer; + option.min_value = toml::find(v, "min"); + option.max_value = toml::find(v, "max"); + option.step = toml::find_or(v, "step", 1); + const int64_t def = toml::find(v, "default"); + if (option.min_value > option.max_value || option.step <= 0 || + def < option.min_value || def > option.max_value) + throw std::runtime_error("invalid integer bounds/default"); + option.default_value = std::to_string(def); + } else { + throw std::runtime_error("unknown option type"); + } + out.options.push_back(std::move(option)); + } + } + if (cfg.contains("patch")) { + size_t declaration_index = 0; + for (const toml::value& v : toml::find(cfg, "patch").as_array()) { + ModPatch patch; + const std::string target = toml::find(v, "target"); + if (target == "main_exe") { + patch.target = ModPatchTarget::MainExe; + const int64_t address = toml::find(v, "address"); + if (address < 0) throw std::runtime_error("patch address is negative"); + patch.location = (uint64_t)address; + } else if (target == "disc_raw" || target == "disc") { + patch.target = ModPatchTarget::DiscRaw; + const int64_t offset = toml::find(v, "offset"); + if (offset < 0) throw std::runtime_error("patch offset is negative"); + patch.location = (uint64_t)offset; + } else if (target == "disc_user") { + patch.target = ModPatchTarget::DiscUser; + const int64_t offset = toml::find(v, "offset"); + if (offset < 0) throw std::runtime_error("patch offset is negative"); + patch.location = (uint64_t)offset; + } else { + throw std::runtime_error( + "patch target must be main_exe, disc_raw, or disc_user"); + } + const std::string expected = toml::find(v, "expected"); + const std::string replacement = toml::find(v, "replace"); + if (!parse_hex_bytes(expected, patch.expected) || + !parse_hex_bytes(replacement, patch.replacement) || + patch.expected.empty() || + patch.expected.size() != patch.replacement.size()) + throw std::runtime_error( + "patch expected/replace must be equal-length non-empty hex"); + const uint64_t sector_size = + patch.target == ModPatchTarget::DiscRaw ? 2352 : + patch.target == ModPatchTarget::DiscUser ? 2048 : 0; + if (sector_size != 0 && + patch.location % sector_size + patch.replacement.size() > sector_size) + throw std::runtime_error( + "disc patch may not cross a sector boundary"); + patch.when_option = + v.contains("when_option") ? toml::find(v, "when_option") : ""; + patch.when_value = + v.contains("when_value") ? toml::find(v, "when_value") : ""; + patch.order = toml::find_or( + v, "order", (int64_t)declaration_index); + if (patch.when_option.empty() != patch.when_value.empty()) + throw std::runtime_error( + "patch condition requires both when_option and when_value"); + if (!patch.when_option.empty()) { + const auto option = std::find_if( + out.options.begin(), out.options.end(), + [&](const ModOption& item) { return item.id == patch.when_option; }); + if (option == out.options.end()) + throw std::runtime_error("patch references unknown option"); + } + out.patches.push_back(std::move(patch)); + ++declaration_index; + } + } + return true; + } catch (const std::exception& ex) { + set_error(error, path.string() + ": " + ex.what()); + return false; + } +} + +bool ModPackageManager::scan(std::string* error) { + packages_.clear(); + std::error_code ec; + const fs::path packages_root = root_ / "packages"; + if (!fs::exists(packages_root, ec)) return true; + for (const fs::directory_entry& id_dir : fs::directory_iterator(packages_root, ec)) { + if (ec) break; + if (!id_dir.is_directory()) continue; + for (const fs::directory_entry& version_dir : fs::directory_iterator(id_dir.path(), ec)) { + if (ec) break; + if (!version_dir.is_directory()) continue; + const fs::path manifest = version_dir.path() / "manifest.toml"; + if (!fs::exists(manifest)) continue; + ModPackage package; + std::string parse_error; + if (!read_manifest(manifest, package, &parse_error)) { + set_error(error, parse_error); + return false; + } + if (package.id != id_dir.path().filename().string() || + package.version != version_dir.path().filename().string()) { + set_error(error, "package path does not match manifest id/version: " + + manifest.string()); + return false; + } + packages_[package.id][package.version] = std::move(package); + } + } + if (ec) { + set_error(error, "cannot scan packages: " + ec.message()); + return false; + } + return true; +} + +bool ModPackageManager::load_state(std::string* error) { + selections_.clear(); + const fs::path path = root_ / "state.toml"; + if (!fs::exists(path)) return true; + try { + const toml::value cfg = toml::parse(path.string()); + const int64_t version = toml::find(cfg, "format_version"); + if (version != 1) throw std::runtime_error("unsupported state format_version"); + if (!cfg.contains("package")) return true; + for (const toml::value& v : toml::find(cfg, "package").as_array()) { + const std::string id = toml::find(v, "id"); + if (!valid_id(id)) throw std::runtime_error("invalid state package id"); + ModSelection selection; + selection.enabled = toml::find_or(v, "enabled", false); + selection.version = toml::find_or(v, "version", ""); + if (v.contains("values")) { + for (const auto& [key, value] : toml::find(v, "values").as_table()) { + if (value.is_string()) selection.values[key] = toml::get(value); + else if (value.is_boolean()) + selection.values[key] = toml::get(value) ? "true" : "false"; + else if (value.is_integer()) + selection.values[key] = std::to_string(toml::get(value)); + else throw std::runtime_error("state option values must be scalar"); + } + } + selections_[id] = std::move(selection); + } + return true; + } catch (const std::exception& ex) { + set_error(error, path.string() + ": " + ex.what()); + return false; + } +} + +bool ModPackageManager::save_state(std::string* error) const { + std::error_code ec; + fs::create_directories(root_, ec); + if (ec) { + set_error(error, "cannot create mods directory: " + ec.message()); + return false; + } + const fs::path temp = root_ / "state.toml.tmp"; + const fs::path final = root_ / "state.toml"; + std::ofstream out(temp, std::ios::trunc); + if (!out) { + set_error(error, "cannot write " + temp.string()); + return false; + } + out << "format_version = 1\n"; + for (const auto& [id, selection] : selections_) { + out << "\n[[package]]\n"; + out << "id = " << quote_toml(id) << "\n"; + out << "enabled = " << (selection.enabled ? "true" : "false") << "\n"; + if (!selection.version.empty()) + out << "version = " << quote_toml(selection.version) << "\n"; + if (!selection.values.empty()) { + out << "[package.values]\n"; + for (const auto& [key, value] : selection.values) + out << key << " = " << quote_toml(value) << "\n"; + } + } + out.close(); + if (!out) { + set_error(error, "cannot finish " + temp.string()); + return false; + } + fs::rename(temp, final, ec); + if (ec) { + fs::remove(final, ec); + ec.clear(); + fs::rename(temp, final, ec); + } + if (ec) { + set_error(error, "cannot publish state: " + ec.message()); + return false; + } + return true; +} + +bool ModPackageManager::install_archive(const fs::path& archive, + std::string* installed_id, + std::string* installed_version, + std::string* error) { + std::vector bytes; + std::vector entries; + if (!read_file(archive, bytes, error) || !parse_zip(bytes, entries, error)) + return false; + const auto manifest_entry = std::find_if(entries.begin(), entries.end(), + [](const ZipEntry& e) { return e.name == "manifest.toml" && !e.directory; }); + if (manifest_entry == entries.end()) { + set_error(error, "archive root does not contain manifest.toml"); + return false; + } + + std::error_code ec; + fs::create_directories(root_ / ".staging", ec); + if (ec) { + set_error(error, "cannot create install staging root: " + ec.message()); + return false; + } + const std::string token = + std::to_string((unsigned long long)crc32_compute(bytes.data(), bytes.size())); + const fs::path staging = root_ / ".staging" / ("install-" + token); + if (fs::exists(staging)) { + set_error(error, "install staging path already exists; remove " + staging.string()); + return false; + } + if (!extract_zip(bytes, entries, staging, error)) { + fs::remove_all(staging, ec); + return false; + } + ModPackage package; + if (!read_manifest(staging / "manifest.toml", package, error)) { + fs::remove_all(staging, ec); + return false; + } + const fs::path destination = root_ / "packages" / package.id / package.version; + if (fs::exists(destination)) { + fs::remove_all(staging, ec); + set_error(error, "package version is already installed"); + return false; + } + fs::create_directories(destination.parent_path(), ec); + if (ec) { + fs::remove_all(staging, ec); + set_error(error, "cannot create package directory: " + ec.message()); + return false; + } + fs::rename(staging, destination, ec); + if (ec) { + fs::remove_all(staging, ec); + set_error(error, "cannot publish installed package: " + ec.message()); + return false; + } + package.root = destination; + packages_[package.id][package.version] = package; + if (installed_id) *installed_id = package.id; + if (installed_version) *installed_version = package.version; + return true; +} + +bool ModPackageManager::remove_version(const std::string& id, const std::string& version, + std::string* error) { + const auto sit = selections_.find(id); + if (sit != selections_.end() && sit->second.enabled && + (sit->second.version.empty() || sit->second.version == version)) { + set_error(error, "cannot remove an active package version"); + return false; + } + for (const auto& [other_id, selection] : selections_) { + if (!selection.enabled || other_id == id) continue; + const ModPackage* package = find_selected(packages_, other_id, selection); + if (!package) continue; + for (const ModRequirement& dep : package->dependencies) { + if (dep.id == id && version_satisfies(version, dep.version)) { + set_error(error, "cannot remove a version required by " + other_id); + return false; + } + } + } + const auto pit = packages_.find(id); + if (pit == packages_.end() || pit->second.find(version) == pit->second.end()) { + set_error(error, "package version is not installed"); + return false; + } + const fs::path path = pit->second.at(version).root; + std::error_code ec; + fs::remove_all(path, ec); + if (ec) { + set_error(error, "cannot remove package version: " + ec.message()); + return false; + } + packages_[id].erase(version); + if (packages_[id].empty()) packages_.erase(id); + return true; +} + +bool ModPackageManager::set_enabled(const std::string& id, bool enabled, std::string* error) { + if (packages_.find(id) == packages_.end()) { + set_error(error, "package is not installed"); + return false; + } + selections_[id].enabled = enabled; + return true; +} + +bool ModPackageManager::select_version(const std::string& id, const std::string& version, + std::string* error) { + const auto pit = packages_.find(id); + if (pit == packages_.end() || pit->second.find(version) == pit->second.end()) { + set_error(error, "package version is not installed"); + return false; + } + selections_[id].version = version; + return true; +} + +bool ModPackageManager::set_option(const std::string& id, const std::string& option_id, + const std::string& value, std::string* error) { + const auto sit = selections_.find(id); + const ModSelection empty; + const ModSelection& selection = sit == selections_.end() ? empty : sit->second; + const ModPackage* package = find_selected(packages_, id, selection); + if (!package) { + set_error(error, "package/version is not installed"); + return false; + } + const auto oit = std::find_if(package->options.begin(), package->options.end(), + [&](const ModOption& option) { return option.id == option_id; }); + if (oit == package->options.end()) { + set_error(error, "unknown package option"); + return false; + } + bool valid = false; + if (oit->type == ModOptionType::Boolean) { + valid = value == "true" || value == "false"; + } else if (oit->type == ModOptionType::Choice) { + valid = std::any_of(oit->choices.begin(), oit->choices.end(), + [&](const ModChoice& choice) { return choice.value == value; }); + } else { + try { + size_t used = 0; + const int64_t parsed = std::stoll(value, &used); + valid = used == value.size() && parsed >= oit->min_value && + parsed <= oit->max_value && + ((parsed - oit->min_value) % oit->step) == 0; + } catch (...) { + valid = false; + } + } + if (!valid) { + set_error(error, "invalid option value"); + return false; + } + selections_[id].values[option_id] = value; + return true; +} + +const ModPackage* ModPackageManager::selected_package(const std::string& id) const { + const auto selection = selections_.find(id); + const ModSelection blank; + return find_selected(packages_, id, + selection == selections_.end() ? blank : selection->second); +} + +ModResolution ModPackageManager::resolve(const std::string& game_id, + const std::string& exe_sha256, + const std::string& disc_sha256) const { + ModResolution result; + std::map active; + for (const auto& [id, selection] : selections_) { + if (!selection.enabled) continue; + const ModPackage* package = find_selected(packages_, id, selection); + if (!package) { + result.errors.push_back("selected package/version is not installed: " + id); + continue; + } + if (!target_matches(*package, game_id, exe_sha256, disc_sha256)) { + result.errors.push_back("package does not target this game/image: " + id); + continue; + } + active[id] = package; + } + + for (const auto& [id, package] : active) { + for (const ModRequirement& dep : package->dependencies) { + const auto found = active.find(dep.id); + if (found == active.end()) + result.errors.push_back(id + " requires enabled package " + dep.id); + else if (!version_satisfies(found->second->version, dep.version)) + result.errors.push_back(id + " requires " + dep.id + " " + dep.version); + } + for (const std::string& conflict : package->conflicts) + if (active.find(conflict) != active.end()) + result.errors.push_back(id + " conflicts with " + conflict); + + const auto selection = selections_.find(id); + if (selection != selections_.end()) { + for (const ModOption& option : package->options) { + const auto value = selection->second.values.find(option.id); + if (value == selection->second.values.end()) continue; + /* Reuse the public validation path without mutating by checking + * the same domain directly. Defaults require no state entry. */ + bool valid = false; + if (option.type == ModOptionType::Boolean) + valid = value->second == "true" || value->second == "false"; + else if (option.type == ModOptionType::Choice) + valid = std::any_of(option.choices.begin(), option.choices.end(), + [&](const ModChoice& c) { return c.value == value->second; }); + else { + try { + size_t used = 0; + const int64_t n = std::stoll(value->second, &used); + valid = used == value->second.size() && n >= option.min_value && + n <= option.max_value && + ((n - option.min_value) % option.step) == 0; + } catch (...) {} + } + if (!valid) result.errors.push_back(id + ": invalid value for " + option.id); + } + } + if (package->resolver.rfind("builtin:", 0) == 0) { + const std::string resolver_id = package->resolver.substr(8); + const auto resolver = builtin_resolvers().find(resolver_id); + if (resolver == builtin_resolvers().end()) + result.errors.push_back(id + ": built-in resolver is unavailable: " + resolver_id); + } + } + + enum class Visit { None, Active, Done }; + std::map visits; + std::function visit = [&](const std::string& id) { + if (visits[id] == Visit::Done) return; + if (visits[id] == Visit::Active) { + result.errors.push_back("dependency cycle includes " + id); + return; + } + visits[id] = Visit::Active; + const ModPackage* package = active.at(id); + std::vector deps; + for (const ModRequirement& dep : package->dependencies) + if (active.find(dep.id) != active.end()) deps.push_back(dep.id); + std::sort(deps.begin(), deps.end()); + for (const std::string& dep : deps) visit(dep); + visits[id] = Visit::Done; + result.ordered.push_back(package); + }; + for (const auto& [id, package] : active) visit(id); + + if (!result.errors.empty()) { + result.ordered.clear(); + return result; + } + + for (const ModPackage* package : result.ordered) { + const auto selected_it = selections_.find(package->id); + const ModSelection blank; + const ModSelection& selected = + selected_it == selections_.end() ? blank : selected_it->second; + if (package->resolver == "declarative") { + std::vector patches; + patches.reserve(package->patches.size()); + for (const ModPatch& patch : package->patches) { + if (!patch.when_option.empty() && + effective_option_value(*package, selected, patch.when_option) != + patch.when_value) + continue; + patches.push_back(&patch); + } + std::stable_sort(patches.begin(), patches.end(), + [](const ModPatch* a, const ModPatch* b) { return a->order < b->order; }); + for (const ModPatch* patch : patches) { + ModResolution::Write write; + write.target = patch->target; + write.location = patch->location; + write.expected = patch->expected; + write.replacement = patch->replacement; + write.package_id = package->id; + result.writes.push_back(std::move(write)); + } + } else { + const std::string resolver_id = package->resolver.substr(8); + const auto resolver = builtin_resolvers().find(resolver_id); + if (resolver != builtin_resolvers().end() && + !resolver->second(*package, selected, result.writes, result.errors) && + result.errors.empty()) + result.errors.push_back(package->id + ": built-in resolver failed"); + } + } + for (size_t i = 0; i < result.writes.size(); ++i) { + const ModResolution::Write& write = result.writes[i]; + if (write.expected.empty() || + write.expected.size() != write.replacement.size()) { + result.errors.push_back(write.package_id + ": resolver emitted invalid write"); + continue; + } + for (size_t j = 0; j < i; ++j) { + if (writes_overlap(result.writes[j], write)) { + result.errors.push_back( + write.package_id + ": patch overlaps a write from " + + result.writes[j].package_id); + break; + } + } + } + if (!result.errors.empty()) { + result.ordered.clear(); + result.writes.clear(); + return result; + } + result.fingerprint = fingerprint_text( + canonical_resolution(result.ordered, selections_, result.writes)); + result.ok = true; + return result; +} + +} // namespace PSXRecompV4 diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp new file mode 100644 index 000000000..d52fef2e0 --- /dev/null +++ b/runtime/src/mod_runtime.cpp @@ -0,0 +1,467 @@ +#include "mod_runtime.h" + +#include "mod_packages.h" +#include "psx_sha256.h" + +#if defined(RECOMP_LAUNCHER) +#include "recomp_launcher.h" +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" uint8_t psx_read_byte(uint32_t addr); +extern "C" void psx_write_byte(uint32_t addr, uint8_t value); +extern "C" void dirty_ram_mark_executable_range(uint32_t phys, uint32_t len); + +namespace PSXRecompV4 { +namespace { + +struct RuntimeMods { + ModPackageManager manager; + ModResolution plan; + std::string game_id; + std::string error; + std::string exe_sha256; + std::string disc_sha256; + std::filesystem::path disc_path; + uint32_t entry_phys = 0; + bool initialized = false; + bool main_applied = false; + bool disc_enabled = false; + bool disc_guard_failed = false; +}; + +RuntimeMods& state() { + static RuntimeMods value; + return value; +} + +const ModPackage* selected_package(const std::string& id) { + return state().manager.selected_package(id); +} + +std::string selected_value(const ModPackage& package, const ModOption& option) { + const auto selection = state().manager.selections().find(package.id); + if (selection != state().manager.selections().end()) { + const auto value = selection->second.values.find(option.id); + if (value != selection->second.values.end()) return value->second; + } + return option.default_value; +} + +void set_error(const std::string& error) { + state().error = error; +} + +bool sha256_file(const std::filesystem::path& path, std::string& out, + std::string* error) { + out.clear(); + if (path.empty()) return true; + std::vector inputs{path}; + std::string extension = path.extension().string(); + std::transform(extension.begin(), extension.end(), extension.begin(), + [](unsigned char c) { return (char)std::tolower(c); }); + if (extension == ".cue") { + std::ifstream cue(path); + if (!cue) { + if (error) *error = "cannot fingerprint image: " + path.string(); + return false; + } + inputs.clear(); + std::set seen; + std::string line; + while (std::getline(cue, line)) { + size_t at = line.find_first_not_of(" \t"); + if (at == std::string::npos || line.size() - at < 4) continue; + std::string keyword = line.substr(at, 4); + std::transform(keyword.begin(), keyword.end(), keyword.begin(), + [](unsigned char c) { return (char)std::toupper(c); }); + if (keyword != "FILE") continue; + at += 4; + at = line.find_first_not_of(" \t", at); + if (at == std::string::npos) continue; + std::string name; + if (line[at] == '"') { + const size_t end = line.find('"', at + 1); + if (end == std::string::npos) continue; + name = line.substr(at + 1, end - at - 1); + } else { + const size_t end = line.find_first_of(" \t", at); + name = line.substr(at, end - at); + } + const std::filesystem::path input = + (path.parent_path() / name).lexically_normal(); + if (seen.insert(input).second) inputs.push_back(input); + } + if (inputs.empty()) { + if (error) *error = "CUE has no FILE entries: " + path.string(); + return false; + } + } + psx_sha256_ctx hash; + psx_sha256_init(&hash); + std::array buffer{}; + for (const std::filesystem::path& input : inputs) { + std::ifstream file(input, std::ios::binary); + if (!file) { + if (error) *error = "cannot fingerprint image: " + input.string(); + return false; + } + while (file) { + file.read((char*)buffer.data(), (std::streamsize)buffer.size()); + const std::streamsize got = file.gcount(); + if (got > 0) psx_sha256_update(&hash, buffer.data(), (size_t)got); + } + if (!file.eof()) { + if (error) *error = + "cannot finish fingerprinting image: " + input.string(); + return false; + } + } + uint8_t digest[32]; + psx_sha256_final(&hash, digest); + std::ostringstream text; + for (uint8_t byte : digest) + text << std::hex << std::setw(2) << std::setfill('0') << (unsigned)byte; + out = text.str(); + return true; +} + +#if defined(RECOMP_LAUNCHER) +void copy_text(char* out, size_t capacity, const std::string& value) { + if (!out || capacity == 0) return; + std::snprintf(out, capacity, "%s", value.c_str()); +} + +int provider_package_count(void*) { + return (int)state().manager.packages().size(); +} + +int provider_package_get(void*, int index, RecompLauncherCModPackage* out) { + if (!out || index < 0) return 0; + const auto& packages = state().manager.packages(); + if ((size_t)index >= packages.size()) return 0; + auto item = packages.begin(); + std::advance(item, index); + const ModPackage* package = selected_package(item->first); + if (!package) return 0; + std::memset(out, 0, sizeof(*out)); + copy_text(out->id, sizeof(out->id), package->id); + copy_text(out->version, sizeof(out->version), package->version); + copy_text(out->name, sizeof(out->name), package->name); + copy_text(out->author, sizeof(out->author), package->author); + copy_text(out->description, sizeof(out->description), package->description); + copy_text(out->license, sizeof(out->license), package->license); + const auto selection = state().manager.selections().find(package->id); + out->enabled = selection != state().manager.selections().end() && + selection->second.enabled; + out->option_count = (int)package->options.size(); + out->removable = !out->enabled; + return 1; +} + +int provider_option_get(void*, const char* package_id, int index, + RecompLauncherCModOption* out) { + if (!package_id || !out || index < 0) return 0; + const ModPackage* package = selected_package(package_id); + if (!package || (size_t)index >= package->options.size()) return 0; + const ModOption& option = package->options[(size_t)index]; + std::memset(out, 0, sizeof(*out)); + copy_text(out->id, sizeof(out->id), option.id); + copy_text(out->label, sizeof(out->label), option.label); + copy_text(out->description, sizeof(out->description), option.description); + copy_text(out->group, sizeof(out->group), option.group); + copy_text(out->value, sizeof(out->value), selected_value(*package, option)); + copy_text(out->default_value, sizeof(out->default_value), option.default_value); + out->type = option.type == ModOptionType::Boolean ? RECOMP_MOD_OPTION_BOOLEAN : + option.type == ModOptionType::Choice ? RECOMP_MOD_OPTION_CHOICE : + RECOMP_MOD_OPTION_INTEGER; + out->min_value = option.min_value; + out->max_value = option.max_value; + out->step = option.step; + out->choice_count = (int)option.choices.size(); + return 1; +} + +int provider_choice_get(void*, const char* package_id, const char* option_id, + int index, RecompLauncherCModChoice* out) { + if (!package_id || !option_id || !out || index < 0) return 0; + const ModPackage* package = selected_package(package_id); + if (!package) return 0; + const auto option = std::find_if(package->options.begin(), package->options.end(), + [&](const ModOption& value) { return value.id == option_id; }); + if (option == package->options.end() || (size_t)index >= option->choices.size()) return 0; + std::memset(out, 0, sizeof(*out)); + copy_text(out->value, sizeof(out->value), option->choices[(size_t)index].value); + copy_text(out->label, sizeof(out->label), option->choices[(size_t)index].label); + return 1; +} + +int provider_version_count(void*, const char* package_id) { + if (!package_id) return 0; + const auto package = state().manager.packages().find(package_id); + return package == state().manager.packages().end() ? 0 : (int)package->second.size(); +} + +int provider_version_get(void*, const char* package_id, int index, + RecompLauncherCModVersion* out) { + if (!package_id || !out || index < 0) return 0; + const auto package = state().manager.packages().find(package_id); + if (package == state().manager.packages().end() || + (size_t)index >= package->second.size()) return 0; + auto version = package->second.begin(); + std::advance(version, index); + std::memset(out, 0, sizeof(*out)); + copy_text(out->version, sizeof(out->version), version->first); + const ModPackage* selected = selected_package(package_id); + out->selected = selected && selected->version == version->first; + const auto selection = state().manager.selections().find(package_id); + out->removable = selection == state().manager.selections().end() || + !selection->second.enabled || !out->selected; + return 1; +} + +template +int mutate(Callback callback) { + std::string error; + if (!callback(error)) { + set_error(error); + return 0; + } + state().error.clear(); + return 1; +} + +int provider_install(void*, const char* path) { + if (!path) return 0; + return mutate([&](std::string& error) { + std::string id, version; + if (!state().manager.install_archive(path, &id, &version, &error)) return false; + if (!state().manager.scan(&error)) return false; + return state().manager.select_version(id, version, &error); + }); +} + +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); + }); +} + +int provider_enable(void*, const char* id, int enabled) { + if (!id) return 0; + return mutate([&](std::string& error) { + return state().manager.set_enabled(id, enabled != 0, &error); + }); +} + +int provider_select(void*, const char* id, const char* version) { + if (!id || !version) return 0; + return mutate([&](std::string& error) { + return state().manager.select_version(id, version, &error); + }); +} + +int provider_set_option(void*, const char* id, const char* option, const char* value) { + if (!id || !option || !value) return 0; + return mutate([&](std::string& error) { + return state().manager.set_option(id, option, value, &error); + }); +} + +int provider_commit(void*, const char* image_path) { + std::string error; + if (!mod_runtime_commit(image_path ? std::filesystem::path(image_path) : + std::filesystem::path(), &error)) { + set_error(error); + return 0; + } + state().error.clear(); + return 1; +} + +const char* provider_error(void*) { + return state().error.c_str(); +} + +RecompLauncherCModProvider provider = { + nullptr, + provider_package_count, + provider_package_get, + provider_option_get, + provider_choice_get, + provider_version_count, + provider_version_get, + provider_install, + provider_remove, + provider_enable, + provider_select, + provider_set_option, + provider_commit, + provider_error, +}; +#endif + +} // namespace + +bool mod_runtime_initialize(const std::filesystem::path& root, + const std::string& game_id, + uint32_t game_entry_pc, + const std::filesystem::path& exe_path, + std::string* error) { + RuntimeMods& s = state(); + s.manager.set_root({}); + s.plan = {}; + s.game_id.clear(); + s.error.clear(); + s.exe_sha256.clear(); + s.disc_sha256.clear(); + s.disc_path.clear(); + s.entry_phys = 0; + s.initialized = false; + s.main_applied = false; + s.disc_enabled = false; + s.disc_guard_failed = false; + s.manager.set_root(root); + s.game_id = game_id; + s.entry_phys = game_entry_pc & 0x1FFFFFFFu; + if (!s.manager.scan(&s.error) || !s.manager.load_state(&s.error)) { + if (error) *error = s.error; + return false; + } + if (!sha256_file(exe_path, s.exe_sha256, &s.error)) { + /* Release installs commonly do not carry a loose PS-X EXE; game-id and + * expected-byte guards remain available in that case. */ + s.exe_sha256.clear(); + s.error.clear(); + } + s.initialized = true; + return true; +} + +bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* error) { + RuntimeMods& s = state(); + if (!s.initialized) return true; + if (disc_path != s.disc_path) { + std::string hash_error; + std::string digest; + if (!sha256_file(disc_path, digest, &hash_error)) digest.clear(); + s.disc_path = disc_path; + s.disc_sha256 = std::move(digest); + } + ModResolution plan = + s.manager.resolve(s.game_id, s.exe_sha256, s.disc_sha256); + if (!plan.ok) { + s.error.clear(); + for (const std::string& item : plan.errors) { + if (!s.error.empty()) s.error += "\n"; + s.error += item; + } + if (error) *error = s.error; + return false; + } + if (!s.manager.save_state(&s.error)) { + if (error) *error = s.error; + return false; + } + s.plan = std::move(plan); + s.main_applied = false; + s.error.clear(); + return true; +} + +const std::string& mod_runtime_fingerprint() { + return state().plan.fingerprint; +} + +#if defined(RECOMP_LAUNCHER) +const RecompLauncherCModProvider* mod_runtime_launcher_provider() { + return &provider; +} +#endif + +} // namespace PSXRecompV4 + +extern "C" void mod_runtime_on_dispatch(uint32_t target) { + using namespace PSXRecompV4; + RuntimeMods& s = state(); + if (!s.initialized || s.main_applied || + (target & 0x1FFFFFFFu) != s.entry_phys) return; + + for (const ModResolution::Write& write : s.plan.writes) { + if (write.target != ModPatchTarget::MainExe) continue; + for (size_t i = 0; i < write.expected.size(); ++i) { + if (psx_read_byte((uint32_t)write.location + (uint32_t)i) != + write.expected[i]) { + std::fprintf(stderr, + "psxrecomp: mod plan %s rejected at 0x%08X " + "(expected-byte guard failed; booting unmodified)\n", + s.plan.fingerprint.c_str(), + (unsigned)((uint32_t)write.location + (uint32_t)i)); + s.main_applied = true; + return; + } + } + } + for (const ModResolution::Write& write : s.plan.writes) { + if (write.target != ModPatchTarget::MainExe) continue; + for (size_t i = 0; i < write.replacement.size(); ++i) + psx_write_byte((uint32_t)write.location + (uint32_t)i, + write.replacement[i]); + dirty_ram_mark_executable_range( + (uint32_t)write.location & 0x1FFFFFFFu, + (uint32_t)write.replacement.size()); + } + s.main_applied = true; + if (!s.plan.writes.empty()) + std::fprintf(stdout, "psxrecomp: applied mod plan %s\n", + s.plan.fingerprint.c_str()); +} + +extern "C" void mod_runtime_enable_disc_patches(void) { + PSXRecompV4::state().disc_enabled = true; +} + +extern "C" void mod_runtime_patch_disc_sector(uint32_t lba, int raw_sector, + uint8_t* bytes, uint32_t size) { + using namespace PSXRecompV4; + RuntimeMods& s = state(); + if (!s.initialized || !s.disc_enabled || s.disc_guard_failed || + !bytes || size == 0) return; + const ModPatchTarget target = + raw_sector ? ModPatchTarget::DiscRaw : ModPatchTarget::DiscUser; + const uint64_t base = (uint64_t)lba * size; + const uint64_t end = base + size; + for (const ModResolution::Write& write : s.plan.writes) { + if (write.target != target || write.location < base || + write.location + write.replacement.size() > end) continue; + const size_t offset = (size_t)(write.location - base); + if (std::memcmp(bytes + offset, write.expected.data(), + write.expected.size()) != 0) { + std::fprintf(stderr, + "psxrecomp: disc mod plan %s rejected at LBA %u+%zu " + "(expected-byte guard failed; disc overlay disabled)\n", + s.plan.fingerprint.c_str(), lba, offset); + s.disc_guard_failed = true; + return; + } + } + for (const ModResolution::Write& write : s.plan.writes) { + if (write.target != target || write.location < base || + write.location + write.replacement.size() > end) continue; + const size_t offset = (size_t)(write.location - base); + std::memcpy(bytes + offset, write.replacement.data(), + write.replacement.size()); + } +} diff --git a/runtime/src/psx_sha256.c b/runtime/src/psx_sha256.c new file mode 100644 index 000000000..5a4b70925 --- /dev/null +++ b/runtime/src/psx_sha256.c @@ -0,0 +1,89 @@ +/* One-shot SHA-256, based directly on FIPS 180-4. Public domain. */ +#include "psx_sha256.h" +#include + +static const uint32_t K[64] = { + 0x428a2f98u,0x71374491u,0xb5c0fbcfu,0xe9b5dba5u,0x3956c25bu,0x59f111f1u,0x923f82a4u,0xab1c5ed5u, + 0xd807aa98u,0x12835b01u,0x243185beu,0x550c7dc3u,0x72be5d74u,0x80deb1feu,0x9bdc06a7u,0xc19bf174u, + 0xe49b69c1u,0xefbe4786u,0x0fc19dc6u,0x240ca1ccu,0x2de92c6fu,0x4a7484aau,0x5cb0a9dcu,0x76f988dau, + 0x983e5152u,0xa831c66du,0xb00327c8u,0xbf597fc7u,0xc6e00bf3u,0xd5a79147u,0x06ca6351u,0x14292967u, + 0x27b70a85u,0x2e1b2138u,0x4d2c6dfcu,0x53380d13u,0x650a7354u,0x766a0abbu,0x81c2c92eu,0x92722c85u, + 0xa2bfe8a1u,0xa81a664bu,0xc24b8b70u,0xc76c51a3u,0xd192e819u,0xd6990624u,0xf40e3585u,0x106aa070u, + 0x19a4c116u,0x1e376c08u,0x2748774cu,0x34b0bcb5u,0x391c0cb3u,0x4ed8aa4au,0x5b9cca4fu,0x682e6ff3u, + 0x748f82eeu,0x78a5636fu,0x84c87814u,0x8cc70208u,0x90befffau,0xa4506cebu,0xbef9a3f7u,0xc67178f2u +}; + +static uint32_t rotr32(uint32_t x, int n) { return (x >> n) | (x << (32 - n)); } + +static void block(uint32_t h[8], const uint8_t b[64]) { + uint32_t w[64]; + for (int i=0;i<16;i++) w[i]=((uint32_t)b[i*4]<<24)|((uint32_t)b[i*4+1]<<16)| + ((uint32_t)b[i*4+2]<<8)|(uint32_t)b[i*4+3]; + for (int i=16;i<64;i++) { + uint32_t s0=rotr32(w[i-15],7)^rotr32(w[i-15],18)^(w[i-15]>>3); + uint32_t s1=rotr32(w[i-2],17)^rotr32(w[i-2],19)^(w[i-2]>>10); + w[i]=w[i-16]+s0+w[i-7]+s1; + } + uint32_t a=h[0],c0=h[1],c=h[2],d=h[3],e=h[4],f=h[5],g=h[6],x=h[7]; + for (int i=0;i<64;i++) { + uint32_t s1=rotr32(e,6)^rotr32(e,11)^rotr32(e,25); + uint32_t t1=x+s1+((e&f)^((~e)&g))+K[i]+w[i]; + uint32_t s0=rotr32(a,2)^rotr32(a,13)^rotr32(a,22); + uint32_t t2=s0+((a&c0)^(a&c)^(c0&c)); + x=g;g=f;f=e;e=d+t1;d=c;c=c0;c0=a;a=t1+t2; + } + h[0]+=a;h[1]+=c0;h[2]+=c;h[3]+=d;h[4]+=e;h[5]+=f;h[6]+=g;h[7]+=x; +} + +void psx_sha256_init(psx_sha256_ctx* ctx) { + static const uint32_t initial[8] = { + 0x6a09e667u,0xbb67ae85u,0x3c6ef372u,0xa54ff53au, + 0x510e527fu,0x9b05688cu,0x1f83d9abu,0x5be0cd19u}; + memcpy(ctx->h, initial, sizeof(initial)); + ctx->total = 0; + ctx->buffered = 0; +} + +void psx_sha256_update(psx_sha256_ctx* ctx, const uint8_t* data, size_t len) { + ctx->total += len; + if (ctx->buffered) { + size_t take = 64 - ctx->buffered; + if (take > len) take = len; + memcpy(ctx->buffer + ctx->buffered, data, take); + ctx->buffered += take; data += take; len -= take; + if (ctx->buffered == 64) { + block(ctx->h, ctx->buffer); + ctx->buffered = 0; + } + } + while (len >= 64) { + block(ctx->h, data); + data += 64; + len -= 64; + } + if (len) { + memcpy(ctx->buffer, data, len); + ctx->buffered = len; + } +} + +void psx_sha256_final(psx_sha256_ctx* ctx, uint8_t out[32]) { + uint8_t tail[128]; + size_t n=ctx->buffered,total=(n<56)?64:128; + memcpy(tail,ctx->buffer,n); tail[n]=0x80; + memset(tail+n+1,0,total-n-1-8); + uint64_t bits=ctx->total*8; + for (int i=0;i<8;i++) tail[total-1-i]=(uint8_t)(bits>>(i*8)); + block(ctx->h,tail); if (total==128) block(ctx->h,tail+64); + for (int i=0;i<8;i++) { + out[i*4]=(uint8_t)(ctx->h[i]>>24); out[i*4+1]=(uint8_t)(ctx->h[i]>>16); + out[i*4+2]=(uint8_t)(ctx->h[i]>>8); out[i*4+3]=(uint8_t)ctx->h[i]; + } +} + +void psx_sha256_compute(const uint8_t *data, size_t len, uint8_t out[32]) { + psx_sha256_ctx ctx; + psx_sha256_init(&ctx); + psx_sha256_update(&ctx, data, len); + psx_sha256_final(&ctx, out); +} diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp new file mode 100644 index 000000000..df0d556ed --- /dev/null +++ b/runtime/tests/test_mod_packages.cpp @@ -0,0 +1,182 @@ +#include "mod_packages.h" +#include "psx_sha256.h" + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using namespace PSXRecompV4; + +static int failures; + +static void check(bool value, const char* message) { + if (!value) { + std::cerr << "FAIL: " << message << "\n"; + failures++; + } +} + +static void write_text(const fs::path& path, const std::string& text) { + fs::create_directories(path.parent_path()); + std::ofstream out(path); + out << text; +} + +static void write_deflated_package(const fs::path& path) { + static const char* compressed_hex = + "4bcb2fca4d2c892f4b2d2acecccf53b05530e4ca4c01524a5599057ab9f929" + "4a5c082925433d033d0325aebcc4dc541037ca3340c117a4a428b5383f07a80" + "e2498929a9c93589458925996aac4151d5d9258949e5a121bcb950ed4140f313" + "ad827345837c4353844890b00"; + std::vector compressed; + for (const char* p = compressed_hex; *p; p += 2) + compressed.push_back((uint8_t)std::stoul(std::string(p, 2), nullptr, 16)); + std::vector zip; + auto le16 = [&](uint16_t v) { + zip.push_back((uint8_t)v); zip.push_back((uint8_t)(v >> 8)); + }; + auto le32 = [&](uint32_t v) { + le16((uint16_t)v); le16((uint16_t)(v >> 16)); + }; + const std::string name = "manifest.toml"; + le32(0x04034b50); le16(20); le16(0); le16(8); le16(0); le16(0); + le32(0x7d8454e1); le32((uint32_t)compressed.size()); le32(127); + le16((uint16_t)name.size()); le16(0); + zip.insert(zip.end(), name.begin(), name.end()); + zip.insert(zip.end(), compressed.begin(), compressed.end()); + const uint32_t central_offset = (uint32_t)zip.size(); + le32(0x02014b50); le16(20); le16(20); le16(0); le16(8); le16(0); le16(0); + le32(0x7d8454e1); le32((uint32_t)compressed.size()); le32(127); + le16((uint16_t)name.size()); le16(0); le16(0); le16(0); le16(0); + le32(0); le32(0); + zip.insert(zip.end(), name.begin(), name.end()); + const uint32_t central_size = (uint32_t)zip.size() - central_offset; + le32(0x06054b50); le16(0); le16(0); le16(1); le16(1); + le32(central_size); le32(central_offset); le16(0); + fs::create_directories(path.parent_path()); + std::ofstream out(path, std::ios::binary); + out.write((const char*)zip.data(), (std::streamsize)zip.size()); +} + +static std::string manifest(const std::string& id, const std::string& version, + const std::string& extra = {}) { + return + "format_version = 1\n" + "id = \"" + id + "\"\n" + "version = \"" + version + "\"\n" + "name = \"" + id + "\"\n" + "resolver = \"declarative\"\n" + "[[target]]\n" + "game_id = \"SLUS-TEST\"\n" + extra; +} + +int main() { + const fs::path root = fs::temp_directory_path() / "psxrecomp-mod-package-test"; + std::error_code ec; + fs::remove_all(root, ec); + + { + const uint8_t abc[] = {'a', 'b', 'c'}; + uint8_t one_shot[32], streamed[32]; + psx_sha256_compute(abc, sizeof(abc), one_shot); + psx_sha256_ctx hash; + psx_sha256_init(&hash); + psx_sha256_update(&hash, abc, 1); + psx_sha256_update(&hash, abc + 1, 2); + psx_sha256_final(&hash, streamed); + check(std::equal(one_shot, one_shot + 32, streamed), + "streaming SHA-256 must match one-shot hashing"); + } + + write_text(root / "packages/base.mod/1.0.0/manifest.toml", + manifest("base.mod", "1.0.0", + "\n[[option]]\n" + "id = \"difficulty\"\n" + "label = \"Difficulty\"\n" + "type = \"choice\"\n" + "default = \"normal\"\n" + "[[option.choice]]\nvalue = \"normal\"\nlabel = \"Normal\"\n" + "[[option.choice]]\nvalue = \"hard\"\nlabel = \"Hard\"\n" + "[[patch]]\n" + "target = \"main_exe\"\n" + "address = 2147487744\n" + "expected = \"01 02 03 04\"\n" + "replace = \"05 06 07 08\"\n" + "when_option = \"difficulty\"\n" + "when_value = \"hard\"\n")); + write_text(root / "packages/addon.mod/2.0.0/manifest.toml", + manifest("addon.mod", "2.0.0", + "\n[[dependency]]\nid = \"base.mod\"\nversion = \"^1.0.0\"\n")); + + ModPackageManager manager(root); + std::string error; + check(manager.scan(&error), error.c_str()); + write_deflated_package(root / "zip.psxmod"); + check(manager.install_archive(root / "zip.psxmod", nullptr, nullptr, &error), + error.c_str()); + check(manager.packages().count("zip.mod") == 1, + "deflated .psxmod must install"); + if (const char* external = std::getenv("PSXMOD_TEST_ARCHIVE"); + external && external[0]) { + std::string installed_id, installed_version; + check(manager.install_archive(external, &installed_id, &installed_version, + &error), + error.c_str()); + check(!installed_id.empty() && !installed_version.empty(), + "external package must report installed identity"); + } + check(manager.load_state(&error), error.c_str()); + check(manager.set_enabled("addon.mod", true, &error), error.c_str()); + ModResolution missing = manager.resolve("SLUS-TEST"); + check(!missing.ok, "missing dependency must fail resolution"); + check(manager.set_enabled("base.mod", true, &error), error.c_str()); + check(manager.set_option("base.mod", "difficulty", "hard", &error), error.c_str()); + check(!manager.set_option("base.mod", "difficulty", "impossible", &error), + "invalid choice must be rejected"); + + ModResolution resolved = manager.resolve("SLUS-TEST"); + check(resolved.ok, "valid dependency graph must resolve"); + check(resolved.ordered.size() == 2, "two packages should resolve"); + check(resolved.ordered.size() == 2 && resolved.ordered[0]->id == "base.mod", + "dependency must precede dependent"); + check(resolved.writes.size() == 1, "selected declarative patch must resolve"); + check(resolved.writes.size() == 1 && + resolved.writes[0].location == 0x80001000ull && + resolved.writes[0].replacement[0] == 5, + "resolved write must retain guest address and bytes"); + check(resolved.fingerprint.size() == 64, "plan fingerprint must be SHA-256 hex"); + const std::string fingerprint = resolved.fingerprint; + + check(manager.save_state(&error), error.c_str()); + ModPackageManager reload(root); + check(reload.scan(&error), error.c_str()); + check(reload.load_state(&error), error.c_str()); + check(reload.resolve("SLUS-TEST").fingerprint == fingerprint, + "saved state must resolve deterministically"); + check(!reload.remove_version("base.mod", "1.0.0", &error), + "active package cannot be removed"); + check(reload.set_enabled("base.mod", false, &error), error.c_str()); + check(!reload.remove_version("base.mod", "1.0.0", &error), + "enabled dependent must protect required version"); + check(reload.set_enabled("addon.mod", false, &error), error.c_str()); + check(reload.remove_version("base.mod", "1.0.0", &error), error.c_str()); + + ModPackage invalid; + write_text(root / "bad.toml", + "format_version=1\nid=\"../bad\"\nversion=\"1.0.0\"\nname=\"Bad\"\n" + "[[target]]\ngame_id=\"SLUS-TEST\"\n"); + check(!ModPackageManager::read_manifest(root / "bad.toml", invalid, &error), + "unsafe package id must be rejected"); + + fs::remove_all(root, ec); + if (failures) { + std::cerr << failures << " mod package test(s) failed\n"; + return 1; + } + std::cout << "mod package tests passed\n"; + return 0; +} diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp new file mode 100644 index 000000000..e8bacfc8f --- /dev/null +++ b/runtime/tests/test_mod_runtime.cpp @@ -0,0 +1,91 @@ +#include "mod_runtime.h" + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +static std::array ram; +static int failures; + +extern "C" uint8_t psx_read_byte(uint32_t address) { + return ram[address & 0x1fffffu]; +} + +extern "C" void psx_write_byte(uint32_t address, uint8_t value) { + ram[address & 0x1fffffu] = value; +} + +extern "C" void dirty_ram_mark_executable_range(uint32_t, uint32_t) {} + +static void check(bool value, const char* message) { + if (!value) { + std::cerr << "FAIL: " << message << "\n"; + failures++; + } +} + +static void write_text(const fs::path& path, const std::string& text) { + fs::create_directories(path.parent_path()); + std::ofstream out(path); + out << text; +} + +int main() { + const fs::path root = fs::temp_directory_path() / "psxrecomp-mod-runtime-test"; + std::error_code ec; + fs::remove_all(root, ec); + write_text(root / "packages/runtime.test/1.0.0/manifest.toml", + "format_version = 1\n" + "id = \"runtime.test\"\n" + "version = \"1.0.0\"\n" + "name = \"Runtime Test\"\n" + "[[target]]\n" + "game_id = \"SLUS-RUNTIME\"\n" + "[[patch]]\n" + "target = \"main_exe\"\n" + "address = 2147487744\n" + "expected = \"01020304\"\n" + "replace = \"a1a2a3a4\"\n" + "[[patch]]\n" + "target = \"disc_raw\"\n" + "offset = 4714\n" + "expected = \"aa\"\n" + "replace = \"bb\"\n"); + write_text(root / "state.toml", + "format_version = 1\n" + "[[package]]\n" + "id = \"runtime.test\"\n" + "enabled = true\n" + "version = \"1.0.0\"\n"); + + std::string error; + check(PSXRecompV4::mod_runtime_initialize( + root, "SLUS-RUNTIME", 0x80002000, {}, &error), + error.c_str()); + check(PSXRecompV4::mod_runtime_commit({}, &error), error.c_str()); + + ram[0x1000] = 1; ram[0x1001] = 2; ram[0x1002] = 3; ram[0x1003] = 4; + mod_runtime_on_dispatch(0x80001000); + check(ram[0x1000] == 1, "patch must wait for the configured entry point"); + mod_runtime_on_dispatch(0x80002000); + check(ram[0x1000] == 0xa1 && ram[0x1003] == 0xa4, + "main-EXE patch must apply before entry execution"); + + std::array sector{}; + sector[10] = 0xaa; + mod_runtime_patch_disc_sector(2, 1, sector.data(), (uint32_t)sector.size()); + check(sector[10] == 0xaa, "disc overlay must stay off during reference reads"); + mod_runtime_enable_disc_patches(); + mod_runtime_patch_disc_sector(2, 1, sector.data(), (uint32_t)sector.size()); + check(sector[10] == 0xbb, "raw disc overlay must patch matching sectors"); + + fs::remove_all(root, ec); + if (failures) return 1; + std::cout << "mod runtime tests passed\n"; + return 0; +} diff --git a/tools/psxmod_pack.py b/tools/psxmod_pack.py new file mode 100644 index 000000000..65f0eac51 --- /dev/null +++ b/tools/psxmod_pack.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Create a deterministic .psxmod ZIP from a package source directory.""" + +from __future__ import annotations + +import argparse +import zipfile +from pathlib import Path + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("source", type=Path) + parser.add_argument("output", type=Path) + args = parser.parse_args() + manifest = args.source / "manifest.toml" + if not manifest.is_file(): + parser.error("source must contain manifest.toml") + files = sorted(path for path in args.source.rglob("*") if path.is_file()) + args.output.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(args.output, "w", zipfile.ZIP_DEFLATED, + compresslevel=9) as archive: + for path in files: + info = zipfile.ZipInfo(path.relative_to(args.source).as_posix()) + info.date_time = (1980, 1, 1, 0, 0, 0) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o100644 << 16 + archive.writestr(info, path.read_bytes(), compresslevel=9) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 9ecf00f782139cefa83731b2fb878fe40ab035ef Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 22 Jul 2026 23:26:06 -0700 Subject: [PATCH 02/12] feat: derive modded discs from verified stock images --- docs/MOD_PACKAGES.md | 46 +++++- runtime/include/mod_packages.h | 20 +++ runtime/include/mod_runtime.h | 1 + runtime/runtime.cmake | 25 +++ runtime/src/main.cpp | 30 ++-- runtime/src/mod_packages.cpp | 86 ++++++++++- runtime/src/mod_runtime.cpp | 231 ++++++++++++++++++++++++++++ runtime/tests/test_mod_packages.cpp | 12 ++ 8 files changed, 437 insertions(+), 14 deletions(-) diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index 74da2b1c8..095bbc9d4 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -56,6 +56,18 @@ replace = "0e 00 02 24" when_option = "delay" when_value = "fast" order = 10 + +# Optional: use this for structural changes that cannot be represented as +# equal-size sector writes. Multiple entries may select different recipes from +# one launcher option, but exactly one may resolve in the complete mod plan. +[[derived_disc]] +kind = "vcdiff" +patch = "assets/fast.xdelta3" +patch_sha256 = "..." +output_size = 600000000 +output_sha256 = "..." +when_option = "delay" +when_value = "fast" ``` Option types are `boolean`, `choice`, and bounded `integer`. `when_option` / @@ -81,6 +93,33 @@ cache. Untouched functions stay on the static native path. This makes runtime cost proportional to the code actually changed, not to the number of possible option combinations. +## Derived discs + +A `derived_disc` is a data-only VCDIFF recipe whose source is the verified stock +disc from `[[target]].disc_sha256`. It is intended for mods that relocate files, +grow the ISO, replace large assets, or otherwise change disc geometry. + +The launcher continues to display and persist the user's stock BIN/CUE. Before +boot, the runtime: + +1. fingerprints that stock image and resolves package options; +2. verifies the package's VCDIFF payload; +3. invokes the release's trusted `xdelta3` binary (packages cannot provide an + executable); +4. verifies the derived size and SHA-256; and +5. atomically publishes and mounts + `mods/cache/.bin`. + +Changing package versions or options changes the plan fingerprint and therefore +the cache key. A cached result is reused on later launches. Ordinary guarded +sector overlays may be applied on top of the derived image, so a structural +base package can support many small composable add-ons. + +Release builders stage the trusted decoder with +`-DPSXRECOMP_XDELTA3_EXECUTABLE=/path/to/xdelta3`. More than one active +derived-disc provider is rejected; packages should use dependencies and +conflicts to make ownership explicit. + ## Resolution rules - Installed versions are side-by-side. The launcher can select an older version @@ -89,9 +128,10 @@ option combinations. package/patch order. - Missing dependencies, version mismatches, declared conflicts, dependency cycles, overlapping writes, unavailable trusted resolvers, invalid option - values, and target mismatches prevent launch. -- The resolved package versions, option values, and writes produce a canonical - SHA-256 plan fingerprint suitable for diagnostics and multiplayer agreement. + values, multiple derived-disc providers, and target mismatches prevent launch. +- The resolved package versions, option values, writes, and derived-disc recipe + produce a canonical SHA-256 plan fingerprint suitable for diagnostics and + multiplayer agreement. - Package and state changes apply on the next launch. There is no mid-frame mutation. diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index 31958948b..3d5b70774 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -60,6 +60,16 @@ struct ModPatch { int64_t order = 0; }; +struct ModDerivedDisc { + std::string kind = "vcdiff"; + std::filesystem::path patch; + std::string patch_sha256; + uint64_t output_size = 0; + std::string output_sha256; + std::string when_option; + std::string when_value; +}; + struct ModPackage { uint32_t format_version = 0; std::string id; @@ -76,6 +86,7 @@ struct ModPackage { std::vector conflicts; std::vector options; std::vector patches; + std::vector derived_discs; }; struct ModSelection { @@ -96,6 +107,15 @@ struct ModResolution { std::string package_id; }; std::vector writes; + struct DerivedDisc { + std::string kind; + std::filesystem::path patch; + std::string patch_sha256; + uint64_t output_size = 0; + std::string output_sha256; + std::string package_id; + }; + std::vector derived_discs; std::vector errors; }; diff --git a/runtime/include/mod_runtime.h b/runtime/include/mod_runtime.h index c97afed28..cd3aa6d33 100644 --- a/runtime/include/mod_runtime.h +++ b/runtime/include/mod_runtime.h @@ -19,6 +19,7 @@ bool mod_runtime_initialize(const std::filesystem::path& root, bool mod_runtime_commit(const std::filesystem::path& disc_path = {}, std::string* error = nullptr); const std::string& mod_runtime_fingerprint(); +const std::filesystem::path& mod_runtime_effective_disc_path(); #if defined(RECOMP_LAUNCHER) const ::RecompLauncherCModProvider* mod_runtime_launcher_provider(); diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 122aecdb6..e8895f669 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -674,6 +674,31 @@ function(psxrecomp_add_runtime_target target) $<$:/SUBSYSTEM:WINDOWS> $<$:/ENTRY:mainCRTStartup>) endif() + + # Packages may contain data-only VCDIFF recipes for deriving a private, + # fingerprinted runtime image from the user's verified stock disc. The + # decoder is supplied by the release builder and invoked only from this + # fixed path; packages cannot provide or execute binaries. + set(PSXRECOMP_XDELTA3_EXECUTABLE "" CACHE FILEPATH + "Trusted xdelta3 executable copied beside runtime targets") + if(PSXRECOMP_XDELTA3_EXECUTABLE) + if(NOT EXISTS "${PSXRECOMP_XDELTA3_EXECUTABLE}") + message(FATAL_ERROR + "PSXRECOMP_XDELTA3_EXECUTABLE does not exist: " + "${PSXRECOMP_XDELTA3_EXECUTABLE}") + endif() + if(WIN32) + set(_psxmod_xdelta_name "xdelta3.exe") + else() + set(_psxmod_xdelta_name "xdelta3") + endif() + add_custom_command(TARGET ${target} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${PSXRECOMP_XDELTA3_EXECUTABLE}" + "$/${_psxmod_xdelta_name}" + COMMENT "Staging trusted xdelta3 decoder for derived-disc mods" + VERBATIM) + endif() endfunction() # Compatibility for early v4 game projects that used the longer helper name. diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index a6a0de3d8..2a1261c9a 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -5409,6 +5409,18 @@ int main(int argc, char** argv) { } #endif + /* Resolve the actual stock image before mod resolution. In particular, + * --disc is a late CLI override and must participate in target hashing; + * the launcher already stores its imported stock path in resolved_disc. */ + if (game_config_path || disc_override_path || !resolved_disc.empty()) { + resolved_disc = + resolve_disc_for_runtime(resolved_disc, disc_override_path, game_id, argv[0]); + if (game_config_path && resolved_disc.empty()) { + std::fprintf(stderr, "psxrecomp: no disc image selected; exiting.\n"); + return 1; + } + } + { std::string mod_error; if (!PSXRecompV4::mod_runtime_commit(resolved_disc, &mod_error)) { @@ -5443,19 +5455,19 @@ int main(int argc, char** argv) { std::fprintf(stderr, "psxrecomp: no BIOS selected; exiting.\n"); return 1; } - if (game_config_path || disc_override_path || !resolved_disc.empty()) { - resolved_disc = resolve_disc_for_runtime(resolved_disc, disc_override_path, game_id, argv[0]); - if (game_config_path && resolved_disc.empty()) { - std::fprintf(stderr, "psxrecomp: no disc image selected; exiting.\n"); - return 1; - } - } - /* memcard_dir was resolved to its default before the launcher (above). */ std::string bios_path_str = resolved_bios.string(); std::string memcard_dir_str = memcard_dir.string(); - std::string disc_path_str = resolved_disc.string(); + const std::filesystem::path& mod_disc = + PSXRecompV4::mod_runtime_effective_disc_path(); + std::string disc_path_str = + (mod_disc.empty() ? resolved_disc : mod_disc).string(); + if (!mod_disc.empty()) { + std::fprintf(stdout, + "psxrecomp: stock disc remains %s; mounting private mod cache %s\n", + resolved_disc.string().c_str(), mod_disc.string().c_str()); + } session_reboot: /* Rematch after lobby soft-return re-enters here with updated net_cfg. */ diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 996c4007e..f992a7a28 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -42,6 +42,13 @@ bool valid_id(const std::string& value) { return value.front() != '.' && value.back() != '.'; } +bool valid_sha256(const std::string& value) { + return value.size() == 64 && + std::all_of(value.begin(), value.end(), [](unsigned char c) { + return std::isdigit(c) || (c >= 'a' && c <= 'f'); + }); +} + bool parse_hex_bytes(const std::string& text, std::vector& out) { std::string compact; compact.reserve(text.size()); @@ -561,8 +568,11 @@ bool target_matches(const ModPackage& package, const std::string& game, std::string canonical_resolution(const std::vector& ordered, const std::map& selections, - const std::vector& writes) { + const std::vector& writes, + const std::vector& derived_discs, + const std::string& source_disc_sha256) { std::ostringstream out; + out << "source_disc=" << source_disc_sha256 << '\n'; for (const ModPackage* package : ordered) { out << package->id << '@' << package->version << '\n'; const auto sit = selections.find(package->id); @@ -577,6 +587,11 @@ std::string canonical_resolution(const std::vector& ordered, << hex_bytes(write.expected) << '>' << hex_bytes(write.replacement) << ':' << write.package_id << '\n'; } + for (const ModResolution::DerivedDisc& derived : derived_discs) { + out << "derived_disc:" << derived.kind << ':' + << derived.patch_sha256 << ':' << derived.output_size << ':' + << derived.output_sha256 << ':' << derived.package_id << '\n'; + } return out.str(); } @@ -786,6 +801,47 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, ++declaration_index; } } + if (cfg.contains("derived_disc")) { + for (const toml::value& v : toml::find(cfg, "derived_disc").as_array()) { + ModDerivedDisc derived; + derived.kind = toml::find_or(v, "kind", "vcdiff"); + const std::string relative_patch = toml::find(v, "patch"); + derived.patch_sha256 = toml::find(v, "patch_sha256"); + const int64_t output_size = toml::find(v, "output_size"); + derived.output_sha256 = toml::find(v, "output_sha256"); + derived.when_option = + v.contains("when_option") ? toml::find(v, "when_option") : ""; + derived.when_value = + v.contains("when_value") ? toml::find(v, "when_value") : ""; + if (derived.kind != "vcdiff") + throw std::runtime_error("derived_disc kind must be vcdiff"); + if (!safe_archive_name(relative_patch)) + throw std::runtime_error("derived_disc patch path is unsafe"); + if (!valid_sha256(derived.patch_sha256) || + !valid_sha256(derived.output_sha256)) + throw std::runtime_error( + "derived_disc hashes must be lowercase SHA-256"); + if (output_size <= 0) + throw std::runtime_error("derived_disc output_size must be positive"); + derived.output_size = (uint64_t)output_size; + if (derived.when_option.empty() != derived.when_value.empty()) + throw std::runtime_error( + "derived_disc condition requires both when_option and when_value"); + if (!derived.when_option.empty()) { + const auto option = std::find_if( + out.options.begin(), out.options.end(), + [&](const ModOption& item) { return item.id == derived.when_option; }); + if (option == out.options.end()) + throw std::runtime_error( + "derived_disc references unknown option"); + } + derived.patch = out.root / fs::path(relative_patch); + if (!fs::is_regular_file(derived.patch)) + throw std::runtime_error( + "derived_disc patch asset is missing: " + relative_patch); + out.derived_discs.push_back(std::move(derived)); + } + } return true; } catch (const std::exception& ex) { set_error(error, path.string() + ": " + ex.what()); @@ -1168,6 +1224,20 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, const ModSelection& selected = selected_it == selections_.end() ? blank : selected_it->second; if (package->resolver == "declarative") { + for (const ModDerivedDisc& derived : package->derived_discs) { + if (!derived.when_option.empty() && + effective_option_value(*package, selected, derived.when_option) != + derived.when_value) + continue; + ModResolution::DerivedDisc resolved; + resolved.kind = derived.kind; + resolved.patch = derived.patch; + resolved.patch_sha256 = derived.patch_sha256; + resolved.output_size = derived.output_size; + resolved.output_sha256 = derived.output_sha256; + resolved.package_id = package->id; + result.derived_discs.push_back(std::move(resolved)); + } std::vector patches; patches.reserve(package->patches.size()); for (const ModPatch& patch : package->patches) { @@ -1197,6 +1267,15 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, result.errors.push_back(package->id + ": built-in resolver failed"); } } + if (result.derived_discs.size() > 1) { + std::string providers; + for (const auto& derived : result.derived_discs) { + if (!providers.empty()) providers += ", "; + providers += derived.package_id; + } + result.errors.push_back( + "more than one derived-disc provider is active: " + providers); + } for (size_t i = 0; i < result.writes.size(); ++i) { const ModResolution::Write& write = result.writes[i]; if (write.expected.empty() || @@ -1216,10 +1295,13 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, if (!result.errors.empty()) { result.ordered.clear(); result.writes.clear(); + result.derived_discs.clear(); return result; } result.fingerprint = fingerprint_text( - canonical_resolution(result.ordered, selections_, result.writes)); + canonical_resolution( + result.ordered, selections_, result.writes, result.derived_discs, + disc_sha256)); result.ok = true; return result; } diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index d52fef2e0..f819e24b1 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -18,6 +19,17 @@ #include #include #include +#include +#include + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#include +#else +#include +#include +#include +#endif extern "C" uint8_t psx_read_byte(uint32_t addr); extern "C" void psx_write_byte(uint32_t addr, uint8_t value); @@ -34,6 +46,7 @@ struct RuntimeMods { std::string exe_sha256; std::string disc_sha256; std::filesystem::path disc_path; + std::filesystem::path effective_disc_path; uint32_t entry_phys = 0; bool initialized = false; bool main_applied = false; @@ -137,6 +150,213 @@ bool sha256_file(const std::filesystem::path& path, std::string& out, return true; } +std::filesystem::path raw_image_path(const std::filesystem::path& path, + std::string* error) { + std::string extension = path.extension().string(); + std::transform(extension.begin(), extension.end(), extension.begin(), + [](unsigned char c) { return (char)std::tolower(c); }); + if (extension != ".cue") return path; + std::ifstream cue(path); + if (!cue) { + if (error) *error = "cannot open CUE for derived disc: " + path.string(); + return {}; + } + std::string line; + while (std::getline(cue, line)) { + size_t at = line.find_first_not_of(" \t"); + if (at == std::string::npos || line.size() - at < 4) continue; + std::string keyword = line.substr(at, 4); + std::transform(keyword.begin(), keyword.end(), keyword.begin(), + [](unsigned char c) { return (char)std::toupper(c); }); + if (keyword != "FILE") continue; + at = line.find_first_not_of(" \t", at + 4); + if (at == std::string::npos) continue; + std::string name; + if (line[at] == '"') { + const size_t end = line.find('"', at + 1); + if (end == std::string::npos) continue; + name = line.substr(at + 1, end - at - 1); + } else { + const size_t end = line.find_first_of(" \t", at); + name = line.substr(at, end - at); + } + return (path.parent_path() / name).lexically_normal(); + } + if (error) *error = "CUE has no source file for derived disc: " + path.string(); + return {}; +} + +#if defined(_WIN32) +std::wstring quote_windows_argument(const std::wstring& value) { + if (value.find_first_of(L" \t\n\v\"") == std::wstring::npos) return value; + std::wstring out = L"\""; + size_t slashes = 0; + for (wchar_t c : value) { + if (c == L'\\') { + ++slashes; + } else if (c == L'"') { + out.append(slashes * 2 + 1, L'\\'); + out.push_back(L'"'); + slashes = 0; + } else { + out.append(slashes, L'\\'); + slashes = 0; + out.push_back(c); + } + } + out.append(slashes * 2, L'\\'); + out.push_back(L'"'); + return out; +} +#endif + +bool run_xdelta_decode(const std::filesystem::path& executable, + const std::filesystem::path& source, + const std::filesystem::path& patch, + const std::filesystem::path& output, + std::string* error) { +#if defined(_WIN32) + std::wstring command = + quote_windows_argument(executable.wstring()) + L" -f -n -d -s " + + quote_windows_argument(source.wstring()) + L" " + + quote_windows_argument(patch.wstring()) + L" " + + quote_windows_argument(output.wstring()); + std::vector mutable_command(command.begin(), command.end()); + mutable_command.push_back(L'\0'); + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION process{}; + if (!CreateProcessW(executable.wstring().c_str(), mutable_command.data(), + nullptr, nullptr, FALSE, CREATE_NO_WINDOW, nullptr, nullptr, + &startup, &process)) { + if (error) *error = "cannot start trusted xdelta3 decoder (Windows error " + + std::to_string((unsigned long)GetLastError()) + ")"; + return false; + } + WaitForSingleObject(process.hProcess, INFINITE); + DWORD exit_code = 1; + GetExitCodeProcess(process.hProcess, &exit_code); + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + if (exit_code != 0) { + if (error) *error = + "trusted xdelta3 decoder failed with exit code " + std::to_string(exit_code); + return false; + } + return true; +#else + const pid_t child = fork(); + if (child == 0) { + execl(executable.c_str(), executable.c_str(), "-f", "-n", "-d", "-s", + source.c_str(), patch.c_str(), output.c_str(), (char*)nullptr); + _exit(127); + } + if (child < 0) { + if (error) *error = "cannot start trusted xdelta3 decoder"; + return false; + } + int status = 0; + if (waitpid(child, &status, 0) < 0 || !WIFEXITED(status) || + WEXITSTATUS(status) != 0) { + if (error) *error = "trusted xdelta3 decoder failed"; + return false; + } + return true; +#endif +} + +bool valid_cached_disc(const std::filesystem::path& path, + const ModResolution::DerivedDisc& derived) { + std::error_code ec; + return std::filesystem::is_regular_file(path, ec) && + std::filesystem::file_size(path, ec) == derived.output_size && !ec; +} + +bool materialize_derived_disc(RuntimeMods& s, const ModResolution& plan, + std::filesystem::path& out, std::string* error) { + out.clear(); + if (plan.derived_discs.empty()) return true; + const ModResolution::DerivedDisc& derived = plan.derived_discs.front(); + std::string digest; + if (!sha256_file(derived.patch, digest, error) || + digest != derived.patch_sha256) { + if (error && error->empty()) + *error = derived.package_id + ": derived-disc patch checksum failed"; + else if (error && digest != derived.patch_sha256) + *error = derived.package_id + ": derived-disc patch checksum failed"; + return false; + } + const std::filesystem::path cache_root = s.manager.root() / "cache"; + const std::filesystem::path cached = cache_root / (plan.fingerprint + ".bin"); + if (valid_cached_disc(cached, derived)) { + out = cached; + return true; + } + std::error_code ec; + std::filesystem::create_directories(cache_root, ec); + if (ec) { + if (error) *error = "cannot create derived-disc cache: " + ec.message(); + return false; + } + const char* override_tool = std::getenv("PSXRECOMP_XDELTA3"); + const std::filesystem::path decoder = + override_tool && override_tool[0] + ? std::filesystem::path(override_tool) +#if defined(_WIN32) + : s.manager.root().parent_path() / "xdelta3.exe"; +#else + : s.manager.root().parent_path() / "xdelta3"; +#endif + if (!std::filesystem::is_regular_file(decoder, ec)) { + if (error) *error = + "this mod needs the trusted xdelta3 decoder, but it is missing: " + + decoder.string(); + return false; + } + const std::filesystem::path source = raw_image_path(s.disc_path, error); + if (source.empty()) return false; +#if defined(_WIN32) + const unsigned long process_id = GetCurrentProcessId(); +#else + const unsigned long process_id = (unsigned long)getpid(); +#endif + const std::filesystem::path temporary = + cache_root / (plan.fingerprint + ".tmp." + std::to_string(process_id)); + std::filesystem::remove(temporary, ec); + std::fprintf(stdout, "psxrecomp: building derived disc for %s...\n", + derived.package_id.c_str()); + if (!run_xdelta_decode(decoder, source, derived.patch, temporary, error)) { + std::filesystem::remove(temporary, ec); + return false; + } + if (!valid_cached_disc(temporary, derived)) { + std::filesystem::remove(temporary, ec); + if (error) *error = derived.package_id + + ": derived disc has the wrong output size"; + return false; + } + if (!sha256_file(temporary, digest, error) || digest != derived.output_sha256) { + std::filesystem::remove(temporary, ec); + if (error && digest != derived.output_sha256) + *error = derived.package_id + ": derived disc checksum failed"; + return false; + } + std::filesystem::rename(temporary, cached, ec); + if (ec) { + std::filesystem::remove(cached, ec); + ec.clear(); + std::filesystem::rename(temporary, cached, ec); + } + if (ec) { + std::filesystem::remove(temporary, ec); + if (error) *error = "cannot publish derived-disc cache: " + ec.message(); + return false; + } + std::fprintf(stdout, "psxrecomp: cached derived disc %s\n", cached.string().c_str()); + out = cached; + return true; +} + #if defined(RECOMP_LAUNCHER) void copy_text(char* out, size_t capacity, const std::string& value) { if (!out || capacity == 0) return; @@ -328,6 +548,7 @@ bool mod_runtime_initialize(const std::filesystem::path& root, s.exe_sha256.clear(); s.disc_sha256.clear(); s.disc_path.clear(); + s.effective_disc_path.clear(); s.entry_phys = 0; s.initialized = false; s.main_applied = false; @@ -371,11 +592,17 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err if (error) *error = s.error; return false; } + std::filesystem::path effective_disc; + if (!materialize_derived_disc(s, plan, effective_disc, &s.error)) { + if (error) *error = s.error; + return false; + } if (!s.manager.save_state(&s.error)) { if (error) *error = s.error; return false; } s.plan = std::move(plan); + s.effective_disc_path = std::move(effective_disc); s.main_applied = false; s.error.clear(); return true; @@ -385,6 +612,10 @@ const std::string& mod_runtime_fingerprint() { return state().plan.fingerprint; } +const std::filesystem::path& mod_runtime_effective_disc_path() { + return state().effective_disc_path; +} + #if defined(RECOMP_LAUNCHER) const RecompLauncherCModProvider* mod_runtime_launcher_provider() { return &provider; diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp index df0d556ed..9d9b5901c 100644 --- a/runtime/tests/test_mod_packages.cpp +++ b/runtime/tests/test_mod_packages.cpp @@ -107,7 +107,16 @@ int main() { "expected = \"01 02 03 04\"\n" "replace = \"05 06 07 08\"\n" "when_option = \"difficulty\"\n" + "when_value = \"hard\"\n" + "[[derived_disc]]\n" + "kind = \"vcdiff\"\n" + "patch = \"assets/base.xdelta3\"\n" + "patch_sha256 = \"0000000000000000000000000000000000000000000000000000000000000000\"\n" + "output_size = 123456\n" + "output_sha256 = \"1111111111111111111111111111111111111111111111111111111111111111\"\n" + "when_option = \"difficulty\"\n" "when_value = \"hard\"\n")); + write_text(root / "packages/base.mod/1.0.0/assets/base.xdelta3", "test"); write_text(root / "packages/addon.mod/2.0.0/manifest.toml", manifest("addon.mod", "2.0.0", "\n[[dependency]]\nid = \"base.mod\"\nversion = \"^1.0.0\"\n")); @@ -148,6 +157,9 @@ int main() { resolved.writes[0].location == 0x80001000ull && resolved.writes[0].replacement[0] == 5, "resolved write must retain guest address and bytes"); + check(resolved.derived_discs.size() == 1 && + resolved.derived_discs[0].output_size == 123456, + "selected derived-disc recipe must resolve"); check(resolved.fingerprint.size() == 64, "plan fingerprint must be SHA-256 hex"); const std::string fingerprint = resolved.fingerprint; From 03a7fe10ba294842f2a51d1daa7ed51407b30b0a Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 23 Jul 2026 00:06:17 -0700 Subject: [PATCH 03/12] fix: auto-disable conflicting mod packages --- runtime/src/mod_packages.cpp | 23 ++++++++++++++++++++++- runtime/tests/test_mod_packages.cpp | 18 ++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index f992a7a28..852e351c8 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -1062,11 +1062,32 @@ bool ModPackageManager::remove_version(const std::string& id, const std::string& } bool ModPackageManager::set_enabled(const std::string& id, bool enabled, std::string* error) { - if (packages_.find(id) == packages_.end()) { + const auto pit = packages_.find(id); + if (pit == packages_.end()) { set_error(error, "package is not installed"); return false; } selections_[id].enabled = enabled; + if (!enabled) return true; + + const ModPackage* package = find_selected(packages_, id, selections_[id]); + if (!package) return true; + + auto conflicts_with_enabled = [&](const std::string& other_id, + const ModPackage& other) { + const auto declared = std::find(package->conflicts.begin(), + package->conflicts.end(), other_id); + if (declared != package->conflicts.end()) return true; + const auto reverse = std::find(other.conflicts.begin(), other.conflicts.end(), id); + return reverse != other.conflicts.end(); + }; + + for (auto& [other_id, selection] : selections_) { + if (other_id == id || !selection.enabled) continue; + const ModPackage* other = find_selected(packages_, other_id, selection); + if (other && conflicts_with_enabled(other_id, *other)) + selection.enabled = false; + } return true; } diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp index 9d9b5901c..aa9ff0d23 100644 --- a/runtime/tests/test_mod_packages.cpp +++ b/runtime/tests/test_mod_packages.cpp @@ -177,6 +177,24 @@ int main() { check(reload.set_enabled("addon.mod", false, &error), error.c_str()); check(reload.remove_version("base.mod", "1.0.0", &error), error.c_str()); + write_text(root / "packages/conflict.a/1.0.0/manifest.toml", + "format_version = 1\n" + "id = \"conflict.a\"\n" + "version = \"1.0.0\"\n" + "name = \"conflict.a\"\n" + "resolver = \"declarative\"\n" + "conflicts = [\"conflict.b\"]\n" + "[[target]]\n" + "game_id = \"SLUS-TEST\"\n"); + write_text(root / "packages/conflict.b/1.0.0/manifest.toml", + manifest("conflict.b", "1.0.0")); + check(reload.scan(&error), error.c_str()); + check(reload.set_enabled("conflict.a", true, &error), error.c_str()); + check(reload.set_enabled("conflict.b", true, &error), error.c_str()); + check(!reload.selections().at("conflict.a").enabled && + reload.selections().at("conflict.b").enabled, + "enabling a conflicting package must disable the previous package"); + ModPackage invalid; write_text(root / "bad.toml", "format_version=1\nid=\"../bad\"\nversion=\"1.0.0\"\nname=\"Bad\"\n" From e1e4f2b367aaffd647fd307edf736717f7acc4c4 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 23 Jul 2026 00:36:18 -0700 Subject: [PATCH 04/12] feat: support multi-option mod conditions --- docs/MOD_PACKAGES.md | 23 ++++++-- runtime/include/mod_packages.h | 2 + runtime/src/mod_packages.cpp | 82 +++++++++++++++++------------ runtime/tests/test_mod_packages.cpp | 42 +++++++++++++++ 4 files changed, 112 insertions(+), 37 deletions(-) diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index 095bbc9d4..da3bd0dd5 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -68,10 +68,22 @@ output_size = 600000000 output_sha256 = "..." when_option = "delay" when_value = "fast" + +# For option matrices, use a condition table. All listed option values must +# match. `when_option` / `when_value` remain supported for single-option cases. +[[derived_disc]] +kind = "vcdiff" +patch = "assets/fast-rockman.xdelta3" +patch_sha256 = "..." +output_size = 600000000 +output_sha256 = "..." +when = { delay = "fast", title_screen = "rockman_japan" } ``` Option types are `boolean`, `choice`, and bounded `integer`. `when_option` / -`when_value` are optional; an unconditional patch omits both. +`when_value` are optional; `when = { option = "value", ... }` can be used when a +patch or derived-disc recipe depends on multiple option values. An unconditional +patch omits both condition forms. ## Patch targets @@ -97,7 +109,9 @@ option combinations. A `derived_disc` is a data-only VCDIFF recipe whose source is the verified stock disc from `[[target]].disc_sha256`. It is intended for mods that relocate files, -grow the ISO, replace large assets, or otherwise change disc geometry. +grow the ISO, replace large assets, or otherwise change disc geometry. A package +may contain a matrix of conditional recipes for its own options, but exactly one +recipe may resolve in the complete mod plan. The launcher continues to display and persist the user's stock BIN/CUE. Before boot, the runtime: @@ -117,8 +131,9 @@ base package can support many small composable add-ons. Release builders stage the trusted decoder with `-DPSXRECOMP_XDELTA3_EXECUTABLE=/path/to/xdelta3`. More than one active -derived-disc provider is rejected; packages should use dependencies and -conflicts to make ownership explicit. +derived-disc recipe is rejected after option resolution; packages should use a +single owner package for structural transforms and dependencies/conflicts for +external ownership. ## Resolution rules diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index 3d5b70774..b9b5facf7 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -57,6 +57,7 @@ struct ModPatch { std::vector replacement; std::string when_option; std::string when_value; + std::map when; int64_t order = 0; }; @@ -68,6 +69,7 @@ struct ModDerivedDisc { std::string output_sha256; std::string when_option; std::string when_value; + std::map when; }; struct ModPackage { diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 852e351c8..2c86e8262 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -605,6 +605,45 @@ std::string effective_option_value(const ModPackage& package, return option == package.options.end() ? std::string() : option->default_value; } +bool conditions_match(const ModPackage& package, const ModSelection& selection, + const std::map& conditions) { + for (const auto& [id, value] : conditions) { + if (effective_option_value(package, selection, id) != value) + return false; + } + return true; +} + +void read_conditions(const toml::value& value, const std::vector& options, + std::map& when, + const char* label) { + const std::string when_option = + value.contains("when_option") ? toml::find(value, "when_option") : ""; + const std::string when_value = + value.contains("when_value") ? toml::find(value, "when_value") : ""; + if (when_option.empty() != when_value.empty()) + throw std::runtime_error( + std::string(label) + " condition requires both when_option and when_value"); + if (!when_option.empty()) when[when_option] = when_value; + if (value.contains("when")) { + const auto table = toml::find>(value, "when"); + for (const auto& [id, condition_value] : table) { + if (when.find(id) != when.end() && when[id] != condition_value) + throw std::runtime_error( + std::string(label) + " has conflicting conditions for " + id); + when[id] = condition_value; + } + } + for (const auto& [id, condition_value] : when) { + (void)condition_value; + const auto option = std::find_if( + options.begin(), options.end(), + [&](const ModOption& item) { return item.id == id; }); + if (option == options.end()) + throw std::runtime_error(std::string(label) + " references unknown option"); + } +} + bool writes_overlap(const ModResolution::Write& a, const ModResolution::Write& b) { if (a.target != b.target) return false; const uint64_t a_end = a.location + a.replacement.size(); @@ -781,21 +820,12 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, patch.location % sector_size + patch.replacement.size() > sector_size) throw std::runtime_error( "disc patch may not cross a sector boundary"); - patch.when_option = - v.contains("when_option") ? toml::find(v, "when_option") : ""; - patch.when_value = - v.contains("when_value") ? toml::find(v, "when_value") : ""; patch.order = toml::find_or( v, "order", (int64_t)declaration_index); - if (patch.when_option.empty() != patch.when_value.empty()) - throw std::runtime_error( - "patch condition requires both when_option and when_value"); - if (!patch.when_option.empty()) { - const auto option = std::find_if( - out.options.begin(), out.options.end(), - [&](const ModOption& item) { return item.id == patch.when_option; }); - if (option == out.options.end()) - throw std::runtime_error("patch references unknown option"); + read_conditions(v, out.options, patch.when, "patch"); + if (!patch.when.empty()) { + patch.when_option = patch.when.begin()->first; + patch.when_value = patch.when.begin()->second; } out.patches.push_back(std::move(patch)); ++declaration_index; @@ -809,10 +839,6 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, derived.patch_sha256 = toml::find(v, "patch_sha256"); const int64_t output_size = toml::find(v, "output_size"); derived.output_sha256 = toml::find(v, "output_sha256"); - derived.when_option = - v.contains("when_option") ? toml::find(v, "when_option") : ""; - derived.when_value = - v.contains("when_value") ? toml::find(v, "when_value") : ""; if (derived.kind != "vcdiff") throw std::runtime_error("derived_disc kind must be vcdiff"); if (!safe_archive_name(relative_patch)) @@ -824,16 +850,10 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, if (output_size <= 0) throw std::runtime_error("derived_disc output_size must be positive"); derived.output_size = (uint64_t)output_size; - if (derived.when_option.empty() != derived.when_value.empty()) - throw std::runtime_error( - "derived_disc condition requires both when_option and when_value"); - if (!derived.when_option.empty()) { - const auto option = std::find_if( - out.options.begin(), out.options.end(), - [&](const ModOption& item) { return item.id == derived.when_option; }); - if (option == out.options.end()) - throw std::runtime_error( - "derived_disc references unknown option"); + read_conditions(v, out.options, derived.when, "derived_disc"); + if (!derived.when.empty()) { + derived.when_option = derived.when.begin()->first; + derived.when_value = derived.when.begin()->second; } derived.patch = out.root / fs::path(relative_patch); if (!fs::is_regular_file(derived.patch)) @@ -1246,9 +1266,7 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, selected_it == selections_.end() ? blank : selected_it->second; if (package->resolver == "declarative") { for (const ModDerivedDisc& derived : package->derived_discs) { - if (!derived.when_option.empty() && - effective_option_value(*package, selected, derived.when_option) != - derived.when_value) + if (!conditions_match(*package, selected, derived.when)) continue; ModResolution::DerivedDisc resolved; resolved.kind = derived.kind; @@ -1262,9 +1280,7 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, std::vector patches; patches.reserve(package->patches.size()); for (const ModPatch& patch : package->patches) { - if (!patch.when_option.empty() && - effective_option_value(*package, selected, patch.when_option) != - patch.when_value) + if (!conditions_match(*package, selected, patch.when)) continue; patches.push_back(&patch); } diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp index aa9ff0d23..4c80a16cf 100644 --- a/runtime/tests/test_mod_packages.cpp +++ b/runtime/tests/test_mod_packages.cpp @@ -195,6 +195,48 @@ int main() { reload.selections().at("conflict.b").enabled, "enabling a conflicting package must disable the previous package"); + write_text(root / "packages/matrix.mod/1.0.0/manifest.toml", + manifest("matrix.mod", "1.0.0", + "\n[[option]]\n" + "id = \"title\"\n" + "label = \"Title\"\n" + "type = \"choice\"\n" + "default = \"mega\"\n" + "[[option.choice]]\n" + "value = \"mega\"\n" + "label = \"Mega\"\n" + "[[option.choice]]\n" + "value = \"rockman\"\n" + "label = \"Rockman\"\n" + "\n[[option]]\n" + "id = \"script\"\n" + "label = \"Script\"\n" + "type = \"choice\"\n" + "default = \"original\"\n" + "[[option.choice]]\n" + "value = \"original\"\n" + "label = \"Original\"\n" + "[[option.choice]]\n" + "value = \"retranslation\"\n" + "label = \"Retranslation\"\n" + "\n[[derived_disc]]\n" + "kind = \"vcdiff\"\n" + "patch = \"assets/matrix.xdelta3\"\n" + "patch_sha256 = \"2222222222222222222222222222222222222222222222222222222222222222\"\n" + "output_size = 222222\n" + "output_sha256 = \"3333333333333333333333333333333333333333333333333333333333333333\"\n" + "when = { title = \"rockman\", script = \"retranslation\" }\n")); + write_text(root / "packages/matrix.mod/1.0.0/assets/matrix.xdelta3", "test"); + check(reload.scan(&error), error.c_str()); + check(reload.set_enabled("conflict.b", false, &error), error.c_str()); + check(reload.set_enabled("matrix.mod", true, &error), error.c_str()); + check(reload.set_option("matrix.mod", "title", "rockman", &error), error.c_str()); + check(reload.set_option("matrix.mod", "script", "retranslation", &error), error.c_str()); + ModResolution matrix = reload.resolve("SLUS-TEST"); + check(matrix.ok && matrix.derived_discs.size() == 1 && + matrix.derived_discs[0].output_size == 222222, + "multi-option derived-disc condition must match selected values"); + ModPackage invalid; write_text(root / "bad.toml", "format_version=1\nid=\"../bad\"\nversion=\"1.0.0\"\nname=\"Bad\"\n" From 7e27a01bc49a0274fe2820709a084676f7be3a6d Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 23 Jul 2026 08:39:32 -0700 Subject: [PATCH 05/12] fix: preserve independent mod toggles --- docs/MOD_PACKAGES.md | 8 ++++++++ runtime/src/mod_packages.cpp | 20 -------------------- runtime/tests/test_mod_packages.cpp | 7 +++++-- 3 files changed, 13 insertions(+), 22 deletions(-) diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index da3bd0dd5..f65268b8f 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -135,10 +135,18 @@ derived-disc recipe is rejected after option resolution; packages should use a single owner package for structural transforms and dependencies/conflicts for external ownership. +Do not split one structural option system into many mutually exclusive +full-disc packages. For example, a game-wide Tweaks system should be one package +with launcher options and a conditional recipe matrix. Choices that are +mutually exclusive by design belong inside that package as option values, while +unrelated mods should remain separate packages and compose normally. + ## Resolution rules - Installed versions are side-by-side. The launcher can select an older version to roll back. +- Enabling or disabling one package changes only that package. It does not + silently toggle other packages. - Enabled packages are topologically ordered by dependencies, then by stable package/patch order. - Missing dependencies, version mismatches, declared conflicts, dependency diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 2c86e8262..50bf59e7e 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -1088,26 +1088,6 @@ bool ModPackageManager::set_enabled(const std::string& id, bool enabled, std::st return false; } selections_[id].enabled = enabled; - if (!enabled) return true; - - const ModPackage* package = find_selected(packages_, id, selections_[id]); - if (!package) return true; - - auto conflicts_with_enabled = [&](const std::string& other_id, - const ModPackage& other) { - const auto declared = std::find(package->conflicts.begin(), - package->conflicts.end(), other_id); - if (declared != package->conflicts.end()) return true; - const auto reverse = std::find(other.conflicts.begin(), other.conflicts.end(), id); - return reverse != other.conflicts.end(); - }; - - for (auto& [other_id, selection] : selections_) { - if (other_id == id || !selection.enabled) continue; - const ModPackage* other = find_selected(packages_, other_id, selection); - if (other && conflicts_with_enabled(other_id, *other)) - selection.enabled = false; - } return true; } diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp index 4c80a16cf..196806834 100644 --- a/runtime/tests/test_mod_packages.cpp +++ b/runtime/tests/test_mod_packages.cpp @@ -191,9 +191,11 @@ int main() { check(reload.scan(&error), error.c_str()); check(reload.set_enabled("conflict.a", true, &error), error.c_str()); check(reload.set_enabled("conflict.b", true, &error), error.c_str()); - check(!reload.selections().at("conflict.a").enabled && + check(reload.selections().at("conflict.a").enabled && reload.selections().at("conflict.b").enabled, - "enabling a conflicting package must disable the previous package"); + "enabling a package must not silently disable another package"); + check(!reload.resolve("SLUS-TEST").ok, + "declared conflicts must fail resolution"); write_text(root / "packages/matrix.mod/1.0.0/manifest.toml", manifest("matrix.mod", "1.0.0", @@ -228,6 +230,7 @@ int main() { "when = { title = \"rockman\", script = \"retranslation\" }\n")); write_text(root / "packages/matrix.mod/1.0.0/assets/matrix.xdelta3", "test"); check(reload.scan(&error), error.c_str()); + check(reload.set_enabled("conflict.a", false, &error), error.c_str()); check(reload.set_enabled("conflict.b", false, &error), error.c_str()); check(reload.set_enabled("matrix.mod", true, &error), error.c_str()); check(reload.set_option("matrix.mod", "title", "rockman", &error), error.c_str()); From ff4827163e70d3e23eacdd5ee3e25bca3ef1ef56 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 23 Jul 2026 09:27:25 -0700 Subject: [PATCH 06/12] feat: add feature-oriented mod resolution --- docs/MOD_PACKAGES.md | 284 ++++++----- runtime/include/mod_packages.h | 68 +++ runtime/src/mod_packages.cpp | 719 ++++++++++++++++++++++++---- runtime/src/mod_runtime.cpp | 413 +++++++++++++++- runtime/tests/test_mod_packages.cpp | 192 ++++++++ runtime/tests/test_mod_runtime.cpp | 116 ++++- 6 files changed, 1524 insertions(+), 268 deletions(-) diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index f65268b8f..c583b8527 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -1,176 +1,170 @@ -# PSXRecomp mod packages +# PSXRecomp mod packages and features -PSXRecomp games may expose a shared Dear ImGui **Mods** view backed by versioned -`.psxmod` packages. A package is a ZIP archive with `manifest.toml` at its root. -Packages are installed under `mods/packages///`; the selected -versions and option values live in `mods/state.toml`. +A `.psxmod` is a versioned installation, provenance, and trust boundary. A +package may contribute any number of independently configurable **features**. +The launcher presents those features as the primary Mods list; package +installation, version selection, and removal are a secondary management view. -Mods are resolved and fingerprinted before boot. They never rewrite the user's -disc or the recomp executable. +Feature identity is always `(package_id, feature_id)`. Enabling one feature +never enables, disables, or reconfigures another feature. -## Minimal manifest +The player selects a verified stock BIN/CUE. Resolution produces guarded native +operations and sparse disc overlays without rewriting or replacing that stock +image. + +## Feature manifest ```toml format_version = 1 -id = "example.faster-charge" +id = "example.localization" version = "1.2.0" -name = "Faster Charge" +name = "Example Localization Pack" author = "Example Author" -description = "Shortens the charge delay." +description = "Independent title and script features." license = "MIT" resolver = "declarative" -save_compatibility = "shared" # or "isolated" -conflicts = ["example.incompatible"] [[target]] game_id = "SLUS-00000" -# Optional. When present, the selected image must have this digest. -exe_sha256 = "..." -disc_sha256 = "..." - -[[dependency]] -id = "example.core" -version = "^1.0.0" +# Required for disc overlays. Use the digest of the supported stock image. +disc_sha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +[[feature]] +id = "title-screen" +name = "Title Screen" +description = "Selects the title-screen artwork." +group = "Localization" +default_enabled = false + +[[feature]] +id = "retranslation" +name = "Retranslation" +description = "Uses the revised English script." +group = "Localization" [[option]] -id = "delay" -label = "Charge delay" -description = "Delay in frames." -group = "Game balance" +feature = "title-screen" +id = "variant" +label = "Title artwork" type = "choice" -default = "normal" +default = "rockman" [[option.choice]] -value = "normal" -label = "Normal" +value = "mega-man" +label = "Mega Man X6 (USA)" [[option.choice]] -value = "fast" -label = "Fast" +value = "rockman" +label = "Rockman X6 (Japan)" [[patch]] +feature = "title-screen" target = "main_exe" address = 0x80041234 expected = "2a 00 02 24" replace = "0e 00 02 24" -when_option = "delay" -when_value = "fast" -order = 10 - -# Optional: use this for structural changes that cannot be represented as -# equal-size sector writes. Multiple entries may select different recipes from -# one launcher option, but exactly one may resolve in the complete mod plan. -[[derived_disc]] -kind = "vcdiff" -patch = "assets/fast.xdelta3" -patch_sha256 = "..." -output_size = 600000000 -output_sha256 = "..." -when_option = "delay" -when_value = "fast" - -# For option matrices, use a condition table. All listed option values must -# match. `when_option` / `when_value` remain supported for single-option cases. -[[derived_disc]] -kind = "vcdiff" -patch = "assets/fast-rockman.xdelta3" -patch_sha256 = "..." -output_size = 600000000 -output_sha256 = "..." -when = { delay = "fast", title_screen = "rockman_japan" } +when = { variant = "rockman" } + +[[overlay]] +feature = "retranslation" +target = "disc_raw" +offset = 123456 +file = "assets/retranslated-script.bin" +sha256 = "..." +# Optional additional guard over the same range in the stock image. +expected_sha256 = "..." ``` -Option types are `boolean`, `choice`, and bounded `integer`. `when_option` / -`when_value` are optional; `when = { option = "value", ... }` can be used when a -patch or derived-disc recipe depends on multiple option values. An unconditional -patch omits both condition forms. - -## Patch targets - -- `main_exe`: `address` is a PSX guest virtual address. All expected bytes are - checked after the BIOS loads the PS-X EXE. The complete main-EXE plan is then - applied before the configured entry point executes. -- `disc_raw`: `offset` is in the canonical 2352-byte raw-sector stream - (`lba * 2352 + byte_in_sector`). -- `disc_user`: `offset` is in the canonical 2048-byte user-data stream - (`lba * 2048 + byte_in_sector`). - -A disc operation may not cross a sector boundary. Use multiple operations. -Expected and replacement data are equal-length hexadecimal byte strings. - -Changed main-EXE code is deliberately not represented by a precompiled -permutation. PSXRecomp's exact text-image guard sees the changed live RAM and -routes that code through the existing dirty-RAM interpreter/native overlay -cache. Untouched functions stay on the static native path. This makes runtime -cost proportional to the code actually changed, not to the number of possible -option combinations. - -## Derived discs - -A `derived_disc` is a data-only VCDIFF recipe whose source is the verified stock -disc from `[[target]].disc_sha256`. It is intended for mods that relocate files, -grow the ISO, replace large assets, or otherwise change disc geometry. A package -may contain a matrix of conditional recipes for its own options, but exactly one -recipe may resolve in the complete mod plan. - -The launcher continues to display and persist the user's stock BIN/CUE. Before -boot, the runtime: - -1. fingerprints that stock image and resolves package options; -2. verifies the package's VCDIFF payload; -3. invokes the release's trusted `xdelta3` binary (packages cannot provide an - executable); -4. verifies the derived size and SHA-256; and -5. atomically publishes and mounts - `mods/cache/.bin`. - -Changing package versions or options changes the plan fingerprint and therefore -the cache key. A cached result is reused on later launches. Ordinary guarded -sector overlays may be applied on top of the derived image, so a structural -base package can support many small composable add-ons. - -Release builders stage the trusted decoder with -`-DPSXRECOMP_XDELTA3_EXECUTABLE=/path/to/xdelta3`. More than one active -derived-disc recipe is rejected after option resolution; packages should use a -single owner package for structural transforms and dependencies/conflicts for -external ownership. - -Do not split one structural option system into many mutually exclusive -full-disc packages. For example, a game-wide Tweaks system should be one package -with launcher options and a conditional recipe matrix. Choices that are -mutually exclusive by design belong inside that package as option values, while -unrelated mods should remain separate packages and compose normally. - -## Resolution rules - -- Installed versions are side-by-side. The launcher can select an older version - to roll back. -- Enabling or disabling one package changes only that package. It does not - silently toggle other packages. -- Enabled packages are topologically ordered by dependencies, then by stable - package/patch order. -- Missing dependencies, version mismatches, declared conflicts, dependency - cycles, overlapping writes, unavailable trusted resolvers, invalid option - values, multiple derived-disc providers, and target mismatches prevent launch. -- The resolved package versions, option values, writes, and derived-disc recipe - produce a canonical SHA-256 plan fingerprint suitable for diagnostics and - multiplayer agreement. -- Package and state changes apply on the next launch. There is no mid-frame - mutation. - -## Trusted adapters - -`resolver = "builtin:"` selects a resolver statically registered by the game -executable. This is for legacy patch systems whose dependency and composition -rules cannot be expressed as independent declarative writes. A package cannot -load native code or choose an arbitrary symbol: unregistered IDs fail closed. -The adapter emits the same expected-byte-guarded resolved writes as a -declarative package, so validation, overlap checks, fingerprinting, and runtime -execution remain shared. - -## Archive safety +Every `[[option]]`, `[[patch]]`, and `[[overlay]]` in a feature-style manifest +must name its owning feature. Ambiguous operations are rejected. + +Option types are `boolean`, `choice`, and bounded `integer`. Conditions are +feature-local: `when = { option = "value", ... }` requires every listed option +to match. The legacy `when_option`/`when_value` pair remains accepted for a +single condition. + +## Native operations + +`main_exe` writes use PSX guest virtual addresses. Expected bytes are checked +after the BIOS loads the executable, then the complete write plan is applied +before its entry point. Changed executable ranges use the existing dirty-RAM +interpreter/native-overlay machinery; untouched functions remain on the static +native path. + +Small `disc_raw` and `disc_user` patches are equal-length guarded writes and may +not cross a sector boundary: + +- `disc_raw` offsets use `lba * 2352 + byte_in_sector`. +- `disc_user` offsets use `lba * 2048 + byte_in_sector`. + +File-backed `[[overlay]]` operations are intended for large assets and may span +any number of sectors. Their paths must remain inside the archive. Payload +size and SHA-256 are verified while scanning, but disabled payloads are not +retained in memory. Enabled payloads are loaded and reverified during +resolution, then indexed by target and LBA before boot. A CD read performs a +direct indexed lookup rather than scanning every installed mod. + +Feature disc overlays require an exact `disc_sha256` on every target entry. +`expected_sha256` can additionally guard the replaced stock range. + +## State and migration + +`mods/state.toml` format 2 stores selected package versions separately from +per-feature enabled states and values: + +```toml +format_version = 2 + +[[package]] +id = "example.localization" +version = "1.2.0" + +[[feature]] +package_id = "example.localization" +id = "title-screen" +enabled = true + +[feature.values] +variant = "rockman" +``` + +State format 1 and package-only manifests remain readable as a migration aid. +They appear through one synthetic legacy feature. New packages should use +explicit features. + +The old `derived_disc` VCDIFF mechanism is legacy conversion scaffolding only. +Feature-style manifests reject it. It is not a product mod primitive, fallback, +or image-selection workflow; patched discs may be used offline as parity +oracles while converting known mods to native operations. + +## Resolution and diagnostics + +Before boot, the manager: + +1. verifies the selected stock game and revision; +2. expands only enabled features and their selected options; +3. orders active packages deterministically by dependencies; +4. verifies enabled payloads and operation bounds; +5. collision-checks the complete byte-range plan; +6. coalesces only truly identical target/range/expected/replacement writes or + identical overlays; and +7. produces a canonical SHA-256 plan fingerprint. + +Incompatible overlaps fail before launch. Structured diagnostics identify both +`(package, feature)` owners and the exact contested target range. The launcher +marks both feature rows and lets the user decide what to disable. It never +silently chooses a winner. + +Package-level dependencies and conflicts are reserved for actual implementation +relationships. Mutually exclusive choices such as US versus Japanese artwork +belong inside one feature as option values. + +## Trusted adapters and archive safety + +`resolver = "builtin:"` selects a resolver statically registered by the +game. Packages cannot load arbitrary native code or select arbitrary symbols. The installer accepts stored or DEFLATE-compressed ZIP entries, validates CRCs, -rejects encrypted entries and unsafe/absolute paths, limits archives to 4096 +rejects encrypted entries and unsafe or absolute paths, limits archives to 4096 files and 256 MiB expanded size, stages extraction, validates the manifest, and -publishes the version with an atomic rename. +publishes the version atomically. diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index b9b5facf7..5a677527b 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -21,6 +21,7 @@ struct ModChoice { }; struct ModOption { + std::string feature_id; std::string id; std::string label; std::string description; @@ -33,6 +34,15 @@ struct ModOption { std::vector choices; }; +struct ModFeature { + std::string id; + std::string name; + std::string description; + std::string group = "General"; + bool default_enabled = false; + bool legacy = false; +}; + struct ModRequirement { std::string id; std::string version; @@ -51,6 +61,7 @@ enum class ModPatchTarget { }; struct ModPatch { + std::string feature_id; ModPatchTarget target = ModPatchTarget::MainExe; uint64_t location = 0; /* guest address or canonical disc-stream byte offset */ std::vector expected; @@ -61,6 +72,18 @@ struct ModPatch { int64_t order = 0; }; +struct ModOverlay { + std::string feature_id; + ModPatchTarget target = ModPatchTarget::DiscRaw; + uint64_t location = 0; + std::filesystem::path file; + std::string sha256; + std::string expected_sha256; + uint64_t size = 0; + std::map when; + int64_t order = 0; +}; + struct ModDerivedDisc { std::string kind = "vcdiff"; std::filesystem::path patch; @@ -86,15 +109,25 @@ struct ModPackage { std::vector targets; std::vector dependencies; std::vector conflicts; + std::vector features; std::vector options; std::vector patches; + std::vector overlays; std::vector derived_discs; }; +struct ModFeatureSelection { + bool enabled = false; + bool has_enabled = false; + std::map values; +}; + struct ModSelection { + /* v1 migration state. Feature-style manifests do not use these fields. */ bool enabled = false; std::string version; std::map values; + std::map features; }; struct ModResolution { @@ -107,8 +140,19 @@ struct ModResolution { std::vector expected; std::vector replacement; std::string package_id; + std::string feature_id; }; std::vector writes; + struct Overlay { + ModPatchTarget target = ModPatchTarget::DiscRaw; + uint64_t location = 0; + std::vector payload; + std::string payload_sha256; + std::string expected_sha256; + std::string package_id; + std::string feature_id; + }; + std::vector overlays; struct DerivedDisc { std::string kind; std::filesystem::path patch; @@ -118,6 +162,15 @@ struct ModResolution { std::string package_id; }; std::vector derived_discs; + struct Diagnostic { + std::string message; + std::string resource; + std::string package_id; + std::string feature_id; + std::string other_package_id; + std::string other_feature_id; + }; + std::vector diagnostics; std::vector errors; }; @@ -150,12 +203,27 @@ class ModPackageManager { std::string* error = nullptr); bool set_option(const std::string& id, const std::string& option, const std::string& value, std::string* error = nullptr); + bool set_feature_enabled(const std::string& package_id, + const std::string& feature_id, bool enabled, + std::string* error = nullptr); + bool set_feature_option(const std::string& package_id, + const std::string& feature_id, + const std::string& option_id, + const std::string& value, + std::string* error = nullptr); const std::map>& packages() const { return packages_; } const std::map& selections() const { return selections_; } const ModPackage* selected_package(const std::string& id) const; + const ModFeature* selected_feature(const std::string& package_id, + const std::string& feature_id) const; + bool feature_enabled(const std::string& package_id, + const std::string& feature_id) const; + std::string feature_option_value(const std::string& package_id, + const std::string& feature_id, + const std::string& option_id) const; ModResolution resolve(const std::string& game_id, const std::string& exe_sha256 = {}, diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 50bf59e7e..aad03e2b1 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -569,6 +569,7 @@ bool target_matches(const ModPackage& package, const std::string& game, std::string canonical_resolution(const std::vector& ordered, const std::map& selections, const std::vector& writes, + const std::vector& overlays, const std::vector& derived_discs, const std::string& source_disc_sha256) { std::ostringstream out; @@ -576,16 +577,49 @@ std::string canonical_resolution(const std::vector& ordered, for (const ModPackage* package : ordered) { out << package->id << '@' << package->version << '\n'; const auto sit = selections.find(package->id); - if (sit == selections.end()) continue; - for (const auto& [key, value] : sit->second.values) - out << key << '=' << value << '\n'; + for (const ModFeature& feature : package->features) { + bool enabled = feature.default_enabled; + const std::map* values = nullptr; + if (sit != selections.end()) { + if (feature.legacy) { + enabled = sit->second.enabled; + values = &sit->second.values; + } else { + const auto selected = sit->second.features.find(feature.id); + if (selected != sit->second.features.end()) { + if (selected->second.has_enabled) + enabled = selected->second.enabled; + values = &selected->second.values; + } + } + } + out << "feature:" << feature.id << '=' + << (enabled ? "enabled" : "disabled") << '\n'; + for (const ModOption& option : package->options) { + if (option.feature_id != feature.id) continue; + const auto selected = values ? values->find(option.id) : + std::map::const_iterator{}; + const std::string value = + values && selected != values->end() + ? selected->second : option.default_value; + out << "feature:" << feature.id << ':' << option.id + << '=' << value << '\n'; + } + } } for (const ModResolution::Write& write : writes) { out << (write.target == ModPatchTarget::MainExe ? "main_exe" : write.target == ModPatchTarget::DiscRaw ? "disc_raw" : "disc_user") << '@' << std::hex << write.location << std::dec << ':' << hex_bytes(write.expected) << '>' << hex_bytes(write.replacement) - << ':' << write.package_id << '\n'; + << ':' << write.package_id << ':' << write.feature_id << '\n'; + } + for (const ModResolution::Overlay& overlay : overlays) { + out << (overlay.target == ModPatchTarget::DiscRaw + ? "disc_raw_overlay" : "disc_user_overlay") + << '@' << std::hex << overlay.location << std::dec << ':' + << overlay.payload_sha256 << ':' << overlay.expected_sha256 << ':' + << overlay.package_id << ':' << overlay.feature_id << '\n'; } for (const ModResolution::DerivedDisc& derived : derived_discs) { out << "derived_disc:" << derived.kind << ':' @@ -595,26 +629,81 @@ std::string canonical_resolution(const std::vector& ordered, return out.str(); } +const ModFeature* find_feature(const ModPackage& package, const std::string& id) { + const auto found = std::find_if(package.features.begin(), package.features.end(), + [&](const ModFeature& feature) { return feature.id == id; }); + return found == package.features.end() ? nullptr : &*found; +} + +const ModOption* find_option(const ModPackage& package, + const std::string& feature_id, + const std::string& id) { + const auto option = std::find_if(package.options.begin(), package.options.end(), + [&](const ModOption& item) { + return item.feature_id == feature_id && item.id == id; + }); + return option == package.options.end() ? nullptr : &*option; +} + +const ModFeatureSelection* find_feature_selection(const ModPackage& package, + const ModSelection& selection, + const std::string& feature_id) { + const ModFeature* feature = find_feature(package, feature_id); + if (!feature) return nullptr; + if (feature->legacy) return nullptr; + const auto found = selection.features.find(feature_id); + return found == selection.features.end() ? nullptr : &found->second; +} + +bool is_feature_enabled(const ModPackage& package, const ModSelection& selection, + const ModFeature& feature) { + if (feature.legacy) return selection.enabled; + const ModFeatureSelection* selected = + find_feature_selection(package, selection, feature.id); + return selected && selected->has_enabled + ? selected->enabled : feature.default_enabled; +} + +bool has_enabled_feature(const ModPackage& package, + const ModSelection& selection) { + return std::any_of(package.features.begin(), package.features.end(), + [&](const ModFeature& feature) { + return is_feature_enabled(package, selection, feature); + }); +} + std::string effective_option_value(const ModPackage& package, const ModSelection& selection, + const std::string& feature_id, const std::string& id) { - const auto selected = selection.values.find(id); - if (selected != selection.values.end()) return selected->second; - const auto option = std::find_if(package.options.begin(), package.options.end(), - [&](const ModOption& item) { return item.id == id; }); - return option == package.options.end() ? std::string() : option->default_value; + const ModFeature* feature = find_feature(package, feature_id); + if (feature && feature->legacy) { + const auto selected = selection.values.find(id); + if (selected != selection.values.end()) return selected->second; + } else { + const ModFeatureSelection* selected = + find_feature_selection(package, selection, feature_id); + if (selected) { + const auto value = selected->values.find(id); + if (value != selected->values.end()) return value->second; + } + } + const ModOption* option = find_option(package, feature_id, id); + return option ? option->default_value : std::string(); } bool conditions_match(const ModPackage& package, const ModSelection& selection, + const std::string& feature_id, const std::map& conditions) { for (const auto& [id, value] : conditions) { - if (effective_option_value(package, selection, id) != value) + if (effective_option_value(package, selection, feature_id, id) != value) return false; } return true; } void read_conditions(const toml::value& value, const std::vector& options, + const std::string& feature_id, std::map& when, const char* label) { const std::string when_option = @@ -638,7 +727,9 @@ void read_conditions(const toml::value& value, const std::vector& opt (void)condition_value; const auto option = std::find_if( options.begin(), options.end(), - [&](const ModOption& item) { return item.id == id; }); + [&](const ModOption& item) { + return item.feature_id == feature_id && item.id == id; + }); if (option == options.end()) throw std::runtime_error(std::string(label) + " references unknown option"); } @@ -651,6 +742,50 @@ bool writes_overlap(const ModResolution::Write& a, const ModResolution::Write& b return a.location < b_end && b.location < a_end; } +bool ranges_overlap(ModPatchTarget a_target, uint64_t a_location, size_t a_size, + ModPatchTarget b_target, uint64_t b_location, size_t b_size) { + if (a_target != b_target) return false; + const uint64_t a_end = a_location + a_size; + const uint64_t b_end = b_location + b_size; + return a_location < b_end && b_location < a_end; +} + +bool identical_write(const ModResolution::Write& a, const ModResolution::Write& b) { + return a.target == b.target && a.location == b.location && + a.expected == b.expected && a.replacement == b.replacement; +} + +std::string overlap_resource(ModPatchTarget target, + uint64_t a_location, size_t a_size, + uint64_t b_location, size_t b_size) { + const char* name = target == ModPatchTarget::MainExe ? "main_exe" : + target == ModPatchTarget::DiscRaw ? "disc_raw" : + "disc_user"; + const uint64_t begin = std::max(a_location, b_location); + const uint64_t end = std::min( + a_location + a_size, b_location + b_size); + std::ostringstream out; + out << name << ":0x" << std::hex << begin << "-0x" << end; + return out.str(); +} + +bool valid_option_value(const ModOption& option, const std::string& value) { + if (option.type == ModOptionType::Boolean) + return value == "true" || value == "false"; + if (option.type == ModOptionType::Choice) + return std::any_of(option.choices.begin(), option.choices.end(), + [&](const ModChoice& choice) { return choice.value == value; }); + try { + size_t used = 0; + const int64_t parsed = std::stoll(value, &used); + return used == value.size() && parsed >= option.min_value && + parsed <= option.max_value && + ((parsed - option.min_value) % option.step) == 0; + } catch (...) { + return false; + } +} + std::string fingerprint_text(const std::string& text) { uint8_t digest[32]; psx_sha256_compute((const uint8_t*)text.data(), text.size(), digest); @@ -735,17 +870,53 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, for (const std::string& id : out.conflicts) if (!valid_id(id)) throw std::runtime_error("invalid conflict id"); + const bool feature_style = cfg.contains("feature"); + if (feature_style) { + std::set feature_ids; + for (const toml::value& v : toml::find(cfg, "feature").as_array()) { + ModFeature feature; + feature.id = toml::find(v, "id"); + feature.name = toml::find(v, "name"); + feature.description = v.contains("description") + ? toml::find(v, "description") : ""; + feature.group = v.contains("group") + ? toml::find(v, "group") : "General"; + feature.default_enabled = + toml::find_or(v, "default_enabled", false); + if (!valid_id(feature.id) || + !feature_ids.insert(feature.id).second) + throw std::runtime_error("invalid or duplicate feature id"); + if (feature.name.empty()) + throw std::runtime_error("feature name is empty"); + out.features.push_back(std::move(feature)); + } + if (out.features.empty()) + throw std::runtime_error("package has no [[feature]] entries"); + } else { + ModFeature feature; + feature.id = "legacy"; + feature.name = out.name; + feature.description = out.description; + feature.legacy = true; + out.features.push_back(std::move(feature)); + } + if (cfg.contains("option")) { - std::set option_ids; + std::set> option_ids; for (const toml::value& v : toml::find(cfg, "option").as_array()) { ModOption option; + option.feature_id = feature_style + ? toml::find(v, "feature") : "legacy"; option.id = toml::find(v, "id"); option.label = toml::find(v, "label"); option.description = v.contains("description") ? toml::find(v, "description") : ""; option.group = v.contains("group") ? toml::find(v, "group") : "General"; const std::string type = toml::find(v, "type"); - if (!valid_id(option.id) || !option_ids.insert(option.id).second) + if (!find_feature(out, option.feature_id)) + throw std::runtime_error("option references unknown feature"); + if (!valid_id(option.id) || + !option_ids.insert({option.feature_id, option.id}).second) throw std::runtime_error("invalid or duplicate option id"); if (type == "boolean") { option.type = ModOptionType::Boolean; @@ -785,6 +956,10 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, size_t declaration_index = 0; for (const toml::value& v : toml::find(cfg, "patch").as_array()) { ModPatch patch; + patch.feature_id = feature_style + ? toml::find(v, "feature") : "legacy"; + if (!find_feature(out, patch.feature_id)) + throw std::runtime_error("patch references unknown feature"); const std::string target = toml::find(v, "target"); if (target == "main_exe") { patch.target = ModPatchTarget::MainExe; @@ -822,7 +997,7 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, "disc patch may not cross a sector boundary"); patch.order = toml::find_or( v, "order", (int64_t)declaration_index); - read_conditions(v, out.options, patch.when, "patch"); + read_conditions(v, out.options, patch.feature_id, patch.when, "patch"); if (!patch.when.empty()) { patch.when_option = patch.when.begin()->first; patch.when_value = patch.when.begin()->second; @@ -831,7 +1006,77 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, ++declaration_index; } } + if (cfg.contains("overlay")) { + if (!feature_style) + throw std::runtime_error( + "disc overlays require explicit [[feature]] ownership"); + size_t declaration_index = 0; + for (const toml::value& v : toml::find(cfg, "overlay").as_array()) { + ModOverlay overlay; + overlay.feature_id = toml::find(v, "feature"); + if (!find_feature(out, overlay.feature_id)) + throw std::runtime_error("overlay references unknown feature"); + const std::string target = toml::find(v, "target"); + if (target == "disc_raw") + overlay.target = ModPatchTarget::DiscRaw; + else if (target == "disc_user") + overlay.target = ModPatchTarget::DiscUser; + else + throw std::runtime_error( + "overlay target must be disc_raw or disc_user"); + const int64_t offset = toml::find(v, "offset"); + if (offset < 0) + throw std::runtime_error("overlay offset is negative"); + overlay.location = (uint64_t)offset; + const std::string relative_file = + toml::find(v, "file"); + if (!safe_archive_name(relative_file)) + throw std::runtime_error("overlay file path is unsafe"); + overlay.file = out.root / fs::path(relative_file); + overlay.sha256 = toml::find(v, "sha256"); + overlay.expected_sha256 = v.contains("expected_sha256") + ? toml::find(v, "expected_sha256") : ""; + if (!valid_sha256(overlay.sha256) || + (!overlay.expected_sha256.empty() && + !valid_sha256(overlay.expected_sha256))) + throw std::runtime_error( + "overlay hashes must be lowercase SHA-256"); + std::string file_error; + std::vector payload; + if (!read_file(overlay.file, payload, &file_error)) + throw std::runtime_error(file_error); + if (payload.empty()) + throw std::runtime_error("overlay payload is empty"); + const std::string actual = fingerprint_text(std::string( + (const char*)payload.data(), payload.size())); + if (actual != overlay.sha256) + throw std::runtime_error("overlay payload checksum failed"); + overlay.size = payload.size(); + if (overlay.location > + std::numeric_limits::max() - overlay.size) + throw std::runtime_error("overlay range overflows"); + overlay.order = toml::find_or( + v, "order", (int64_t)declaration_index); + read_conditions(v, out.options, overlay.feature_id, + overlay.when, "overlay"); + out.overlays.push_back(std::move(overlay)); + ++declaration_index; + } + const bool guarded_stock = std::all_of( + out.targets.begin(), out.targets.end(), + [](const ModTarget& target) { + return valid_sha256(target.disc_sha256); + }); + if (!out.overlays.empty() && !guarded_stock) + throw std::runtime_error( + "feature disc overlays require an exact disc_sha256 " + "on every [[target]]"); + } if (cfg.contains("derived_disc")) { + if (feature_style) + throw std::runtime_error( + "derived_disc is a legacy conversion artifact and may not " + "be used by feature-style packages"); for (const toml::value& v : toml::find(cfg, "derived_disc").as_array()) { ModDerivedDisc derived; derived.kind = toml::find_or(v, "kind", "vcdiff"); @@ -850,7 +1095,8 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, if (output_size <= 0) throw std::runtime_error("derived_disc output_size must be positive"); derived.output_size = (uint64_t)output_size; - read_conditions(v, out.options, derived.when, "derived_disc"); + read_conditions(v, out.options, "legacy", + derived.when, "derived_disc"); if (!derived.when.empty()) { derived.when_option = derived.when.begin()->first; derived.when_value = derived.when.begin()->second; @@ -911,25 +1157,62 @@ bool ModPackageManager::load_state(std::string* error) { try { const toml::value cfg = toml::parse(path.string()); const int64_t version = toml::find(cfg, "format_version"); - if (version != 1) throw std::runtime_error("unsupported state format_version"); - if (!cfg.contains("package")) return true; - for (const toml::value& v : toml::find(cfg, "package").as_array()) { - const std::string id = toml::find(v, "id"); - if (!valid_id(id)) throw std::runtime_error("invalid state package id"); - ModSelection selection; - selection.enabled = toml::find_or(v, "enabled", false); - selection.version = toml::find_or(v, "version", ""); - if (v.contains("values")) { - for (const auto& [key, value] : toml::find(v, "values").as_table()) { - if (value.is_string()) selection.values[key] = toml::get(value); - else if (value.is_boolean()) - selection.values[key] = toml::get(value) ? "true" : "false"; - else if (value.is_integer()) - selection.values[key] = std::to_string(toml::get(value)); - else throw std::runtime_error("state option values must be scalar"); + if (version != 1 && version != 2) + throw std::runtime_error("unsupported state format_version"); + if (cfg.contains("package")) { + for (const toml::value& v : toml::find(cfg, "package").as_array()) { + const std::string id = toml::find(v, "id"); + if (!valid_id(id)) throw std::runtime_error("invalid state package id"); + ModSelection selection; + selection.enabled = toml::find_or(v, "enabled", false); + selection.version = toml::find_or(v, "version", ""); + if (v.contains("values")) { + for (const auto& [key, value] : toml::find(v, "values").as_table()) { + if (value.is_string()) + selection.values[key] = toml::get(value); + else if (value.is_boolean()) + selection.values[key] = + toml::get(value) ? "true" : "false"; + else if (value.is_integer()) + selection.values[key] = + std::to_string(toml::get(value)); + else + throw std::runtime_error( + "state option values must be scalar"); + } } + selections_[id] = std::move(selection); + } + } + if (version == 2 && cfg.contains("feature")) { + for (const toml::value& v : toml::find(cfg, "feature").as_array()) { + const std::string package_id = + toml::find(v, "package_id"); + const std::string feature_id = toml::find(v, "id"); + if (!valid_id(package_id) || !valid_id(feature_id)) + throw std::runtime_error("invalid state feature identity"); + ModFeatureSelection feature; + feature.enabled = toml::find(v, "enabled"); + feature.has_enabled = true; + if (v.contains("values")) { + for (const auto& [key, value] : + toml::find(v, "values").as_table()) { + if (value.is_string()) + feature.values[key] = toml::get(value); + else if (value.is_boolean()) + feature.values[key] = + toml::get(value) ? "true" : "false"; + else if (value.is_integer()) + feature.values[key] = + std::to_string(toml::get(value)); + else + throw std::runtime_error( + "state feature option values must be scalar"); + } + } + selections_[package_id].features[feature_id] = + std::move(feature); } - selections_[id] = std::move(selection); } return true; } catch (const std::exception& ex) { @@ -952,19 +1235,46 @@ bool ModPackageManager::save_state(std::string* error) const { set_error(error, "cannot write " + temp.string()); return false; } - out << "format_version = 1\n"; + out << "format_version = 2\n"; for (const auto& [id, selection] : selections_) { out << "\n[[package]]\n"; out << "id = " << quote_toml(id) << "\n"; - out << "enabled = " << (selection.enabled ? "true" : "false") << "\n"; if (!selection.version.empty()) out << "version = " << quote_toml(selection.version) << "\n"; - if (!selection.values.empty()) { + const ModPackage* package = find_selected(packages_, id, selection); + const bool legacy = package && package->features.size() == 1 && + package->features.front().legacy; + if (legacy) { + out << "enabled = " << (selection.enabled ? "true" : "false") << "\n"; + } + if (legacy && !selection.values.empty()) { out << "[package.values]\n"; for (const auto& [key, value] : selection.values) out << key << " = " << quote_toml(value) << "\n"; } } + for (const auto& [package_id, selection] : selections_) { + for (const auto& [feature_id, feature] : selection.features) { + bool enabled = feature.enabled; + if (!feature.has_enabled) { + const ModPackage* package = + find_selected(packages_, package_id, selection); + const ModFeature* manifest_feature = + package ? find_feature(*package, feature_id) : nullptr; + if (manifest_feature) + enabled = manifest_feature->default_enabled; + } + out << "\n[[feature]]\n"; + out << "package_id = " << quote_toml(package_id) << "\n"; + out << "id = " << quote_toml(feature_id) << "\n"; + out << "enabled = " << (enabled ? "true" : "false") << "\n"; + if (!feature.values.empty()) { + out << "[feature.values]\n"; + for (const auto& [key, value] : feature.values) + out << key << " = " << quote_toml(value) << "\n"; + } + } + } out.close(); if (!out) { set_error(error, "cannot finish " + temp.string()); @@ -1048,15 +1358,25 @@ bool ModPackageManager::install_archive(const fs::path& archive, bool ModPackageManager::remove_version(const std::string& id, const std::string& version, std::string* error) { const auto sit = selections_.find(id); - if (sit != selections_.end() && sit->second.enabled && - (sit->second.version.empty() || sit->second.version == version)) { + const ModSelection blank; + const ModSelection& current = + sit == selections_.end() ? blank : sit->second; + const ModPackage* selected = find_selected(packages_, id, current); + if (selected && selected->version == version && + has_enabled_feature(*selected, current)) { set_error(error, "cannot remove an active package version"); return false; } - for (const auto& [other_id, selection] : selections_) { - if (!selection.enabled || other_id == id) continue; - const ModPackage* package = find_selected(packages_, other_id, selection); - if (!package) continue; + for (const auto& [other_id, versions] : packages_) { + (void)versions; + const auto other_selection = selections_.find(other_id); + const ModSelection& selection = + other_selection == selections_.end() ? blank : + other_selection->second; + const ModPackage* package = + find_selected(packages_, other_id, selection); + if (!package || other_id == id || + !has_enabled_feature(*package, selection)) continue; for (const ModRequirement& dep : package->dependencies) { if (dep.id == id && version_satisfies(version, dep.version)) { set_error(error, "cannot remove a version required by " + other_id); @@ -1087,6 +1407,16 @@ bool ModPackageManager::set_enabled(const std::string& id, bool enabled, std::st set_error(error, "package is not installed"); return false; } + const ModPackage* package = selected_package(id); + if (!package) { + set_error(error, "package/version is not installed"); + return false; + } + if (package->features.size() != 1 || !package->features.front().legacy) { + set_error(error, + "feature-style packages must be enabled per feature"); + return false; + } selections_[id].enabled = enabled; return true; } @@ -1112,30 +1442,20 @@ bool ModPackageManager::set_option(const std::string& id, const std::string& opt set_error(error, "package/version is not installed"); return false; } + if (package->features.size() != 1 || !package->features.front().legacy) { + set_error(error, + "feature-style package options must name a feature"); + return false; + } const auto oit = std::find_if(package->options.begin(), package->options.end(), - [&](const ModOption& option) { return option.id == option_id; }); + [&](const ModOption& option) { + return option.feature_id == "legacy" && option.id == option_id; + }); if (oit == package->options.end()) { set_error(error, "unknown package option"); return false; } - bool valid = false; - if (oit->type == ModOptionType::Boolean) { - valid = value == "true" || value == "false"; - } else if (oit->type == ModOptionType::Choice) { - valid = std::any_of(oit->choices.begin(), oit->choices.end(), - [&](const ModChoice& choice) { return choice.value == value; }); - } else { - try { - size_t used = 0; - const int64_t parsed = std::stoll(value, &used); - valid = used == value.size() && parsed >= oit->min_value && - parsed <= oit->max_value && - ((parsed - oit->min_value) % oit->step) == 0; - } catch (...) { - valid = false; - } - } - if (!valid) { + if (!valid_option_value(*oit, value)) { set_error(error, "invalid option value"); return false; } @@ -1143,6 +1463,46 @@ bool ModPackageManager::set_option(const std::string& id, const std::string& opt return true; } +bool ModPackageManager::set_feature_enabled(const std::string& package_id, + const std::string& feature_id, + bool enabled, + std::string* error) { + const ModPackage* package = selected_package(package_id); + const ModFeature* feature = + package ? find_feature(*package, feature_id) : nullptr; + if (!feature || feature->legacy) { + set_error(error, "unknown package feature"); + return false; + } + ModFeatureSelection& selection = + selections_[package_id].features[feature_id]; + selection.enabled = enabled; + selection.has_enabled = true; + return true; +} + +bool ModPackageManager::set_feature_option(const std::string& package_id, + const std::string& feature_id, + const std::string& option_id, + const std::string& value, + std::string* error) { + const ModPackage* package = selected_package(package_id); + const ModFeature* feature = + package ? find_feature(*package, feature_id) : nullptr; + const ModOption* option = + package ? find_option(*package, feature_id, option_id) : nullptr; + if (!feature || feature->legacy || !option) { + set_error(error, "unknown feature option"); + return false; + } + if (!valid_option_value(*option, value)) { + set_error(error, "invalid feature option value"); + return false; + } + selections_[package_id].features[feature_id].values[option_id] = value; + return true; +} + const ModPackage* ModPackageManager::selected_package(const std::string& id) const { const auto selection = selections_.find(id); const ModSelection blank; @@ -1150,18 +1510,55 @@ const ModPackage* ModPackageManager::selected_package(const std::string& id) con selection == selections_.end() ? blank : selection->second); } +const ModFeature* ModPackageManager::selected_feature( + const std::string& package_id, const std::string& feature_id) const { + const ModPackage* package = selected_package(package_id); + return package ? find_feature(*package, feature_id) : nullptr; +} + +bool ModPackageManager::feature_enabled(const std::string& package_id, + const std::string& feature_id) const { + const ModPackage* package = selected_package(package_id); + const ModFeature* feature = + package ? find_feature(*package, feature_id) : nullptr; + if (!package || !feature) return false; + const auto found = selections_.find(package_id); + const ModSelection blank; + return is_feature_enabled( + *package, found == selections_.end() ? blank : found->second, *feature); +} + +std::string ModPackageManager::feature_option_value( + const std::string& package_id, const std::string& feature_id, + const std::string& option_id) const { + const ModPackage* package = selected_package(package_id); + if (!package) return {}; + const auto found = selections_.find(package_id); + const ModSelection blank; + return effective_option_value( + *package, found == selections_.end() ? blank : found->second, + feature_id, option_id); +} + ModResolution ModPackageManager::resolve(const std::string& game_id, const std::string& exe_sha256, const std::string& disc_sha256) const { ModResolution result; std::map active; - for (const auto& [id, selection] : selections_) { - if (!selection.enabled) continue; + for (const auto& [id, versions] : packages_) { + (void)versions; + const auto selected = selections_.find(id); + const ModSelection blank; + const ModSelection& selection = + selected == selections_.end() ? blank : selected->second; const ModPackage* package = find_selected(packages_, id, selection); if (!package) { - result.errors.push_back("selected package/version is not installed: " + id); + if (selected != selections_.end()) + result.errors.push_back( + "selected package/version is not installed: " + id); continue; } + if (!has_enabled_feature(*package, selection)) continue; if (!target_matches(*package, game_id, exe_sha256, disc_sha256)) { result.errors.push_back("package does not target this game/image: " + id); continue; @@ -1184,26 +1581,27 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, const auto selection = selections_.find(id); if (selection != selections_.end()) { for (const ModOption& option : package->options) { - const auto value = selection->second.values.find(option.id); - if (value == selection->second.values.end()) continue; - /* Reuse the public validation path without mutating by checking - * the same domain directly. Defaults require no state entry. */ - bool valid = false; - if (option.type == ModOptionType::Boolean) - valid = value->second == "true" || value->second == "false"; - else if (option.type == ModOptionType::Choice) - valid = std::any_of(option.choices.begin(), option.choices.end(), - [&](const ModChoice& c) { return c.value == value->second; }); - else { - try { - size_t used = 0; - const int64_t n = std::stoll(value->second, &used); - valid = used == value->second.size() && n >= option.min_value && - n <= option.max_value && - ((n - option.min_value) % option.step) == 0; - } catch (...) {} + const ModFeature* feature = + find_feature(*package, option.feature_id); + if (!feature || + !is_feature_enabled(*package, selection->second, *feature)) + continue; + const std::map* values = nullptr; + if (feature->legacy) { + values = &selection->second.values; + } else { + const auto feature_selection = + selection->second.features.find(feature->id); + if (feature_selection != selection->second.features.end()) + values = &feature_selection->second.values; } - if (!valid) result.errors.push_back(id + ": invalid value for " + option.id); + if (!values) continue; + const auto value = values->find(option.id); + if (value != values->end() && + !valid_option_value(option, value->second)) + result.errors.push_back( + id + "/" + feature->id + + ": invalid value for " + option.id); } } if (package->resolver.rfind("builtin:", 0) == 0) { @@ -1246,7 +1644,10 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, selected_it == selections_.end() ? blank : selected_it->second; if (package->resolver == "declarative") { for (const ModDerivedDisc& derived : package->derived_discs) { - if (!conditions_match(*package, selected, derived.when)) + const ModFeature& legacy = package->features.front(); + if (!legacy.legacy || + !is_feature_enabled(*package, selected, legacy) || + !conditions_match(*package, selected, "legacy", derived.when)) continue; ModResolution::DerivedDisc resolved; resolved.kind = derived.kind; @@ -1260,7 +1661,12 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, std::vector patches; patches.reserve(package->patches.size()); for (const ModPatch& patch : package->patches) { - if (!conditions_match(*package, selected, patch.when)) + const ModFeature* feature = + find_feature(*package, patch.feature_id); + if (!feature || + !is_feature_enabled(*package, selected, *feature) || + !conditions_match(*package, selected, + patch.feature_id, patch.when)) continue; patches.push_back(&patch); } @@ -1273,8 +1679,53 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, write.expected = patch->expected; write.replacement = patch->replacement; write.package_id = package->id; + write.feature_id = patch->feature_id; result.writes.push_back(std::move(write)); } + std::vector overlays; + overlays.reserve(package->overlays.size()); + for (const ModOverlay& overlay : package->overlays) { + const ModFeature* feature = + find_feature(*package, overlay.feature_id); + if (!feature || + !is_feature_enabled(*package, selected, *feature) || + !conditions_match(*package, selected, + overlay.feature_id, overlay.when)) + continue; + overlays.push_back(&overlay); + } + std::stable_sort(overlays.begin(), overlays.end(), + [](const ModOverlay* a, const ModOverlay* b) { + return a->order < b->order; + }); + for (const ModOverlay* overlay : overlays) { + std::vector payload; + std::string payload_error; + if (!read_file(overlay->file, payload, &payload_error)) { + result.errors.push_back( + package->id + "/" + overlay->feature_id + ": " + + payload_error); + continue; + } + const std::string actual = fingerprint_text(std::string( + (const char*)payload.data(), payload.size())); + if (payload.size() != overlay->size || + actual != overlay->sha256) { + result.errors.push_back( + package->id + "/" + overlay->feature_id + + ": overlay payload changed after installation"); + continue; + } + ModResolution::Overlay resolved; + resolved.target = overlay->target; + resolved.location = overlay->location; + resolved.payload = std::move(payload); + resolved.payload_sha256 = overlay->sha256; + resolved.expected_sha256 = overlay->expected_sha256; + resolved.package_id = package->id; + resolved.feature_id = overlay->feature_id; + result.overlays.push_back(std::move(resolved)); + } } else { const std::string resolver_id = package->resolver.substr(8); const auto resolver = builtin_resolvers().find(resolver_id); @@ -1293,31 +1744,113 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, result.errors.push_back( "more than one derived-disc provider is active: " + providers); } - for (size_t i = 0; i < result.writes.size(); ++i) { - const ModResolution::Write& write = result.writes[i]; + std::vector coalesced; + coalesced.reserve(result.writes.size()); + for (const ModResolution::Write& write : result.writes) { if (write.expected.empty() || write.expected.size() != write.replacement.size()) { - result.errors.push_back(write.package_id + ": resolver emitted invalid write"); + result.errors.push_back( + write.package_id + "/" + write.feature_id + + ": resolver emitted invalid write"); continue; } - for (size_t j = 0; j < i; ++j) { - if (writes_overlap(result.writes[j], write)) { - result.errors.push_back( - write.package_id + ": patch overlaps a write from " + - result.writes[j].package_id); + bool duplicate = false; + for (const ModResolution::Write& previous : coalesced) { + if (identical_write(previous, write)) { + duplicate = true; + break; + } + if (!writes_overlap(previous, write)) continue; + ModResolution::Diagnostic diagnostic; + diagnostic.resource = overlap_resource( + write.target, write.location, write.replacement.size(), + previous.location, previous.replacement.size()); + diagnostic.package_id = write.package_id; + diagnostic.feature_id = write.feature_id; + diagnostic.other_package_id = previous.package_id; + diagnostic.other_feature_id = previous.feature_id; + diagnostic.message = + write.package_id + "/" + write.feature_id + + " collides at " + diagnostic.resource + " with " + + previous.package_id + "/" + previous.feature_id; + result.diagnostics.push_back(diagnostic); + result.errors.push_back(diagnostic.message); + duplicate = true; + break; + } + if (!duplicate) coalesced.push_back(write); + } + result.writes = std::move(coalesced); + std::vector coalesced_overlays; + coalesced_overlays.reserve(result.overlays.size()); + for (const ModResolution::Overlay& overlay : result.overlays) { + bool claimed = false; + for (const ModResolution::Write& write : result.writes) { + if (!ranges_overlap( + overlay.target, overlay.location, overlay.payload.size(), + write.target, write.location, write.replacement.size())) + continue; + ModResolution::Diagnostic diagnostic; + diagnostic.resource = overlap_resource( + overlay.target, overlay.location, overlay.payload.size(), + write.location, write.replacement.size()); + diagnostic.package_id = overlay.package_id; + diagnostic.feature_id = overlay.feature_id; + diagnostic.other_package_id = write.package_id; + diagnostic.other_feature_id = write.feature_id; + diagnostic.message = + overlay.package_id + "/" + overlay.feature_id + + " collides at " + diagnostic.resource + " with " + + write.package_id + "/" + write.feature_id; + result.diagnostics.push_back(diagnostic); + result.errors.push_back(diagnostic.message); + claimed = true; + break; + } + if (claimed) continue; + for (const ModResolution::Overlay& previous : coalesced_overlays) { + if (!ranges_overlap( + overlay.target, overlay.location, overlay.payload.size(), + previous.target, previous.location, previous.payload.size())) + continue; + if (overlay.target == previous.target && + overlay.location == previous.location && + overlay.payload == previous.payload && + overlay.expected_sha256 == previous.expected_sha256) { + claimed = true; break; } + ModResolution::Diagnostic diagnostic; + diagnostic.resource = overlap_resource( + overlay.target, overlay.location, overlay.payload.size(), + previous.location, previous.payload.size()); + diagnostic.package_id = overlay.package_id; + diagnostic.feature_id = overlay.feature_id; + diagnostic.other_package_id = previous.package_id; + diagnostic.other_feature_id = previous.feature_id; + diagnostic.message = + overlay.package_id + "/" + overlay.feature_id + + " collides at " + diagnostic.resource + " with " + + previous.package_id + "/" + previous.feature_id; + result.diagnostics.push_back(diagnostic); + result.errors.push_back(diagnostic.message); + claimed = true; + break; } + if (!claimed) coalesced_overlays.push_back(overlay); } + result.overlays = std::move(coalesced_overlays); if (!result.errors.empty()) { result.ordered.clear(); result.writes.clear(); + result.overlays.clear(); result.derived_discs.clear(); return result; } result.fingerprint = fingerprint_text( canonical_resolution( - result.ordered, selections_, result.writes, result.derived_discs, + result.ordered, selections_, result.writes, result.overlays, + result.derived_discs, disc_sha256)); result.ok = true; return result; diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index f819e24b1..f8cad4987 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -41,6 +41,11 @@ namespace { struct RuntimeMods { ModPackageManager manager; ModResolution plan; + ModResolution validation; + std::map> raw_disc_index; + std::map> user_disc_index; + std::map> raw_overlay_index; + std::map> user_overlay_index; std::string game_id; std::string error; std::string exe_sha256; @@ -63,6 +68,14 @@ const ModPackage* selected_package(const std::string& id) { return state().manager.selected_package(id); } +bool package_has_enabled_feature(const ModPackage& package) { + return std::any_of( + package.features.begin(), package.features.end(), + [&](const ModFeature& feature) { + return state().manager.feature_enabled(package.id, feature.id); + }); +} + std::string selected_value(const ModPackage& package, const ModOption& option) { const auto selection = state().manager.selections().find(package.id); if (selection != state().manager.selections().end()) { @@ -72,6 +85,32 @@ std::string selected_value(const ModPackage& package, const ModOption& option) { return option.default_value; } +void build_disc_index(RuntimeMods& s) { + s.raw_disc_index.clear(); + s.user_disc_index.clear(); + s.raw_overlay_index.clear(); + s.user_overlay_index.clear(); + for (size_t i = 0; i < s.plan.writes.size(); ++i) { + const ModResolution::Write& write = s.plan.writes[i]; + if (write.target == ModPatchTarget::DiscRaw) + s.raw_disc_index[(uint32_t)(write.location / 2352)].push_back(i); + else if (write.target == ModPatchTarget::DiscUser) + s.user_disc_index[(uint32_t)(write.location / 2048)].push_back(i); + } + for (size_t i = 0; i < s.plan.overlays.size(); ++i) { + const ModResolution::Overlay& overlay = s.plan.overlays[i]; + const uint64_t sector_size = + overlay.target == ModPatchTarget::DiscRaw ? 2352 : 2048; + auto& index = overlay.target == ModPatchTarget::DiscRaw + ? s.raw_overlay_index : s.user_overlay_index; + const uint64_t first = overlay.location / sector_size; + const uint64_t last = + (overlay.location + overlay.payload.size() - 1) / sector_size; + for (uint64_t lba = first; lba <= last; ++lba) + index[(uint32_t)lba].push_back(i); + } +} + void set_error(const std::string& error) { state().error = error; } @@ -158,7 +197,7 @@ std::filesystem::path raw_image_path(const std::filesystem::path& path, if (extension != ".cue") return path; std::ifstream cue(path); if (!cue) { - if (error) *error = "cannot open CUE for derived disc: " + path.string(); + if (error) *error = "cannot open disc CUE: " + path.string(); return {}; } std::string line; @@ -182,10 +221,67 @@ std::filesystem::path raw_image_path(const std::filesystem::path& path, } return (path.parent_path() / name).lexically_normal(); } - if (error) *error = "CUE has no source file for derived disc: " + path.string(); + if (error) *error = "disc CUE has no source file: " + path.string(); return {}; } +bool sha256_disc_range(const std::filesystem::path& image, + ModPatchTarget target, uint64_t location, size_t size, + std::string& out, std::string* error) { + const std::filesystem::path source = raw_image_path(image, error); + if (source.empty()) return false; + std::ifstream file(source, std::ios::binary); + if (!file) { + if (error) *error = "cannot open stock image range: " + source.string(); + return false; + } + file.seekg(0, std::ios::end); + const std::streamoff file_size = file.tellg(); + if (file_size < 0) { + if (error) *error = "cannot size stock image: " + source.string(); + return false; + } + psx_sha256_ctx hash; + psx_sha256_init(&hash); + std::array bytes{}; + size_t remaining = size; + uint64_t at = location; + const bool raw_source = file_size > 0 && + ((uint64_t)file_size % 2352u) == 0; + while (remaining != 0) { + uint64_t physical = at; + size_t chunk = remaining; + if (target == ModPatchTarget::DiscUser && raw_source) { + const uint64_t lba = at / 2048u; + const size_t within = (size_t)(at % 2048u); + physical = lba * 2352u + 24u + within; + chunk = std::min(chunk, 2048u - within); + } + chunk = std::min(chunk, bytes.size()); + if (physical > (uint64_t)file_size || + chunk > (uint64_t)file_size - physical) { + if (error) *error = "overlay expected range exceeds stock image"; + return false; + } + file.clear(); + file.seekg((std::streamoff)physical); + if (!file.read((char*)bytes.data(), (std::streamsize)chunk)) { + if (error) *error = "cannot read stock image overlay range"; + return false; + } + psx_sha256_update(&hash, bytes.data(), chunk); + at += chunk; + remaining -= chunk; + } + uint8_t digest[32]; + psx_sha256_final(&hash, digest); + std::ostringstream text; + for (uint8_t byte : digest) + text << std::hex << std::setw(2) << std::setfill('0') << (unsigned)byte; + out = text.str(); + return true; +} + #if defined(_WIN32) std::wstring quote_windows_argument(const std::wstring& value) { if (value.find_first_of(L" \t\n\v\"") == std::wstring::npos) return value; @@ -382,9 +478,7 @@ int provider_package_get(void*, int index, RecompLauncherCModPackage* out) { copy_text(out->author, sizeof(out->author), package->author); copy_text(out->description, sizeof(out->description), package->description); copy_text(out->license, sizeof(out->license), package->license); - const auto selection = state().manager.selections().find(package->id); - out->enabled = selection != state().manager.selections().end() && - selection->second.enabled; + out->enabled = package_has_enabled_feature(*package); out->option_count = (int)package->options.size(); out->removable = !out->enabled; return 1; @@ -427,6 +521,201 @@ int provider_choice_get(void*, const char* package_id, const char* option_id, return 1; } +template +int mutate(Callback callback); + +bool provider_feature_at(int index, const ModPackage*& package, + const ModFeature*& feature) { + if (index < 0) return false; + for (const auto& [package_id, versions] : state().manager.packages()) { + (void)versions; + const ModPackage* selected = selected_package(package_id); + if (!selected) continue; + for (const ModFeature& candidate : selected->features) { + if (index-- == 0) { + package = selected; + feature = &candidate; + return true; + } + } + } + return false; +} + +std::vector provider_feature_options( + const ModPackage& package, const std::string& feature_id) { + std::vector out; + for (const ModOption& option : package.options) + if (option.feature_id == feature_id) out.push_back(&option); + return out; +} + +bool diagnostic_matches(const ModResolution::Diagnostic& diagnostic, + const std::string& package_id, + const std::string& feature_id) { + return (diagnostic.package_id == package_id && + diagnostic.feature_id == feature_id) || + (diagnostic.other_package_id == package_id && + diagnostic.other_feature_id == feature_id); +} + +int provider_feature_count(void*) { + int count = 0; + for (const auto& [package_id, versions] : state().manager.packages()) { + (void)versions; + const ModPackage* package = selected_package(package_id); + if (package) count += (int)package->features.size(); + } + return count; +} + +int provider_feature_get(void*, int index, RecompLauncherCModFeature* out) { + if (!out) return 0; + const ModPackage* package = nullptr; + const ModFeature* feature = nullptr; + if (!provider_feature_at(index, package, feature)) return 0; + std::memset(out, 0, sizeof(*out)); + copy_text(out->id, sizeof(out->id), feature->id); + copy_text(out->package_id, sizeof(out->package_id), package->id); + copy_text(out->package_version, sizeof(out->package_version), package->version); + copy_text(out->package_name, sizeof(out->package_name), package->name); + copy_text(out->name, sizeof(out->name), feature->name); + copy_text(out->author, sizeof(out->author), package->author); + copy_text(out->description, sizeof(out->description), feature->description); + copy_text(out->group, sizeof(out->group), feature->group); + out->enabled = + state().manager.feature_enabled(package->id, feature->id) ? 1 : 0; + out->option_count = + (int)provider_feature_options(*package, feature->id).size(); + for (const ModResolution::Diagnostic& diagnostic : + state().validation.diagnostics) { + if (!diagnostic_matches(diagnostic, package->id, feature->id)) continue; + out->has_error = 1; + copy_text(out->status, sizeof(out->status), diagnostic.message); + break; + } + return 1; +} + +int provider_feature_option_get(void*, const char* package_id, + const char* feature_id, int index, + RecompLauncherCModOption* out) { + if (!package_id || !feature_id || !out || index < 0) return 0; + const ModPackage* package = selected_package(package_id); + if (!package) return 0; + const auto options = provider_feature_options(*package, feature_id); + if ((size_t)index >= options.size()) return 0; + const ModOption& option = *options[(size_t)index]; + std::memset(out, 0, sizeof(*out)); + copy_text(out->id, sizeof(out->id), option.id); + copy_text(out->label, sizeof(out->label), option.label); + copy_text(out->description, sizeof(out->description), option.description); + copy_text(out->group, sizeof(out->group), option.group); + copy_text(out->value, sizeof(out->value), + state().manager.feature_option_value( + package_id, feature_id, option.id)); + copy_text(out->default_value, sizeof(out->default_value), + option.default_value); + out->type = option.type == ModOptionType::Boolean + ? RECOMP_MOD_OPTION_BOOLEAN + : option.type == ModOptionType::Choice + ? RECOMP_MOD_OPTION_CHOICE : RECOMP_MOD_OPTION_INTEGER; + out->min_value = option.min_value; + out->max_value = option.max_value; + out->step = option.step; + out->choice_count = (int)option.choices.size(); + return 1; +} + +int provider_feature_choice_get(void*, const char* package_id, + const char* feature_id, + const char* option_id, int index, + RecompLauncherCModChoice* out) { + if (!package_id || !feature_id || !option_id || !out || index < 0) + return 0; + const ModPackage* package = selected_package(package_id); + if (!package) return 0; + const auto option = std::find_if( + package->options.begin(), package->options.end(), + [&](const ModOption& value) { + return value.feature_id == feature_id && value.id == option_id; + }); + if (option == package->options.end() || + (size_t)index >= option->choices.size()) return 0; + std::memset(out, 0, sizeof(*out)); + copy_text(out->value, sizeof(out->value), + option->choices[(size_t)index].value); + copy_text(out->label, sizeof(out->label), + option->choices[(size_t)index].label); + return 1; +} + +int provider_feature_enable(void*, const char* package_id, + const char* feature_id, int enabled) { + if (!package_id || !feature_id) return 0; + return mutate([&](std::string& error) { + const ModFeature* feature = + state().manager.selected_feature(package_id, feature_id); + if (feature && feature->legacy) + return state().manager.set_enabled( + package_id, enabled != 0, &error); + return state().manager.set_feature_enabled( + package_id, feature_id, enabled != 0, &error); + }); +} + +int provider_feature_set_option(void*, const char* package_id, + const char* feature_id, + const char* option_id, + const char* value) { + if (!package_id || !feature_id || !option_id || !value) return 0; + return mutate([&](std::string& error) { + const ModFeature* feature = + state().manager.selected_feature(package_id, feature_id); + if (feature && feature->legacy) + return state().manager.set_option( + package_id, option_id, value, &error); + return state().manager.set_feature_option( + package_id, feature_id, option_id, value, &error); + }); +} + +int provider_diagnostic_count(void*, const char* package_id, + const char* feature_id) { + if (!package_id || !feature_id) return 0; + return (int)std::count_if( + state().validation.diagnostics.begin(), + state().validation.diagnostics.end(), + [&](const ModResolution::Diagnostic& diagnostic) { + return diagnostic_matches(diagnostic, package_id, feature_id); + }); +} + +int provider_diagnostic_get(void*, const char* package_id, + const char* feature_id, int index, + RecompLauncherCModDiagnostic* out) { + if (!package_id || !feature_id || !out || index < 0) return 0; + for (const ModResolution::Diagnostic& diagnostic : + state().validation.diagnostics) { + if (!diagnostic_matches(diagnostic, package_id, feature_id)) continue; + if (index-- != 0) continue; + std::memset(out, 0, sizeof(*out)); + out->severity = 2; + copy_text(out->resource, sizeof(out->resource), diagnostic.resource); + copy_text(out->message, sizeof(out->message), diagnostic.message); + const bool primary = diagnostic.package_id == package_id && + diagnostic.feature_id == feature_id; + copy_text(out->related_package_id, sizeof(out->related_package_id), + primary ? diagnostic.other_package_id : + diagnostic.package_id); + copy_text(out->related_feature_id, sizeof(out->related_feature_id), + primary ? diagnostic.other_feature_id : + diagnostic.feature_id); + return 1; + } + return 0; +} + int provider_version_count(void*, const char* package_id) { if (!package_id) return 0; const auto package = state().manager.packages().find(package_id); @@ -445,9 +734,8 @@ int provider_version_get(void*, const char* package_id, int index, copy_text(out->version, sizeof(out->version), version->first); const ModPackage* selected = selected_package(package_id); out->selected = selected && selected->version == version->first; - const auto selection = state().manager.selections().find(package_id); - out->removable = selection == state().manager.selections().end() || - !selection->second.enabled || !out->selected; + out->removable = !out->selected || + !selected || !package_has_enabled_feature(*selected); return 1; } @@ -458,6 +746,11 @@ int mutate(Callback callback) { set_error(error); return 0; } + if (!state().disc_path.empty()) + state().validation = state().manager.resolve( + state().game_id, state().exe_sha256, state().disc_sha256); + else + state().validation = {}; state().error.clear(); return 1; } @@ -530,6 +823,14 @@ RecompLauncherCModProvider provider = { provider_set_option, provider_commit, provider_error, + provider_feature_count, + provider_feature_get, + provider_feature_option_get, + provider_feature_choice_get, + provider_feature_enable, + provider_feature_set_option, + provider_diagnostic_count, + provider_diagnostic_get, }; #endif @@ -543,6 +844,11 @@ bool mod_runtime_initialize(const std::filesystem::path& root, RuntimeMods& s = state(); s.manager.set_root({}); s.plan = {}; + s.validation = {}; + s.raw_disc_index.clear(); + s.user_disc_index.clear(); + s.raw_overlay_index.clear(); + s.user_overlay_index.clear(); s.game_id.clear(); s.error.clear(); s.exe_sha256.clear(); @@ -583,6 +889,7 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err } ModResolution plan = s.manager.resolve(s.game_id, s.exe_sha256, s.disc_sha256); + s.validation = plan; if (!plan.ok) { s.error.clear(); for (const std::string& item : plan.errors) { @@ -592,6 +899,20 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err if (error) *error = s.error; return false; } + for (const ModResolution::Overlay& overlay : plan.overlays) { + if (overlay.expected_sha256.empty()) continue; + std::string actual; + if (!sha256_disc_range( + s.disc_path, overlay.target, overlay.location, + overlay.payload.size(), actual, &s.error) || + actual != overlay.expected_sha256) { + if (s.error.empty()) + s.error = overlay.package_id + "/" + overlay.feature_id + + ": stock overlay range checksum failed"; + if (error) *error = s.error; + return false; + } + } std::filesystem::path effective_disc; if (!materialize_derived_disc(s, plan, effective_disc, &s.error)) { if (error) *error = s.error; @@ -602,6 +923,7 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err return false; } s.plan = std::move(plan); + build_disc_index(s); s.effective_disc_path = std::move(effective_disc); s.main_applied = false; s.error.clear(); @@ -670,29 +992,68 @@ extern "C" void mod_runtime_patch_disc_sector(uint32_t lba, int raw_sector, RuntimeMods& s = state(); if (!s.initialized || !s.disc_enabled || s.disc_guard_failed || !bytes || size == 0) return; + /* Raw Mode2 Form1 reads are also the source of the 2048-byte logical + * stream consumed by the emulated CD controller. Apply raw claims to the + * complete sector, then user-data claims to its payload window. Form2/XA + * and CDDA sectors deliberately do not receive disc_user overlays. */ + const bool has_mode2_form1_user_data = + raw_sector && size >= 2072 && bytes[15] == 2 && + (bytes[18] & 0x20u) == 0; const ModPatchTarget target = raw_sector ? ModPatchTarget::DiscRaw : ModPatchTarget::DiscUser; const uint64_t base = (uint64_t)lba * size; const uint64_t end = base + size; - for (const ModResolution::Write& write : s.plan.writes) { - if (write.target != target || write.location < base || - write.location + write.replacement.size() > end) continue; - const size_t offset = (size_t)(write.location - base); - if (std::memcmp(bytes + offset, write.expected.data(), - write.expected.size()) != 0) { - std::fprintf(stderr, - "psxrecomp: disc mod plan %s rejected at LBA %u+%zu " - "(expected-byte guard failed; disc overlay disabled)\n", - s.plan.fingerprint.c_str(), lba, offset); - s.disc_guard_failed = true; - return; + const auto& index = raw_sector ? s.raw_disc_index : s.user_disc_index; + const auto sector = index.find(lba); + const auto& overlay_index = + raw_sector ? s.raw_overlay_index : s.user_overlay_index; + const auto overlay_sector = overlay_index.find(lba); + if (sector == index.end() && overlay_sector == overlay_index.end()) { + if (has_mode2_form1_user_data) + mod_runtime_patch_disc_sector(lba, 0, bytes + 24, 2048); + return; + } + if (sector != index.end()) { + for (size_t write_index : sector->second) { + const ModResolution::Write& write = s.plan.writes[write_index]; + if (write.target != target || write.location < base || + write.location + write.replacement.size() > end) continue; + const size_t offset = (size_t)(write.location - base); + if (std::memcmp(bytes + offset, write.expected.data(), + write.expected.size()) != 0) { + std::fprintf(stderr, + "psxrecomp: disc mod plan %s rejected at LBA %u+%zu " + "(expected-byte guard failed; disc overlay disabled)\n", + s.plan.fingerprint.c_str(), lba, offset); + s.disc_guard_failed = true; + return; + } + } + for (size_t write_index : sector->second) { + const ModResolution::Write& write = s.plan.writes[write_index]; + if (write.target != target || write.location < base || + write.location + write.replacement.size() > end) continue; + const size_t offset = (size_t)(write.location - base); + std::memcpy(bytes + offset, write.replacement.data(), + write.replacement.size()); } } - for (const ModResolution::Write& write : s.plan.writes) { - if (write.target != target || write.location < base || - write.location + write.replacement.size() > end) continue; - const size_t offset = (size_t)(write.location - base); - std::memcpy(bytes + offset, write.replacement.data(), - write.replacement.size()); + if (overlay_sector != overlay_index.end()) { + for (size_t overlay_index_value : overlay_sector->second) { + const ModResolution::Overlay& overlay = + s.plan.overlays[overlay_index_value]; + const uint64_t overlay_end = + overlay.location + overlay.payload.size(); + const uint64_t copy_begin = std::max(base, overlay.location); + const uint64_t copy_end = std::min(end, overlay_end); + if (copy_begin >= copy_end) continue; + const size_t destination = (size_t)(copy_begin - base); + const size_t source = (size_t)(copy_begin - overlay.location); + const size_t count = (size_t)(copy_end - copy_begin); + std::memcpy(bytes + destination, + overlay.payload.data() + source, count); + } } + if (has_mode2_form1_user_data && !s.disc_guard_failed) + mod_runtime_patch_disc_sector(lba, 0, bytes + 24, 2048); } diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp index 196806834..55599a405 100644 --- a/runtime/tests/test_mod_packages.cpp +++ b/runtime/tests/test_mod_packages.cpp @@ -26,6 +26,24 @@ static void write_text(const fs::path& path, const std::string& text) { out << text; } +static void write_bytes(const fs::path& path, const std::vector& bytes) { + fs::create_directories(path.parent_path()); + std::ofstream out(path, std::ios::binary); + out.write((const char*)bytes.data(), (std::streamsize)bytes.size()); +} + +static std::string sha256_hex(const std::vector& bytes) { + uint8_t digest[32]; + psx_sha256_compute(bytes.data(), bytes.size(), digest); + static const char hex[] = "0123456789abcdef"; + std::string out(64, '0'); + for (size_t i = 0; i < 32; ++i) { + out[i * 2] = hex[digest[i] >> 4]; + out[i * 2 + 1] = hex[digest[i] & 15]; + } + return out; +} + static void write_deflated_package(const fs::path& path) { static const char* compressed_hex = "4bcb2fca4d2c892f4b2d2acecccf53b05530e4ca4c01524a5599057ab9f929" @@ -240,6 +258,180 @@ int main() { matrix.derived_discs[0].output_size == 222222, "multi-option derived-disc condition must match selected values"); + write_text(root / "packages/features.mod/1.0.0/manifest.toml", + manifest("features.mod", "1.0.0", + "\n[[feature]]\n" + "id = \"title-screen\"\n" + "name = \"Title Screen\"\n" + "group = \"Localization\"\n" + "\n[[feature]]\n" + "id = \"retranslation\"\n" + "name = \"Retranslation\"\n" + "group = \"Localization\"\n" + "\n[[feature]]\n" + "id = \"title-collision\"\n" + "name = \"Title Collision\"\n" + "\n[[feature]]\n" + "id = \"title-identical\"\n" + "name = \"Title Identical\"\n" + "\n[[option]]\n" + "feature = \"title-screen\"\n" + "id = \"variant\"\n" + "label = \"Variant\"\n" + "type = \"choice\"\n" + "default = \"usa\"\n" + "[[option.choice]]\n" + "value = \"usa\"\n" + "label = \"Mega Man X6\"\n" + "[[option.choice]]\n" + "value = \"japan\"\n" + "label = \"Rockman X6\"\n" + "\n[[option]]\n" + "feature = \"retranslation\"\n" + "id = \"variant\"\n" + "label = \"Variant\"\n" + "type = \"boolean\"\n" + "default = \"true\"\n" + "\n[[patch]]\n" + "feature = \"title-screen\"\n" + "target = \"main_exe\"\n" + "address = 2147495936\n" + "expected = \"0102\"\n" + "replace = \"a1a2\"\n" + "when = { variant = \"japan\" }\n" + "\n[[patch]]\n" + "feature = \"retranslation\"\n" + "target = \"disc_raw\"\n" + "offset = 23520\n" + "expected = \"03\"\n" + "replace = \"b3\"\n" + "when = { variant = \"true\" }\n" + "\n[[patch]]\n" + "feature = \"title-collision\"\n" + "target = \"main_exe\"\n" + "address = 2147495937\n" + "expected = \"02\"\n" + "replace = \"ff\"\n" + "\n[[patch]]\n" + "feature = \"title-identical\"\n" + "target = \"main_exe\"\n" + "address = 2147495936\n" + "expected = \"0102\"\n" + "replace = \"a1a2\"\n")); + check(reload.scan(&error), error.c_str()); + check(!reload.set_enabled("features.mod", true, &error), + "feature-style package must not expose package enablement"); + check(reload.set_feature_option( + "features.mod", "title-screen", "variant", "japan", &error), + error.c_str()); + check(reload.set_feature_enabled( + "features.mod", "title-screen", true, &error), error.c_str()); + check(reload.set_feature_enabled( + "features.mod", "retranslation", true, &error), error.c_str()); + ModResolution features = reload.resolve("SLUS-TEST"); + check(features.ok && features.writes.size() == 2, + "independently enabled features must compose their operations"); + check(features.ok && features.writes[0].feature_id == "title-screen" && + features.writes[1].feature_id == "retranslation", + "resolved writes must retain feature ownership"); + check(reload.set_feature_enabled( + "features.mod", "title-collision", true, &error), error.c_str()); + ModResolution collision = reload.resolve("SLUS-TEST"); + check(!collision.ok && collision.diagnostics.size() == 1, + "overlapping feature writes must produce a structured diagnostic"); + check(!collision.diagnostics.empty() && + collision.diagnostics[0].feature_id == "title-collision" && + collision.diagnostics[0].other_feature_id == "title-screen" && + !collision.diagnostics[0].resource.empty(), + "collision diagnostic must identify both features and the resource"); + check(reload.set_feature_enabled( + "features.mod", "title-collision", false, &error), error.c_str()); + check(reload.set_feature_enabled( + "features.mod", "title-identical", true, &error), error.c_str()); + ModResolution identical = reload.resolve("SLUS-TEST"); + check(identical.ok && identical.writes.size() == 2, + "truly identical writes must coalesce deterministically"); + check(reload.save_state(&error), error.c_str()); + ModPackageManager feature_reload(root); + check(feature_reload.scan(&error), error.c_str()); + check(feature_reload.load_state(&error), error.c_str()); + check(feature_reload.feature_enabled("features.mod", "title-screen") && + feature_reload.feature_enabled("features.mod", "retranslation") && + !feature_reload.feature_enabled("features.mod", "title-collision"), + "per-feature enabled state must survive save/reload"); + check(feature_reload.feature_option_value( + "features.mod", "title-screen", "variant") == "japan", + "feature-scoped option values must survive save/reload"); + check(feature_reload.resolve("SLUS-TEST").fingerprint == + identical.fingerprint, + "feature state must resolve deterministically after reload"); + + const std::vector overlay_a = {1, 2, 3, 4}; + const std::vector overlay_b = {8, 9}; + const std::string overlay_disc_hash(64, '4'); + write_bytes(root / "packages/overlay.mod/1.0.0/assets/a.bin", overlay_a); + write_bytes(root / "packages/overlay.mod/1.0.0/assets/b.bin", overlay_b); + write_text(root / "packages/overlay.mod/1.0.0/manifest.toml", + manifest("overlay.mod", "1.0.0", + "disc_sha256 = \"" + overlay_disc_hash + "\"\n" + "[[feature]]\n" + "id = \"asset-a\"\n" + "name = \"Asset A\"\n" + "[[feature]]\n" + "id = \"asset-b\"\n" + "name = \"Asset B\"\n" + "[[overlay]]\n" + "feature = \"asset-a\"\n" + "target = \"disc_raw\"\n" + "offset = 100\n" + "file = \"assets/a.bin\"\n" + "sha256 = \"" + sha256_hex(overlay_a) + "\"\n" + "[[overlay]]\n" + "feature = \"asset-b\"\n" + "target = \"disc_raw\"\n" + "offset = 102\n" + "file = \"assets/b.bin\"\n" + "sha256 = \"" + sha256_hex(overlay_b) + "\"\n")); + check(feature_reload.scan(&error), error.c_str()); + const ModPackage* overlay_package = + feature_reload.selected_package("overlay.mod"); + check(overlay_package && overlay_package->overlays.size() == 2 && + overlay_package->overlays[0].size == overlay_a.size(), + "manifest scan must verify and retain overlay metadata"); + check(feature_reload.set_feature_enabled( + "overlay.mod", "asset-a", true, &error), error.c_str()); + check(feature_reload.set_feature_enabled( + "overlay.mod", "asset-b", true, &error), error.c_str()); + ModResolution overlay_collision = + feature_reload.resolve("SLUS-TEST", {}, overlay_disc_hash); + check(!overlay_collision.ok && + overlay_collision.diagnostics.size() == 1 && + overlay_collision.diagnostics[0].feature_id == "asset-b" && + overlay_collision.diagnostics[0].other_feature_id == "asset-a", + "overlapping file overlays must identify both owning features"); + check(feature_reload.set_feature_enabled( + "overlay.mod", "asset-b", false, &error), error.c_str()); + ModResolution one_overlay = + feature_reload.resolve("SLUS-TEST", {}, overlay_disc_hash); + check(one_overlay.ok && one_overlay.overlays.size() == 1 && + one_overlay.overlays[0].payload == overlay_a, + "only enabled overlay payloads must enter the resolved plan"); + + write_text(root / "feature-derived.toml", + manifest("bad.derived", "1.0.0", + "\n[[feature]]\n" + "id = \"bad\"\n" + "name = \"Bad\"\n" + "[[derived_disc]]\n" + "patch = \"bad.xdelta3\"\n" + "patch_sha256 = \"0000000000000000000000000000000000000000000000000000000000000000\"\n" + "output_size = 1\n" + "output_sha256 = \"1111111111111111111111111111111111111111111111111111111111111111\"\n")); + ModPackage feature_derived; + check(!ModPackageManager::read_manifest( + root / "feature-derived.toml", feature_derived, &error), + "feature-style packages must reject derived-disc operations"); + ModPackage invalid; write_text(root / "bad.toml", "format_version=1\nid=\"../bad\"\nversion=\"1.0.0\"\nname=\"Bad\"\n" diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index e8bacfc8f..5ae841b9e 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -1,4 +1,5 @@ #include "mod_runtime.h" +#include "psx_sha256.h" #include #include @@ -6,6 +7,7 @@ #include #include #include +#include namespace fs = std::filesystem; @@ -35,10 +37,36 @@ static void write_text(const fs::path& path, const std::string& text) { out << text; } +static void write_bytes(const fs::path& path, const std::vector& bytes) { + fs::create_directories(path.parent_path()); + std::ofstream out(path, std::ios::binary); + out.write((const char*)bytes.data(), (std::streamsize)bytes.size()); +} + +static std::string sha256_hex(const std::vector& bytes) { + uint8_t digest[32]; + psx_sha256_compute(bytes.data(), bytes.size(), digest); + static const char hex[] = "0123456789abcdef"; + std::string out(64, '0'); + for (size_t i = 0; i < 32; ++i) { + out[i * 2] = hex[digest[i] >> 4]; + out[i * 2 + 1] = hex[digest[i] & 15]; + } + return out; +} + int main() { const fs::path root = fs::temp_directory_path() / "psxrecomp-mod-runtime-test"; std::error_code ec; fs::remove_all(root, ec); + const std::vector stock(8 * 2352, 0); + std::vector overlay(3000); + for (size_t i = 0; i < overlay.size(); ++i) + overlay[i] = (uint8_t)(i * 17u + 3u); + const fs::path stock_path = root / "stock.bin"; + write_bytes(stock_path, stock); + write_bytes(root / "packages/runtime.test/1.0.0/assets/overlay.bin", + overlay); write_text(root / "packages/runtime.test/1.0.0/manifest.toml", "format_version = 1\n" "id = \"runtime.test\"\n" @@ -46,28 +74,72 @@ int main() { "name = \"Runtime Test\"\n" "[[target]]\n" "game_id = \"SLUS-RUNTIME\"\n" + "disc_sha256 = \"" + sha256_hex(stock) + "\"\n" + "[[feature]]\n" + "id = \"main-code\"\n" + "name = \"Main Code\"\n" + "[[feature]]\n" + "id = \"disc-byte\"\n" + "name = \"Disc Byte\"\n" + "[[feature]]\n" + "id = \"asset-overlay\"\n" + "name = \"Asset Overlay\"\n" + "[[feature]]\n" + "id = \"user-byte\"\n" + "name = \"User Byte\"\n" "[[patch]]\n" + "feature = \"main-code\"\n" "target = \"main_exe\"\n" "address = 2147487744\n" "expected = \"01020304\"\n" "replace = \"a1a2a3a4\"\n" "[[patch]]\n" + "feature = \"disc-byte\"\n" "target = \"disc_raw\"\n" "offset = 4714\n" "expected = \"aa\"\n" - "replace = \"bb\"\n"); + "replace = \"bb\"\n" + "[[patch]]\n" + "feature = \"user-byte\"\n" + "target = \"disc_user\"\n" + "offset = 6154\n" + "expected = \"cc\"\n" + "replace = \"dd\"\n" + "[[overlay]]\n" + "feature = \"asset-overlay\"\n" + "target = \"disc_raw\"\n" + "offset = 11408\n" + "file = \"assets/overlay.bin\"\n" + "sha256 = \"" + sha256_hex(overlay) + "\"\n" + "expected_sha256 = \"" + + sha256_hex(std::vector(overlay.size(), 0)) + "\"\n"); write_text(root / "state.toml", - "format_version = 1\n" + "format_version = 2\n" "[[package]]\n" "id = \"runtime.test\"\n" + "version = \"1.0.0\"\n" + "[[feature]]\n" + "package_id = \"runtime.test\"\n" + "id = \"main-code\"\n" + "enabled = true\n" + "[[feature]]\n" + "package_id = \"runtime.test\"\n" + "id = \"disc-byte\"\n" "enabled = true\n" - "version = \"1.0.0\"\n"); + "[[feature]]\n" + "package_id = \"runtime.test\"\n" + "id = \"asset-overlay\"\n" + "enabled = true\n" + "[[feature]]\n" + "package_id = \"runtime.test\"\n" + "id = \"user-byte\"\n" + "enabled = true\n"); std::string error; check(PSXRecompV4::mod_runtime_initialize( root, "SLUS-RUNTIME", 0x80002000, {}, &error), error.c_str()); - check(PSXRecompV4::mod_runtime_commit({}, &error), error.c_str()); + check(PSXRecompV4::mod_runtime_commit(stock_path, &error), error.c_str()); ram[0x1000] = 1; ram[0x1001] = 2; ram[0x1002] = 3; ram[0x1003] = 4; mod_runtime_on_dispatch(0x80001000); @@ -84,6 +156,42 @@ int main() { mod_runtime_patch_disc_sector(2, 1, sector.data(), (uint32_t)sector.size()); check(sector[10] == 0xbb, "raw disc overlay must patch matching sectors"); + std::array overlay_sector{}; + mod_runtime_patch_disc_sector( + 4, 1, overlay_sector.data(), (uint32_t)overlay_sector.size()); + check(overlay_sector[1999] == 0 && + overlay_sector[2000] == overlay[0] && + overlay_sector[2351] == overlay[351], + "file overlay must patch the tail of its first sector"); + overlay_sector.fill(0); + mod_runtime_patch_disc_sector( + 5, 1, overlay_sector.data(), (uint32_t)overlay_sector.size()); + check(overlay_sector.front() == overlay[352] && + overlay_sector.back() == overlay[2703], + "file overlay must patch complete middle sectors"); + overlay_sector.fill(0); + mod_runtime_patch_disc_sector( + 6, 1, overlay_sector.data(), (uint32_t)overlay_sector.size()); + check(overlay_sector[0] == overlay[2704] && + overlay_sector[295] == overlay[2999] && + overlay_sector[296] == 0, + "file overlay must patch the head of its final sector"); + + std::array mode2_sector{}; + mode2_sector[15] = 2; + mode2_sector[18] = 0; + mode2_sector[24 + 10] = 0xcc; + mod_runtime_patch_disc_sector( + 3, 1, mode2_sector.data(), (uint32_t)mode2_sector.size()); + check(mode2_sector[24 + 10] == 0xdd, + "disc_user operations must apply to raw Mode2 Form1 user data"); + std::array audio_sector{}; + audio_sector[24 + 10] = 0xcc; + mod_runtime_patch_disc_sector( + 3, 1, audio_sector.data(), (uint32_t)audio_sector.size()); + check(audio_sector[24 + 10] == 0xcc, + "disc_user operations must not modify CDDA/non-data sectors"); + fs::remove_all(root, ec); if (failures) return 1; std::cout << "mod runtime tests passed\n"; From 7a7dc5e27696ff9a873b383b57e62b88f320ed92 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 23 Jul 2026 09:38:33 -0700 Subject: [PATCH 07/12] fix: compose compatible partial mod overlaps --- docs/MOD_PACKAGES.md | 6 +++ runtime/src/mod_packages.cpp | 77 +++++++++++++++++++++++++---- runtime/tests/test_mod_packages.cpp | 70 +++++++++++++++++++++++--- 3 files changed, 137 insertions(+), 16 deletions(-) diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index c583b8527..71d8d455c 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -155,6 +155,12 @@ Incompatible overlaps fail before launch. Structured diagnostics identify both marks both feature rows and lets the user decide what to disable. It never silently chooses a winner. +Operation boundaries are not semantic boundaries. Partially overlapping writes +compose when both their expected and replacement bytes agree throughout the +intersection; partially overlapping overlays compose when their replacement +payload bytes agree. A differing byte produces a diagnostic at that exact +location. Exact duplicate operations may be coalesced. + Package-level dependencies and conflicts are reserved for actual implementation relationships. Mutually exclusive choices such as US versus Japanese artwork belong inside one feature as option values. diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index aad03e2b1..395d1687b 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -755,6 +755,58 @@ bool identical_write(const ModResolution::Write& a, const ModResolution::Write& a.expected == b.expected && a.replacement == b.replacement; } +bool write_overlap_mismatch(const ModResolution::Write& a, + const ModResolution::Write& b, + uint64_t& mismatch) { + const uint64_t begin = std::max(a.location, b.location); + const uint64_t end = std::min( + a.location + a.replacement.size(), + b.location + b.replacement.size()); + for (uint64_t at = begin; at < end; ++at) { + const size_t ai = (size_t)(at - a.location); + const size_t bi = (size_t)(at - b.location); + if (a.expected[ai] != b.expected[bi] || + a.replacement[ai] != b.replacement[bi]) { + mismatch = at; + return true; + } + } + return false; +} + +bool write_overlay_mismatch(const ModResolution::Write& write, + const ModResolution::Overlay& overlay, + uint64_t& mismatch) { + const uint64_t begin = std::max(write.location, overlay.location); + const uint64_t end = std::min( + write.location + write.replacement.size(), + overlay.location + overlay.payload.size()); + for (uint64_t at = begin; at < end; ++at) { + if (write.replacement[(size_t)(at - write.location)] != + overlay.payload[(size_t)(at - overlay.location)]) { + mismatch = at; + return true; + } + } + return false; +} + +bool overlay_overlap_mismatch(const ModResolution::Overlay& a, + const ModResolution::Overlay& b, + uint64_t& mismatch) { + const uint64_t begin = std::max(a.location, b.location); + const uint64_t end = std::min( + a.location + a.payload.size(), b.location + b.payload.size()); + for (uint64_t at = begin; at < end; ++at) { + if (a.payload[(size_t)(at - a.location)] != + b.payload[(size_t)(at - b.location)]) { + mismatch = at; + return true; + } + } + return false; +} + std::string overlap_resource(ModPatchTarget target, uint64_t a_location, size_t a_size, uint64_t b_location, size_t b_size) { @@ -769,6 +821,10 @@ std::string overlap_resource(ModPatchTarget target, return out.str(); } +std::string byte_resource(ModPatchTarget target, uint64_t location) { + return overlap_resource(target, location, 1, location, 1); +} + bool valid_option_value(const ModOption& option, const std::string& value) { if (option.type == ModOptionType::Boolean) return value == "true" || value == "false"; @@ -1761,10 +1817,11 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, break; } if (!writes_overlap(previous, write)) continue; + uint64_t mismatch = 0; + if (!write_overlap_mismatch(previous, write, mismatch)) + continue; ModResolution::Diagnostic diagnostic; - diagnostic.resource = overlap_resource( - write.target, write.location, write.replacement.size(), - previous.location, previous.replacement.size()); + diagnostic.resource = byte_resource(write.target, mismatch); diagnostic.package_id = write.package_id; diagnostic.feature_id = write.feature_id; diagnostic.other_package_id = previous.package_id; @@ -1790,10 +1847,11 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, overlay.target, overlay.location, overlay.payload.size(), write.target, write.location, write.replacement.size())) continue; + uint64_t mismatch = 0; + if (!write_overlay_mismatch(write, overlay, mismatch)) + continue; ModResolution::Diagnostic diagnostic; - diagnostic.resource = overlap_resource( - overlay.target, overlay.location, overlay.payload.size(), - write.location, write.replacement.size()); + diagnostic.resource = byte_resource(overlay.target, mismatch); diagnostic.package_id = overlay.package_id; diagnostic.feature_id = overlay.feature_id; diagnostic.other_package_id = write.package_id; @@ -1820,10 +1878,11 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, claimed = true; break; } + uint64_t mismatch = 0; + if (!overlay_overlap_mismatch(previous, overlay, mismatch)) + continue; ModResolution::Diagnostic diagnostic; - diagnostic.resource = overlap_resource( - overlay.target, overlay.location, overlay.payload.size(), - previous.location, previous.payload.size()); + diagnostic.resource = byte_resource(overlay.target, mismatch); diagnostic.package_id = overlay.package_id; diagnostic.feature_id = overlay.feature_id; diagnostic.other_package_id = previous.package_id; diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp index 55599a405..3d947665f 100644 --- a/runtime/tests/test_mod_packages.cpp +++ b/runtime/tests/test_mod_packages.cpp @@ -274,6 +274,12 @@ int main() { "\n[[feature]]\n" "id = \"title-identical\"\n" "name = \"Title Identical\"\n" + "\n[[feature]]\n" + "id = \"title-partial-compatible\"\n" + "name = \"Title Partial Compatible\"\n" + "\n[[feature]]\n" + "id = \"title-partial-conflict\"\n" + "name = \"Title Partial Conflict\"\n" "\n[[option]]\n" "feature = \"title-screen\"\n" "id = \"variant\"\n" @@ -317,7 +323,19 @@ int main() { "target = \"main_exe\"\n" "address = 2147495936\n" "expected = \"0102\"\n" - "replace = \"a1a2\"\n")); + "replace = \"a1a2\"\n" + "\n[[patch]]\n" + "feature = \"title-partial-compatible\"\n" + "target = \"main_exe\"\n" + "address = 2147495937\n" + "expected = \"0209\"\n" + "replace = \"a2c9\"\n" + "\n[[patch]]\n" + "feature = \"title-partial-conflict\"\n" + "target = \"main_exe\"\n" + "address = 2147495937\n" + "expected = \"ff09\"\n" + "replace = \"a2c9\"\n")); check(reload.scan(&error), error.c_str()); check(!reload.set_enabled("features.mod", true, &error), "feature-style package must not expose package enablement"); @@ -351,6 +369,25 @@ int main() { ModResolution identical = reload.resolve("SLUS-TEST"); check(identical.ok && identical.writes.size() == 2, "truly identical writes must coalesce deterministically"); + check(reload.set_feature_enabled( + "features.mod", "title-partial-compatible", true, &error), + error.c_str()); + ModResolution partial_compatible = reload.resolve("SLUS-TEST"); + check(partial_compatible.ok && partial_compatible.writes.size() == 3, + "partially overlapping writes with matching expected and replacement " + "bytes must compose"); + check(reload.set_feature_enabled( + "features.mod", "title-partial-conflict", true, &error), + error.c_str()); + ModResolution partial_conflict = reload.resolve("SLUS-TEST"); + check(!partial_conflict.ok && !partial_conflict.diagnostics.empty() && + partial_conflict.diagnostics[0].resource == + "main_exe:0x80003001-0x80003002", + "one differing expected byte in a partial overlap must identify " + "the exact contested byte"); + check(reload.set_feature_enabled( + "features.mod", "title-partial-conflict", false, &error), + error.c_str()); check(reload.save_state(&error), error.c_str()); ModPackageManager feature_reload(root); check(feature_reload.scan(&error), error.c_str()); @@ -363,14 +400,16 @@ int main() { "features.mod", "title-screen", "variant") == "japan", "feature-scoped option values must survive save/reload"); check(feature_reload.resolve("SLUS-TEST").fingerprint == - identical.fingerprint, + partial_compatible.fingerprint, "feature state must resolve deterministically after reload"); const std::vector overlay_a = {1, 2, 3, 4}; const std::vector overlay_b = {8, 9}; + const std::vector overlay_c = {3, 4, 7}; const std::string overlay_disc_hash(64, '4'); write_bytes(root / "packages/overlay.mod/1.0.0/assets/a.bin", overlay_a); write_bytes(root / "packages/overlay.mod/1.0.0/assets/b.bin", overlay_b); + write_bytes(root / "packages/overlay.mod/1.0.0/assets/c.bin", overlay_c); write_text(root / "packages/overlay.mod/1.0.0/manifest.toml", manifest("overlay.mod", "1.0.0", "disc_sha256 = \"" + overlay_disc_hash + "\"\n" @@ -380,6 +419,9 @@ int main() { "[[feature]]\n" "id = \"asset-b\"\n" "name = \"Asset B\"\n" + "[[feature]]\n" + "id = \"asset-c\"\n" + "name = \"Asset C\"\n" "[[overlay]]\n" "feature = \"asset-a\"\n" "target = \"disc_raw\"\n" @@ -391,15 +433,28 @@ int main() { "target = \"disc_raw\"\n" "offset = 102\n" "file = \"assets/b.bin\"\n" - "sha256 = \"" + sha256_hex(overlay_b) + "\"\n")); + "sha256 = \"" + sha256_hex(overlay_b) + "\"\n" + "[[overlay]]\n" + "feature = \"asset-c\"\n" + "target = \"disc_raw\"\n" + "offset = 102\n" + "file = \"assets/c.bin\"\n" + "sha256 = \"" + sha256_hex(overlay_c) + "\"\n")); check(feature_reload.scan(&error), error.c_str()); const ModPackage* overlay_package = feature_reload.selected_package("overlay.mod"); - check(overlay_package && overlay_package->overlays.size() == 2 && + check(overlay_package && overlay_package->overlays.size() == 3 && overlay_package->overlays[0].size == overlay_a.size(), "manifest scan must verify and retain overlay metadata"); check(feature_reload.set_feature_enabled( "overlay.mod", "asset-a", true, &error), error.c_str()); + check(feature_reload.set_feature_enabled( + "overlay.mod", "asset-c", true, &error), error.c_str()); + ModResolution overlay_compatible = + feature_reload.resolve("SLUS-TEST", {}, overlay_disc_hash); + check(overlay_compatible.ok && overlay_compatible.overlays.size() == 2, + "partially overlapping file overlays with matching payload bytes " + "must compose"); check(feature_reload.set_feature_enabled( "overlay.mod", "asset-b", true, &error), error.c_str()); ModResolution overlay_collision = @@ -413,9 +468,10 @@ int main() { "overlay.mod", "asset-b", false, &error), error.c_str()); ModResolution one_overlay = feature_reload.resolve("SLUS-TEST", {}, overlay_disc_hash); - check(one_overlay.ok && one_overlay.overlays.size() == 1 && - one_overlay.overlays[0].payload == overlay_a, - "only enabled overlay payloads must enter the resolved plan"); + check(one_overlay.ok && one_overlay.overlays.size() == 2 && + one_overlay.overlays[0].payload == overlay_a && + one_overlay.overlays[1].payload == overlay_c, + "compatible enabled overlay payloads must remain in the plan"); write_text(root / "feature-derived.toml", manifest("bad.derived", "1.0.0", From 47eeadef6f94728aecb44d8ed4789b69dbc73bda Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 23 Jul 2026 15:06:13 -0700 Subject: [PATCH 08/12] feat: add bounded integer mod patches --- docs/MOD_PACKAGES.md | 55 ++++++++ runtime/include/mod_packages.h | 9 ++ runtime/src/mod_packages.cpp | 208 +++++++++++++++++++++++++--- runtime/tests/test_mod_packages.cpp | 200 ++++++++++++++++++++++++++ runtime/tests/test_mod_runtime.cpp | 50 ++++++- 5 files changed, 503 insertions(+), 19 deletions(-) diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index 71d8d455c..0d605e928 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -83,6 +83,61 @@ feature-local: `when = { option = "value", ... }` requires every listed option to match. The legacy `when_option`/`when_value` pair remains accepted for a single condition. +## Bounded integer patches + +Package format 2 can encode a bounded integer option directly into a guarded +write: + +```toml +format_version = 2 + +[[feature]] +id = "starting-lives" +name = "Starting Lives" + +[[option]] +feature = "starting-lives" +id = "count" +label = "Lives" +type = "integer" +min = 0 +max = 99 +step = 1 +default = 2 + +[[patch]] +feature = "starting-lives" +target = "main_exe" +address = 0x8001DE64 +expected = "02 00" +replace_from = { option = "count", encoding = "u16le" } + +[[patch]] +feature = "starting-lives" +target = "main_exe" +address = 0x8001DE70 +expected = "03 00" +replace_from = { option = "count", encoding = "u16le", addend = 1 } +``` + +`replace_from` and literal `replace` are mutually exclusive. The referenced +option must be a bounded integer owned by the same feature. The initial +encodings are `u8`, `u16le`, and `u32le`; the expected guard must have exactly +the selected width. `addend` is the only supported transform, and the complete +declared option range after that addend must fit the unsigned encoding. + +There is deliberately no host-endian encoding, signed inference, mask, shift, +scale, expression language, or partial-field merge. A package uses multiple +guarded `[[patch]]` entries when the same value has multiple destinations. +Generated bytes enter the ordinary pre-boot write plan, collision checks, and +fingerprint. A generated value identical to the stock guard is omitted as a +no-op, so an enabled stock-valued option does not claim or conflict on bytes it +does not change. + +Integer values use canonical decimal text. Leading plus signs, redundant +leading zeroes, values outside the bounds, and values not aligned to `step` are +rejected. + ## Native operations `main_exe` writes use PSX guest virtual addresses. Expected bytes are checked diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index 5a677527b..f129e2cec 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -60,12 +60,21 @@ enum class ModPatchTarget { DiscUser, }; +enum class ModValueEncoding { + U8, + U16LE, + U32LE, +}; + struct ModPatch { std::string feature_id; ModPatchTarget target = ModPatchTarget::MainExe; uint64_t location = 0; /* guest address or canonical disc-stream byte offset */ std::vector expected; std::vector replacement; + std::string replace_from_option; + ModValueEncoding replace_encoding = ModValueEncoding::U8; + int64_t replace_addend = 0; std::string when_option; std::string when_value; std::map when; diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 395d1687b..1f275db24 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -20,7 +20,8 @@ namespace fs = std::filesystem; namespace PSXRecompV4 { namespace { -constexpr uint32_t kFormatVersion = 1; +constexpr uint32_t kMinFormatVersion = 1; +constexpr uint32_t kMaxFormatVersion = 2; constexpr uint64_t kMaxArchiveBytes = 256ull * 1024ull * 1024ull; constexpr uint32_t kMaxArchiveFiles = 4096; @@ -73,6 +74,33 @@ bool parse_hex_bytes(const std::string& text, std::vector& out) { return true; } +bool parse_value_encoding(const std::string& text, ModValueEncoding& out) { + static const std::pair values[] = { + {"u8", ModValueEncoding::U8}, + {"u16le", ModValueEncoding::U16LE}, + {"u32le", ModValueEncoding::U32LE}, + }; + for (const auto& [name, encoding] : values) { + if (text == name) { + out = encoding; + return true; + } + } + return false; +} + +uint32_t value_encoding_size(ModValueEncoding encoding) { + switch (encoding) { + case ModValueEncoding::U8: + return 1; + case ModValueEncoding::U16LE: + return 2; + case ModValueEncoding::U32LE: + return 4; + } + return 0; +} + std::string hex_bytes(const std::vector& bytes) { std::ostringstream out; for (uint8_t byte : bytes) @@ -702,6 +730,8 @@ bool conditions_match(const ModPackage& package, const ModSelection& selection, return true; } +bool valid_option_value(const ModOption& option, const std::string& value); + void read_conditions(const toml::value& value, const std::vector& options, const std::string& feature_id, std::map& when, @@ -732,6 +762,9 @@ void read_conditions(const toml::value& value, const std::vector& opt }); if (option == options.end()) throw std::runtime_error(std::string(label) + " references unknown option"); + if (!valid_option_value(*option, condition_value)) + throw std::runtime_error( + std::string(label) + " condition has invalid option value"); } } @@ -825,21 +858,77 @@ std::string byte_resource(ModPatchTarget target, uint64_t location) { return overlap_resource(target, location, 1, location, 1); } +bool parse_canonical_int64(const std::string& value, int64_t& parsed) { + try { + size_t used = 0; + parsed = std::stoll(value, &used); + return used == value.size() && std::to_string(parsed) == value; + } catch (...) { + return false; + } +} + +bool integer_step_aligned(int64_t value, int64_t minimum, int64_t step) { + if (value < minimum || step <= 0) return false; + const uint64_t distance = + static_cast(value) - static_cast(minimum); + return distance % static_cast(step) == 0; +} + bool valid_option_value(const ModOption& option, const std::string& value) { if (option.type == ModOptionType::Boolean) return value == "true" || value == "false"; if (option.type == ModOptionType::Choice) return std::any_of(option.choices.begin(), option.choices.end(), [&](const ModChoice& choice) { return choice.value == value; }); - try { - size_t used = 0; - const int64_t parsed = std::stoll(value, &used); - return used == value.size() && parsed >= option.min_value && - parsed <= option.max_value && - ((parsed - option.min_value) % option.step) == 0; - } catch (...) { + int64_t parsed = 0; + return parse_canonical_int64(value, parsed) && + parsed >= option.min_value && parsed <= option.max_value && + integer_step_aligned(parsed, option.min_value, option.step); +} + +bool checked_add_int64(int64_t left, int64_t right, int64_t& out) { + if ((right > 0 && left > std::numeric_limits::max() - right) || + (right < 0 && left < std::numeric_limits::min() - right)) return false; + out = left + right; + return true; +} + +uint64_t value_encoding_max(ModValueEncoding encoding) { + switch (encoding) { + case ModValueEncoding::U8: + return UINT8_MAX; + case ModValueEncoding::U16LE: + return UINT16_MAX; + case ModValueEncoding::U32LE: + return UINT32_MAX; } + return 0; +} + +bool option_range_fits_encoding(const ModOption& option, + ModValueEncoding encoding, + int64_t addend) { + int64_t low = 0; + int64_t high = 0; + return checked_add_int64(option.min_value, addend, low) && + checked_add_int64(option.max_value, addend, high) && + low >= 0 && + static_cast(high) <= value_encoding_max(encoding); +} + +bool encode_unsigned_value(ModValueEncoding encoding, int64_t value, + std::vector& out) { + if (value < 0 || + static_cast(value) > value_encoding_max(encoding)) + return false; + const uint32_t size = value_encoding_size(encoding); + out.resize(size); + const uint64_t encoded = static_cast(value); + for (uint32_t i = 0; i < size; ++i) + out[i] = static_cast(encoded >> (i * 8)); + return true; } std::string fingerprint_text(const std::string& text) { @@ -888,7 +977,8 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, out.save_compatibility = cfg.contains("save_compatibility") ? toml::find(cfg, "save_compatibility") : "shared"; out.root = path.parent_path(); - if (out.format_version != kFormatVersion) + if (out.format_version < kMinFormatVersion || + out.format_version > kMaxFormatVersion) throw std::runtime_error("unsupported format_version"); if (!valid_id(out.id)) throw std::runtime_error("invalid package id"); if (!parse_semver(out.version).valid) throw std::runtime_error("invalid semantic version"); @@ -999,7 +1089,9 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, option.step = toml::find_or(v, "step", 1); const int64_t def = toml::find(v, "default"); if (option.min_value > option.max_value || option.step <= 0 || - def < option.min_value || def > option.max_value) + def < option.min_value || def > option.max_value || + !integer_step_aligned( + def, option.min_value, option.step)) throw std::runtime_error("invalid integer bounds/default"); option.default_value = std::to_string(def); } else { @@ -1037,20 +1129,79 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, "patch target must be main_exe, disc_raw, or disc_user"); } const std::string expected = toml::find(v, "expected"); - const std::string replacement = toml::find(v, "replace"); if (!parse_hex_bytes(expected, patch.expected) || - !parse_hex_bytes(replacement, patch.replacement) || - patch.expected.empty() || - patch.expected.size() != patch.replacement.size()) + patch.expected.empty()) throw std::runtime_error( - "patch expected/replace must be equal-length non-empty hex"); + "patch expected must be non-empty hex"); + const bool has_static_replace = v.contains("replace"); + const bool has_dynamic_replace = v.contains("replace_from"); + if (has_static_replace == has_dynamic_replace) + throw std::runtime_error( + "patch requires exactly one of replace or replace_from"); + if (has_static_replace) { + const std::string replacement = + toml::find(v, "replace"); + if (!parse_hex_bytes(replacement, patch.replacement) || + patch.expected.size() != patch.replacement.size()) + throw std::runtime_error( + "patch expected/replace must be equal-length non-empty hex"); + } else { + if (out.format_version < 2) + throw std::runtime_error( + "replace_from requires format_version 2"); + const toml::value& replacement = + toml::find(v, "replace_from"); + const auto& table = replacement.as_table(); + for (const auto& [key, unused] : table) { + (void)unused; + if (key != "option" && key != "encoding" && + key != "addend") + throw std::runtime_error( + "replace_from has unknown field: " + key); + } + patch.replace_from_option = + toml::find(replacement, "option"); + const std::string encoding = + toml::find(replacement, "encoding"); + patch.replace_addend = + toml::find_or(replacement, "addend", 0); + if (!parse_value_encoding( + encoding, patch.replace_encoding)) + throw std::runtime_error( + "replace_from encoding must be u8, u16le, or u32le"); + const ModOption* option = find_option( + out, patch.feature_id, patch.replace_from_option); + if (!option || option->type != ModOptionType::Integer) + throw std::runtime_error( + "replace_from must reference a same-feature integer option"); + if (patch.expected.size() != + value_encoding_size(patch.replace_encoding)) + throw std::runtime_error( + "replace_from encoding width must match expected bytes"); + if (!option_range_fits_encoding( + *option, patch.replace_encoding, + patch.replace_addend)) + throw std::runtime_error( + "replace_from option range/addend does not fit encoding"); + } const uint64_t sector_size = patch.target == ModPatchTarget::DiscRaw ? 2352 : patch.target == ModPatchTarget::DiscUser ? 2048 : 0; if (sector_size != 0 && - patch.location % sector_size + patch.replacement.size() > sector_size) + patch.location % sector_size + patch.expected.size() > sector_size) throw std::runtime_error( "disc patch may not cross a sector boundary"); + if (patch.target == ModPatchTarget::MainExe && + (patch.location > UINT32_MAX || + patch.expected.size() > + static_cast(UINT32_MAX) + 1 - + patch.location)) + throw std::runtime_error( + "main_exe patch exceeds 32-bit guest address space"); + if (sector_size != 0 && + patch.location / sector_size > UINT32_MAX) + throw std::runtime_error( + "disc patch LBA exceeds runtime index range"); patch.order = toml::find_or( v, "order", (int64_t)declaration_index); read_conditions(v, out.options, patch.feature_id, patch.when, "patch"); @@ -1733,7 +1884,30 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, write.target = patch->target; write.location = patch->location; write.expected = patch->expected; - write.replacement = patch->replacement; + if (patch->replace_from_option.empty()) { + write.replacement = patch->replacement; + } else { + const std::string selected_value = + effective_option_value( + *package, selected, patch->feature_id, + patch->replace_from_option); + int64_t parsed = 0; + int64_t adjusted = 0; + if (!parse_canonical_int64(selected_value, parsed) || + !checked_add_int64( + parsed, patch->replace_addend, adjusted) || + !encode_unsigned_value( + patch->replace_encoding, adjusted, + write.replacement)) { + result.errors.push_back( + package->id + "/" + patch->feature_id + + ": could not encode replace_from option " + + patch->replace_from_option); + continue; + } + if (write.replacement == write.expected) + continue; + } write.package_id = package->id; write.feature_id = patch->feature_id; result.writes.push_back(std::move(write)); diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp index 3d947665f..a2169dd46 100644 --- a/runtime/tests/test_mod_packages.cpp +++ b/runtime/tests/test_mod_packages.cpp @@ -1,6 +1,7 @@ #include "mod_packages.h" #include "psx_sha256.h" +#include #include #include #include @@ -403,6 +404,205 @@ int main() { partial_compatible.fingerprint, "feature state must resolve deterministically after reload"); + write_text(root / "packages/parametric.mod/1.0.0/manifest.toml", + "format_version = 2\n" + "id = \"parametric.mod\"\n" + "version = \"1.0.0\"\n" + "name = \"Parametric\"\n" + "resolver = \"declarative\"\n" + "[[target]]\n" + "game_id = \"SLUS-TEST\"\n" + "[[feature]]\n" + "id = \"numeric\"\n" + "name = \"Numeric\"\n" + "[[feature]]\n" + "id = \"numeric-collision\"\n" + "name = \"Numeric Collision\"\n" + "[[option]]\n" + "feature = \"numeric\"\n" + "id = \"byte\"\n" + "label = \"Byte\"\n" + "type = \"integer\"\n" + "min = 0\n" + "max = 255\n" + "step = 1\n" + "default = 7\n" + "[[option]]\n" + "feature = \"numeric\"\n" + "id = \"word\"\n" + "label = \"Word\"\n" + "type = \"integer\"\n" + "min = 0\n" + "max = 65534\n" + "step = 1\n" + "default = 4660\n" + "[[option]]\n" + "feature = \"numeric\"\n" + "id = \"dword\"\n" + "label = \"Dword\"\n" + "type = \"integer\"\n" + "min = 0\n" + "max = 4294967295\n" + "step = 1\n" + "default = 305419896\n" + "[[option]]\n" + "feature = \"numeric-collision\"\n" + "id = \"byte\"\n" + "label = \"Byte\"\n" + "type = \"integer\"\n" + "min = 0\n" + "max = 255\n" + "default = 8\n" + "[[patch]]\n" + "feature = \"numeric\"\n" + "target = \"main_exe\"\n" + "address = 2147500032\n" + "expected = \"00\"\n" + "replace_from = { option = \"byte\", encoding = \"u8\" }\n" + "[[patch]]\n" + "feature = \"numeric\"\n" + "target = \"main_exe\"\n" + "address = 2147500033\n" + "expected = \"07\"\n" + "replace_from = { option = \"byte\", encoding = \"u8\" }\n" + "[[patch]]\n" + "feature = \"numeric\"\n" + "target = \"main_exe\"\n" + "address = 2147500034\n" + "expected = \"0000\"\n" + "replace_from = { option = \"word\", encoding = \"u16le\", addend = 1 }\n" + "[[patch]]\n" + "feature = \"numeric\"\n" + "target = \"main_exe\"\n" + "address = 2147500036\n" + "expected = \"00000000\"\n" + "replace_from = { option = \"dword\", encoding = \"u32le\" }\n" + "[[patch]]\n" + "feature = \"numeric-collision\"\n" + "target = \"main_exe\"\n" + "address = 2147500032\n" + "expected = \"00\"\n" + "replace_from = { option = \"byte\", encoding = \"u8\" }\n"); + check(feature_reload.scan(&error), error.c_str()); + check(feature_reload.set_feature_enabled( + "parametric.mod", "numeric", true, &error), error.c_str()); + check(!feature_reload.set_feature_option( + "parametric.mod", "numeric", "byte", "+7", &error), + "integer options must reject a leading plus"); + check(!feature_reload.set_feature_option( + "parametric.mod", "numeric", "byte", "07", &error), + "integer options must reject noncanonical leading zeroes"); + ModResolution parametric = feature_reload.resolve("SLUS-TEST"); + const auto numeric_write = [&](uint64_t location) + -> const ModResolution::Write* { + const auto found = std::find_if( + parametric.writes.begin(), parametric.writes.end(), + [&](const ModResolution::Write& write) { + return write.package_id == "parametric.mod" && + write.location == location; + }); + return found == parametric.writes.end() ? nullptr : &*found; + }; + const ModResolution::Write* byte_write = numeric_write(0x80004000ull); + const ModResolution::Write* noop_write = numeric_write(0x80004001ull); + const ModResolution::Write* word_write = numeric_write(0x80004002ull); + const ModResolution::Write* dword_write = numeric_write(0x80004004ull); + check(parametric.ok && byte_write && + byte_write->replacement == std::vector({7}), + "u8 replace_from must encode the selected value"); + check(!noop_write, + "replace_from equal to the stock guard must elide the no-op write"); + check(word_write && + word_write->replacement == std::vector({0x35, 0x12}), + "u16le replace_from must apply addend and encode little-endian"); + check(dword_write && + dword_write->replacement == + std::vector({0x78, 0x56, 0x34, 0x12}), + "u32le replace_from must encode little-endian"); + const std::string parametric_fingerprint = parametric.fingerprint; + check(feature_reload.set_feature_option( + "parametric.mod", "numeric", "byte", "9", &error), + error.c_str()); + ModResolution changed_parametric = feature_reload.resolve("SLUS-TEST"); + check(changed_parametric.ok && + changed_parametric.fingerprint != parametric_fingerprint, + "changing a generated integer must change the plan fingerprint"); + check(feature_reload.set_feature_enabled( + "parametric.mod", "numeric-collision", true, &error), + error.c_str()); + check(!feature_reload.resolve("SLUS-TEST").ok, + "different generated values at one guarded byte must collide"); + check(feature_reload.set_feature_enabled( + "parametric.mod", "numeric-collision", false, &error), + error.c_str()); + check(feature_reload.save_state(&error), error.c_str()); + ModPackageManager parametric_reload(root); + check(parametric_reload.scan(&error), error.c_str()); + check(parametric_reload.load_state(&error), error.c_str()); + check(parametric_reload.feature_option_value( + "parametric.mod", "numeric", "byte") == "9" && + parametric_reload.resolve("SLUS-TEST").fingerprint == + changed_parametric.fingerprint, + "generated integer state and fingerprint must survive reload"); + + const auto reject_parametric_manifest = + [&](const std::string& name, const std::string& body) { + const fs::path path = root / (name + ".toml"); + write_text(path, body); + ModPackage rejected; + return !ModPackageManager::read_manifest(path, rejected, &error); + }; + const std::string dynamic_prelude = + "id=\"bad.dynamic\"\nversion=\"1.0.0\"\nname=\"Bad\"\n" + "[[target]]\ngame_id=\"SLUS-TEST\"\n" + "[[feature]]\nid=\"bad\"\nname=\"Bad\"\n" + "[[option]]\nfeature=\"bad\"\nid=\"value\"\nlabel=\"Value\"\n" + "type=\"integer\"\nmin=0\nmax=255\ndefault=1\n"; + check(reject_parametric_manifest( + "dynamic-v1", + "format_version=1\n" + dynamic_prelude + + "[[patch]]\nfeature=\"bad\"\ntarget=\"main_exe\"\n" + "address=2147487744\nexpected=\"00\"\n" + "replace_from={option=\"value\",encoding=\"u8\"}\n"), + "format 1 manifests must reject replace_from"); + check(reject_parametric_manifest( + "dynamic-both", + "format_version=2\n" + dynamic_prelude + + "[[patch]]\nfeature=\"bad\"\ntarget=\"main_exe\"\n" + "address=2147487744\nexpected=\"00\"\nreplace=\"01\"\n" + "replace_from={option=\"value\",encoding=\"u8\"}\n"), + "a patch must reject simultaneous replace and replace_from"); + check(reject_parametric_manifest( + "dynamic-width", + "format_version=2\n" + dynamic_prelude + + "[[patch]]\nfeature=\"bad\"\ntarget=\"main_exe\"\n" + "address=2147487744\nexpected=\"0000\"\n" + "replace_from={option=\"value\",encoding=\"u8\"}\n"), + "replace_from width must match the expected guard"); + check(reject_parametric_manifest( + "dynamic-overflow", + "format_version=2\n" + dynamic_prelude + + "[[patch]]\nfeature=\"bad\"\ntarget=\"main_exe\"\n" + "address=2147487744\nexpected=\"00\"\n" + "replace_from={option=\"value\",encoding=\"u8\",addend=1}\n"), + "the full option range plus addend must fit its encoding"); + check(reject_parametric_manifest( + "dynamic-unknown", + "format_version=2\n" + dynamic_prelude + + "[[patch]]\nfeature=\"bad\"\ntarget=\"main_exe\"\n" + "address=2147487744\nexpected=\"00\"\n" + "replace_from={option=\"value\",encoding=\"u8\",shift=1}\n"), + "replace_from must reject unknown transform fields"); + check(reject_parametric_manifest( + "dynamic-step-default", + "format_version=2\n" + "id=\"bad.dynamic\"\nversion=\"1.0.0\"\nname=\"Bad\"\n" + "[[target]]\ngame_id=\"SLUS-TEST\"\n" + "[[feature]]\nid=\"bad\"\nname=\"Bad\"\n" + "[[option]]\nfeature=\"bad\"\nid=\"value\"\nlabel=\"Value\"\n" + "type=\"integer\"\nmin=0\nmax=10\nstep=2\ndefault=3\n"), + "integer defaults must align to their declared step"); + const std::vector overlay_a = {1, 2, 3, 4}; const std::vector overlay_b = {8, 9}; const std::vector overlay_c = {3, 4, 7}; diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 5ae841b9e..daaa2938e 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -68,7 +68,7 @@ int main() { write_bytes(root / "packages/runtime.test/1.0.0/assets/overlay.bin", overlay); write_text(root / "packages/runtime.test/1.0.0/manifest.toml", - "format_version = 1\n" + "format_version = 2\n" "id = \"runtime.test\"\n" "version = \"1.0.0\"\n" "name = \"Runtime Test\"\n" @@ -87,6 +87,17 @@ int main() { "[[feature]]\n" "id = \"user-byte\"\n" "name = \"User Byte\"\n" + "[[feature]]\n" + "id = \"dynamic-main\"\n" + "name = \"Dynamic Main\"\n" + "[[option]]\n" + "feature = \"dynamic-main\"\n" + "id = \"count\"\n" + "label = \"Count\"\n" + "type = \"integer\"\n" + "min = 0\n" + "max = 254\n" + "default = 42\n" "[[patch]]\n" "feature = \"main-code\"\n" "target = \"main_exe\"\n" @@ -105,6 +116,18 @@ int main() { "offset = 6154\n" "expected = \"cc\"\n" "replace = \"dd\"\n" + "[[patch]]\n" + "feature = \"dynamic-main\"\n" + "target = \"main_exe\"\n" + "address = 2147488000\n" + "expected = \"0000\"\n" + "replace_from = { option = \"count\", encoding = \"u16le\" }\n" + "[[patch]]\n" + "feature = \"dynamic-main\"\n" + "target = \"main_exe\"\n" + "address = 2147488002\n" + "expected = \"0100\"\n" + "replace_from = { option = \"count\", encoding = \"u16le\", addend = 1 }\n" "[[overlay]]\n" "feature = \"asset-overlay\"\n" "target = \"disc_raw\"\n" @@ -133,7 +156,13 @@ int main() { "[[feature]]\n" "package_id = \"runtime.test\"\n" "id = \"user-byte\"\n" - "enabled = true\n"); + "enabled = true\n" + "[[feature]]\n" + "package_id = \"runtime.test\"\n" + "id = \"dynamic-main\"\n" + "enabled = true\n" + "[feature.values]\n" + "count = 42\n"); std::string error; check(PSXRecompV4::mod_runtime_initialize( @@ -142,11 +171,16 @@ int main() { check(PSXRecompV4::mod_runtime_commit(stock_path, &error), error.c_str()); ram[0x1000] = 1; ram[0x1001] = 2; ram[0x1002] = 3; ram[0x1003] = 4; + ram[0x1100] = 0; ram[0x1101] = 0; + ram[0x1102] = 1; ram[0x1103] = 0; mod_runtime_on_dispatch(0x80001000); check(ram[0x1000] == 1, "patch must wait for the configured entry point"); mod_runtime_on_dispatch(0x80002000); check(ram[0x1000] == 0xa1 && ram[0x1003] == 0xa4, "main-EXE patch must apply before entry execution"); + check(ram[0x1100] == 42 && ram[0x1101] == 0 && + ram[0x1102] == 43 && ram[0x1103] == 0, + "dynamic main-EXE patches must encode all sites before entry"); std::array sector{}; sector[10] = 0xaa; @@ -192,6 +226,18 @@ int main() { check(audio_sector[24 + 10] == 0xcc, "disc_user operations must not modify CDDA/non-data sectors"); + check(PSXRecompV4::mod_runtime_initialize( + root, "SLUS-RUNTIME", 0x80002000, {}, &error), + error.c_str()); + check(PSXRecompV4::mod_runtime_commit(stock_path, &error), error.c_str()); + ram[0x1000] = 1; ram[0x1001] = 2; ram[0x1002] = 3; ram[0x1003] = 4; + ram[0x1100] = 0; ram[0x1101] = 0; + ram[0x1102] = 2; ram[0x1103] = 0; /* second dynamic guard is wrong */ + mod_runtime_on_dispatch(0x80002000); + check(ram[0x1000] == 1 && ram[0x1003] == 4 && + ram[0x1100] == 0 && ram[0x1101] == 0, + "one failed generated guard must leave the complete main plan untouched"); + fs::remove_all(root, ec); if (failures) return 1; std::cout << "mod runtime tests passed\n"; From 603cdbb4b2e3ac8c03dc426530eafe20dcab0406 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 23 Jul 2026 15:10:03 -0700 Subject: [PATCH 09/12] fix: guard complete dynamic patch records --- docs/MOD_PACKAGES.md | 18 +++++++++++------- runtime/include/mod_packages.h | 1 + runtime/src/mod_packages.cpp | 26 +++++++++++++++++++++----- runtime/tests/test_mod_packages.cpp | 16 ++++++++++++++-- 4 files changed, 47 insertions(+), 14 deletions(-) diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index 0d605e928..466e5b7f7 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -109,22 +109,26 @@ default = 2 feature = "starting-lives" target = "main_exe" address = 0x8001DE64 -expected = "02 00" -replace_from = { option = "count", encoding = "u16le" } +expected = "02 00 02 24" +replace_from = { option = "count", encoding = "u16le", offset = 0 } [[patch]] feature = "starting-lives" target = "main_exe" address = 0x8001DE70 -expected = "03 00" -replace_from = { option = "count", encoding = "u16le", addend = 1 } +expected = "03 00 02 24" +replace_from = { option = "count", encoding = "u16le", offset = 0, addend = 1 } ``` `replace_from` and literal `replace` are mutually exclusive. The referenced option must be a bounded integer owned by the same feature. The initial -encodings are `u8`, `u16le`, and `u32le`; the expected guard must have exactly -the selected width. `addend` is the only supported transform, and the complete -declared option range after that addend must fit the unsigned encoding. +encodings are `u8`, `u16le`, and `u32le`. `offset` selects a byte field inside +the expected guard and defaults to zero. Generated replacement bytes begin as +an exact copy of the expected bytes, then the encoded value replaces only that +field. This lets a MIPS immediate, for example, retain a guard and collision +claim over its complete instruction. `addend` is the only supported transform, +and the complete declared option range after that addend must fit the unsigned +encoding. There is deliberately no host-endian encoding, signed inference, mask, shift, scale, expression language, or partial-field merge. A package uses multiple diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index f129e2cec..1a63cdee2 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -74,6 +74,7 @@ struct ModPatch { std::vector replacement; std::string replace_from_option; ModValueEncoding replace_encoding = ModValueEncoding::U8; + uint64_t replace_offset = 0; int64_t replace_addend = 0; std::string when_option; std::string when_value; diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 1f275db24..c6e88f5ea 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -1155,7 +1155,7 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, for (const auto& [key, unused] : table) { (void)unused; if (key != "option" && key != "encoding" && - key != "addend") + key != "offset" && key != "addend") throw std::runtime_error( "replace_from has unknown field: " + key); } @@ -1163,6 +1163,13 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, toml::find(replacement, "option"); const std::string encoding = toml::find(replacement, "encoding"); + const int64_t replace_offset = + toml::find_or(replacement, "offset", 0); + if (replace_offset < 0) + throw std::runtime_error( + "replace_from offset must not be negative"); + patch.replace_offset = + static_cast(replace_offset); patch.replace_addend = toml::find_or(replacement, "addend", 0); if (!parse_value_encoding( @@ -1174,10 +1181,13 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, if (!option || option->type != ModOptionType::Integer) throw std::runtime_error( "replace_from must reference a same-feature integer option"); - if (patch.expected.size() != - value_encoding_size(patch.replace_encoding)) + const uint64_t encoded_size = + value_encoding_size(patch.replace_encoding); + if (patch.replace_offset > patch.expected.size() || + encoded_size > + patch.expected.size() - patch.replace_offset) throw std::runtime_error( - "replace_from encoding width must match expected bytes"); + "replace_from value exceeds expected byte range"); if (!option_range_fits_encoding( *option, patch.replace_encoding, patch.replace_addend)) @@ -1893,18 +1903,24 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, patch->replace_from_option); int64_t parsed = 0; int64_t adjusted = 0; + std::vector encoded; if (!parse_canonical_int64(selected_value, parsed) || !checked_add_int64( parsed, patch->replace_addend, adjusted) || !encode_unsigned_value( patch->replace_encoding, adjusted, - write.replacement)) { + encoded)) { result.errors.push_back( package->id + "/" + patch->feature_id + ": could not encode replace_from option " + patch->replace_from_option); continue; } + write.replacement = write.expected; + std::copy( + encoded.begin(), encoded.end(), + write.replacement.begin() + + static_cast(patch->replace_offset)); if (write.replacement == write.expected) continue; } diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp index a2169dd46..1d3982b94 100644 --- a/runtime/tests/test_mod_packages.cpp +++ b/runtime/tests/test_mod_packages.cpp @@ -478,6 +478,12 @@ int main() { "expected = \"00000000\"\n" "replace_from = { option = \"dword\", encoding = \"u32le\" }\n" "[[patch]]\n" + "feature = \"numeric\"\n" + "target = \"main_exe\"\n" + "address = 2147500040\n" + "expected = \"0000aabb\"\n" + "replace_from = { option = \"word\", encoding = \"u16le\", offset = 0 }\n" + "[[patch]]\n" "feature = \"numeric-collision\"\n" "target = \"main_exe\"\n" "address = 2147500032\n" @@ -507,6 +513,8 @@ int main() { const ModResolution::Write* noop_write = numeric_write(0x80004001ull); const ModResolution::Write* word_write = numeric_write(0x80004002ull); const ModResolution::Write* dword_write = numeric_write(0x80004004ull); + const ModResolution::Write* guarded_word_write = + numeric_write(0x80004008ull); check(parametric.ok && byte_write && byte_write->replacement == std::vector({7}), "u8 replace_from must encode the selected value"); @@ -519,6 +527,10 @@ int main() { dword_write->replacement == std::vector({0x78, 0x56, 0x34, 0x12}), "u32le replace_from must encode little-endian"); + check(guarded_word_write && + guarded_word_write->replacement == + std::vector({0x34, 0x12, 0xaa, 0xbb}), + "replace_from must preserve guarded bytes outside its value field"); const std::string parametric_fingerprint = parametric.fingerprint; check(feature_reload.set_feature_option( "parametric.mod", "numeric", "byte", "9", &error), @@ -577,8 +589,8 @@ int main() { "format_version=2\n" + dynamic_prelude + "[[patch]]\nfeature=\"bad\"\ntarget=\"main_exe\"\n" "address=2147487744\nexpected=\"0000\"\n" - "replace_from={option=\"value\",encoding=\"u8\"}\n"), - "replace_from width must match the expected guard"); + "replace_from={option=\"value\",encoding=\"u8\",offset=2}\n"), + "replace_from value must stay inside the expected guard"); check(reject_parametric_manifest( "dynamic-overflow", "format_version=2\n" + dynamic_prelude + From c60a5c650928c674b614b993d484b7d0f0a408eb Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 23 Jul 2026 16:23:55 -0700 Subject: [PATCH 10/12] feat: add ordered mod values and MIPS pair patches --- docs/MOD_PACKAGES.md | 45 +++++ runtime/include/mod_packages.h | 20 ++ runtime/src/mod_packages.cpp | 287 ++++++++++++++++++++++++++-- runtime/tests/test_mod_packages.cpp | 120 +++++++++++- 4 files changed, 456 insertions(+), 16 deletions(-) diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index 466e5b7f7..d40dc0450 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -142,6 +142,51 @@ Integer values use canonical decimal text. Leading plus signs, redundant leading zeroes, values outside the bounds, and values not aligned to `step` are rejected. +## Ordered values and split MIPS immediates + +Package format 3 adds feature-local ordering constraints for related integer +fields: + +```toml +format_version = 3 + +[[constraint]] +feature = "rank-thresholds" +kind = "ordered_integer" +direction = "nondecreasing" +options = ["rank-c", "rank-b", "rank-a"] +``` + +All listed options must be integer options on that feature. Defaults must +satisfy the constraint. While a feature is enabled, an edit that would invert +the order is rejected with the neighboring option labels. Disabled features +may retain an incomplete or invalid draft, but cannot be enabled until it is +valid. `nonincreasing` is also supported. + +Format 3 also provides a narrow, typed transform for constants constructed by +a linked MIPS `LUI`/`ORI` pair: + +```toml +replace_from = { + option = "speed", + encoding = "mips_lui_ori_u32", + omit_when_default = true +} +``` + +The patch must target one aligned, fully guarded eight-byte `main_exe` +instruction pair. The loader verifies the opcodes and register linkage, then +places the raw high and low 16-bit halves into the two immediates. It does not +apply signed-`ADDIU` carry adjustment. `offset` and `addend` are not accepted +for this encoding. + +`omit_when_default` suppresses the entire patch when the selected value equals +the option default. This models source tools whose declared default means +"make no writes," including cases where multiple guarded sites contain +different stock values. For any nondefault selection, every declared site +retains its collision claim even if one generated replacement happens to equal +its stock guard. + ## Native operations `main_exe` writes use PSX guest virtual addresses. Expected bytes are checked diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index 1a63cdee2..6bfcebff4 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -43,6 +43,23 @@ struct ModFeature { bool legacy = false; }; +enum class ModConstraintKind { + OrderedInteger, +}; + +enum class ModConstraintDirection { + Nondecreasing, + Nonincreasing, +}; + +struct ModConstraint { + std::string feature_id; + ModConstraintKind kind = ModConstraintKind::OrderedInteger; + ModConstraintDirection direction = + ModConstraintDirection::Nondecreasing; + std::vector options; +}; + struct ModRequirement { std::string id; std::string version; @@ -64,6 +81,7 @@ enum class ModValueEncoding { U8, U16LE, U32LE, + MipsLuiOriU32, }; struct ModPatch { @@ -76,6 +94,7 @@ struct ModPatch { ModValueEncoding replace_encoding = ModValueEncoding::U8; uint64_t replace_offset = 0; int64_t replace_addend = 0; + bool replace_omit_when_default = false; std::string when_option; std::string when_value; std::map when; @@ -121,6 +140,7 @@ struct ModPackage { std::vector conflicts; std::vector features; std::vector options; + std::vector constraints; std::vector patches; std::vector overlays; std::vector derived_discs; diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index c6e88f5ea..9c3fc958c 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -21,7 +21,7 @@ namespace PSXRecompV4 { namespace { constexpr uint32_t kMinFormatVersion = 1; -constexpr uint32_t kMaxFormatVersion = 2; +constexpr uint32_t kMaxFormatVersion = 3; constexpr uint64_t kMaxArchiveBytes = 256ull * 1024ull * 1024ull; constexpr uint32_t kMaxArchiveFiles = 4096; @@ -79,6 +79,7 @@ bool parse_value_encoding(const std::string& text, ModValueEncoding& out) { {"u8", ModValueEncoding::U8}, {"u16le", ModValueEncoding::U16LE}, {"u32le", ModValueEncoding::U32LE}, + {"mips_lui_ori_u32", ModValueEncoding::MipsLuiOriU32}, }; for (const auto& [name, encoding] : values) { if (text == name) { @@ -97,6 +98,8 @@ uint32_t value_encoding_size(ModValueEncoding encoding) { return 2; case ModValueEncoding::U32LE: return 4; + case ModValueEncoding::MipsLuiOriU32: + return 8; } return 0; } @@ -673,6 +676,11 @@ const ModOption* find_option(const ModPackage& package, return option == package.options.end() ? nullptr : &*option; } +bool constraint_satisfied( + const ModPackage& package, const ModConstraint& constraint, + const std::function& value_for, + std::string* reason); + const ModFeatureSelection* find_feature_selection(const ModPackage& package, const ModSelection& selection, const std::string& feature_id) { @@ -720,6 +728,27 @@ std::string effective_option_value(const ModPackage& package, return option ? option->default_value : std::string(); } +bool feature_constraints_satisfied( + const ModPackage& package, const ModSelection& selection, + const std::string& feature_id, const std::string* override_id, + const std::string* override_value, std::string* reason) { + for (const ModConstraint& constraint : package.constraints) { + if (constraint.feature_id != feature_id) continue; + if (!constraint_satisfied( + package, constraint, + [&](const std::string& id) { + return override_id && override_value && + id == *override_id + ? *override_value + : effective_option_value( + package, selection, feature_id, id); + }, + reason)) + return false; + } + return true; +} + bool conditions_match(const ModPackage& package, const ModSelection& selection, const std::string& feature_id, const std::map& conditions) { @@ -887,6 +916,46 @@ bool valid_option_value(const ModOption& option, const std::string& value) { integer_step_aligned(parsed, option.min_value, option.step); } +bool constraint_satisfied( + const ModPackage& package, const ModConstraint& constraint, + const std::function& value_for, + std::string* reason) { + if (constraint.kind != ModConstraintKind::OrderedInteger) return false; + for (size_t index = 1; index < constraint.options.size(); ++index) { + const std::string& previous_id = constraint.options[index - 1]; + const std::string& current_id = constraint.options[index]; + int64_t previous = 0; + int64_t current = 0; + if (!parse_canonical_int64(value_for(previous_id), previous) || + !parse_canonical_int64(value_for(current_id), current)) { + if (reason) + *reason = "constraint references a non-integer value"; + return false; + } + const bool violated = + constraint.direction == ModConstraintDirection::Nondecreasing + ? previous > current + : previous < current; + if (violated) { + if (reason) { + const ModOption* previous_option = find_option( + package, constraint.feature_id, previous_id); + const ModOption* current_option = find_option( + package, constraint.feature_id, current_id); + *reason = + (previous_option ? previous_option->label : previous_id) + + (constraint.direction == + ModConstraintDirection::Nondecreasing + ? " must be less than or equal to " + : " must be greater than or equal to ") + + (current_option ? current_option->label : current_id); + } + return false; + } + } + return true; +} + bool checked_add_int64(int64_t left, int64_t right, int64_t& out) { if ((right > 0 && left > std::numeric_limits::max() - right) || (right < 0 && left < std::numeric_limits::min() - right)) @@ -903,6 +972,8 @@ uint64_t value_encoding_max(ModValueEncoding encoding) { return UINT16_MAX; case ModValueEncoding::U32LE: return UINT32_MAX; + case ModValueEncoding::MipsLuiOriU32: + return UINT32_MAX; } return 0; } @@ -926,6 +997,7 @@ bool encode_unsigned_value(ModValueEncoding encoding, int64_t value, const uint32_t size = value_encoding_size(encoding); out.resize(size); const uint64_t encoded = static_cast(value); + if (encoding == ModValueEncoding::MipsLuiOriU32) return false; for (uint32_t i = 0; i < size; ++i) out[i] = static_cast(encoded >> (i * 8)); return true; @@ -1100,6 +1172,67 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, out.options.push_back(std::move(option)); } } + if (cfg.contains("constraint")) { + if (out.format_version < 3) + throw std::runtime_error( + "constraints require format_version 3"); + for (const toml::value& v : + toml::find(cfg, "constraint").as_array()) { + ModConstraint constraint; + constraint.feature_id = + toml::find(v, "feature"); + const std::string kind = + toml::find(v, "kind"); + const std::string direction = + toml::find(v, "direction"); + constraint.options = + toml::find>(v, "options"); + if (!find_feature(out, constraint.feature_id)) + throw std::runtime_error( + "constraint references unknown feature"); + if (kind != "ordered_integer") + throw std::runtime_error( + "constraint kind must be ordered_integer"); + if (direction == "nondecreasing") { + constraint.direction = + ModConstraintDirection::Nondecreasing; + } else if (direction == "nonincreasing") { + constraint.direction = + ModConstraintDirection::Nonincreasing; + } else { + throw std::runtime_error( + "ordered_integer direction must be " + "nondecreasing or nonincreasing"); + } + if (constraint.options.size() < 2) + throw std::runtime_error( + "ordered_integer requires at least two options"); + std::set option_ids; + for (const std::string& id : constraint.options) { + const ModOption* option = + find_option(out, constraint.feature_id, id); + if (!option || option->type != ModOptionType::Integer) + throw std::runtime_error( + "ordered_integer must reference same-feature " + "integer options"); + if (!option_ids.insert(id).second) + throw std::runtime_error( + "ordered_integer contains a duplicate option"); + } + std::string reason; + if (!constraint_satisfied( + out, constraint, + [&](const std::string& id) { + return find_option( + out, constraint.feature_id, id) + ->default_value; + }, + &reason)) + throw std::runtime_error( + "constraint defaults are invalid: " + reason); + out.constraints.push_back(std::move(constraint)); + } + } if (cfg.contains("patch")) { size_t declaration_index = 0; for (const toml::value& v : toml::find(cfg, "patch").as_array()) { @@ -1155,7 +1288,8 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, for (const auto& [key, unused] : table) { (void)unused; if (key != "option" && key != "encoding" && - key != "offset" && key != "addend") + key != "offset" && key != "addend" && + key != "omit_when_default") throw std::runtime_error( "replace_from has unknown field: " + key); } @@ -1172,10 +1306,22 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, static_cast(replace_offset); patch.replace_addend = toml::find_or(replacement, "addend", 0); + patch.replace_omit_when_default = + toml::find_or( + replacement, "omit_when_default", false); if (!parse_value_encoding( encoding, patch.replace_encoding)) throw std::runtime_error( - "replace_from encoding must be u8, u16le, or u32le"); + "replace_from encoding is unsupported"); + const bool mips_pair_encoding = + patch.replace_encoding == + ModValueEncoding::MipsLuiOriU32; + if ((mips_pair_encoding || + patch.replace_omit_when_default) && + out.format_version < 3) + throw std::runtime_error( + "typed MIPS encodings and omit_when_default " + "require format_version 3"); const ModOption* option = find_option( out, patch.feature_id, patch.replace_from_option); if (!option || option->type != ModOptionType::Integer) @@ -1193,6 +1339,37 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, patch.replace_addend)) throw std::runtime_error( "replace_from option range/addend does not fit encoding"); + if (mips_pair_encoding) { + if (patch.target != ModPatchTarget::MainExe || + patch.expected.size() != 8 || + patch.replace_offset != 0 || + patch.replace_addend != 0 || + patch.location % 4 != 0) + throw std::runtime_error( + "typed MIPS LUI/ORI encoding requires one " + "aligned complete main_exe instruction pair " + "at offset zero without an addend"); + const uint32_t lui = + static_cast(patch.expected[0]) | + (static_cast(patch.expected[1]) << 8) | + (static_cast(patch.expected[2]) << 16) | + (static_cast(patch.expected[3]) << 24); + const uint32_t ori = + static_cast(patch.expected[4]) | + (static_cast(patch.expected[5]) << 8) | + (static_cast(patch.expected[6]) << 16) | + (static_cast(patch.expected[7]) << 24); + const uint32_t lui_rs = (lui >> 21) & 0x1Fu; + const uint32_t lui_rt = (lui >> 16) & 0x1Fu; + const uint32_t ori_rs = (ori >> 21) & 0x1Fu; + const uint32_t ori_rt = (ori >> 16) & 0x1Fu; + if ((lui >> 26) != 0x0Fu || lui_rs != 0 || + lui_rt == 0 || (ori >> 26) != 0x0Du || + ori_rs != lui_rt || ori_rt != lui_rt) + throw std::runtime_error( + "typed MIPS encoding guard is not a linked " + "LUI/ORI register pair"); + } } const uint64_t sector_size = patch.target == ModPatchTarget::DiscRaw ? 2352 : @@ -1691,8 +1868,17 @@ bool ModPackageManager::set_feature_enabled(const std::string& package_id, set_error(error, "unknown package feature"); return false; } - ModFeatureSelection& selection = - selections_[package_id].features[feature_id]; + ModSelection& package_selection = selections_[package_id]; + if (enabled) { + std::string reason; + if (!feature_constraints_satisfied( + *package, package_selection, feature_id, + nullptr, nullptr, &reason)) { + set_error(error, package_id + "/" + feature_id + ": " + reason); + return false; + } + } + ModFeatureSelection& selection = package_selection.features[feature_id]; selection.enabled = enabled; selection.has_enabled = true; return true; @@ -1716,7 +1902,17 @@ bool ModPackageManager::set_feature_option(const std::string& package_id, set_error(error, "invalid feature option value"); return false; } - selections_[package_id].features[feature_id].values[option_id] = value; + ModSelection& package_selection = selections_[package_id]; + if (is_feature_enabled(*package, package_selection, *feature)) { + std::string reason; + if (!feature_constraints_satisfied( + *package, package_selection, feature_id, + &option_id, &value, &reason)) { + set_error(error, package_id + "/" + feature_id + ": " + reason); + return false; + } + } + package_selection.features[feature_id].values[option_id] = value; return true; } @@ -1796,6 +1992,30 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, result.errors.push_back(id + " conflicts with " + conflict); const auto selection = selections_.find(id); + const ModSelection blank_selection; + const ModSelection& effective_selection = + selection == selections_.end() + ? blank_selection + : selection->second; + for (const ModConstraint& constraint : package->constraints) { + const ModFeature* feature = + find_feature(*package, constraint.feature_id); + if (!feature || + !is_feature_enabled( + *package, effective_selection, *feature)) + continue; + std::string reason; + if (!constraint_satisfied( + *package, constraint, + [&](const std::string& option_id) { + return effective_option_value( + *package, effective_selection, + constraint.feature_id, option_id); + }, + &reason)) + result.errors.push_back( + id + "/" + constraint.feature_id + ": " + reason); + } if (selection != selections_.end()) { for (const ModOption& option : package->options) { const ModFeature* feature = @@ -1901,15 +2121,19 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, effective_option_value( *package, selected, patch->feature_id, patch->replace_from_option); + const ModOption* source_option = find_option( + *package, patch->feature_id, + patch->replace_from_option); + if (patch->replace_omit_when_default && + source_option && + selected_value == source_option->default_value) + continue; int64_t parsed = 0; int64_t adjusted = 0; std::vector encoded; if (!parse_canonical_int64(selected_value, parsed) || !checked_add_int64( - parsed, patch->replace_addend, adjusted) || - !encode_unsigned_value( - patch->replace_encoding, adjusted, - encoded)) { + parsed, patch->replace_addend, adjusted)) { result.errors.push_back( package->id + "/" + patch->feature_id + ": could not encode replace_from option " + @@ -1917,11 +2141,44 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, continue; } write.replacement = write.expected; - std::copy( - encoded.begin(), encoded.end(), - write.replacement.begin() + - static_cast(patch->replace_offset)); - if (write.replacement == write.expected) + if (patch->replace_encoding == + ModValueEncoding::MipsLuiOriU32) { + if (adjusted < 0 || + static_cast(adjusted) > UINT32_MAX) { + result.errors.push_back( + package->id + "/" + patch->feature_id + + ": could not encode replace_from option " + + patch->replace_from_option); + continue; + } + const uint32_t value = + static_cast(adjusted); + write.replacement[0] = + static_cast(value >> 16); + write.replacement[1] = + static_cast(value >> 24); + write.replacement[4] = + static_cast(value); + write.replacement[5] = + static_cast(value >> 8); + } else { + if (!encode_unsigned_value( + patch->replace_encoding, adjusted, + encoded)) { + result.errors.push_back( + package->id + "/" + patch->feature_id + + ": could not encode replace_from option " + + patch->replace_from_option); + continue; + } + std::copy( + encoded.begin(), encoded.end(), + write.replacement.begin() + + static_cast( + patch->replace_offset)); + } + if (write.replacement == write.expected && + !patch->replace_omit_when_default) continue; } write.package_id = package->id; diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp index 1d3982b94..7f7771ec7 100644 --- a/runtime/tests/test_mod_packages.cpp +++ b/runtime/tests/test_mod_packages.cpp @@ -405,7 +405,7 @@ int main() { "feature state must resolve deterministically after reload"); write_text(root / "packages/parametric.mod/1.0.0/manifest.toml", - "format_version = 2\n" + "format_version = 3\n" "id = \"parametric.mod\"\n" "version = \"1.0.0\"\n" "name = \"Parametric\"\n" @@ -446,6 +446,15 @@ int main() { "step = 1\n" "default = 305419896\n" "[[option]]\n" + "feature = \"numeric\"\n" + "id = \"split\"\n" + "label = \"Split\"\n" + "type = \"integer\"\n" + "min = 200000\n" + "max = 600000\n" + "step = 1\n" + "default = 425984\n" + "[[option]]\n" "feature = \"numeric-collision\"\n" "id = \"byte\"\n" "label = \"Byte\"\n" @@ -453,6 +462,11 @@ int main() { "min = 0\n" "max = 255\n" "default = 8\n" + "[[constraint]]\n" + "feature = \"numeric\"\n" + "kind = \"ordered_integer\"\n" + "direction = \"nondecreasing\"\n" + "options = [\"byte\", \"word\", \"dword\"]\n" "[[patch]]\n" "feature = \"numeric\"\n" "target = \"main_exe\"\n" @@ -484,6 +498,18 @@ int main() { "expected = \"0000aabb\"\n" "replace_from = { option = \"word\", encoding = \"u16le\", offset = 0 }\n" "[[patch]]\n" + "feature = \"numeric\"\n" + "target = \"main_exe\"\n" + "address = 2147500048\n" + "expected = \"0600013c00802134\"\n" + "replace_from = { option = \"split\", encoding = \"mips_lui_ori_u32\", omit_when_default = true }\n" + "[[patch]]\n" + "feature = \"numeric\"\n" + "target = \"main_exe\"\n" + "address = 2147500056\n" + "expected = \"0400013c00202134\"\n" + "replace_from = { option = \"split\", encoding = \"mips_lui_ori_u32\", omit_when_default = true }\n" + "[[patch]]\n" "feature = \"numeric-collision\"\n" "target = \"main_exe\"\n" "address = 2147500032\n" @@ -531,14 +557,63 @@ int main() { guarded_word_write->replacement == std::vector({0x34, 0x12, 0xaa, 0xbb}), "replace_from must preserve guarded bytes outside its value field"); + check(!numeric_write(0x80004010ull) && + !numeric_write(0x80004018ull), + "omit_when_default must suppress every split-immediate site"); const std::string parametric_fingerprint = parametric.fingerprint; check(feature_reload.set_feature_option( "parametric.mod", "numeric", "byte", "9", &error), error.c_str()); + check(!feature_reload.set_feature_option( + "parametric.mod", "numeric", "word", "5", &error), + "enabled ordered integer features must reject inverted values"); ModResolution changed_parametric = feature_reload.resolve("SLUS-TEST"); check(changed_parametric.ok && changed_parametric.fingerprint != parametric_fingerprint, "changing a generated integer must change the plan fingerprint"); + check(feature_reload.set_feature_option( + "parametric.mod", "numeric", "split", "200000", &error), + error.c_str()); + ModResolution split_parametric = feature_reload.resolve("SLUS-TEST"); + const auto split_write = [&](uint64_t location) + -> const ModResolution::Write* { + const auto found = std::find_if( + split_parametric.writes.begin(), split_parametric.writes.end(), + [&](const ModResolution::Write& write) { + return write.package_id == "parametric.mod" && + write.location == location; + }); + return found == split_parametric.writes.end() ? nullptr : &*found; + }; + check(split_write(0x80004010ull) && + split_write(0x80004010ull)->replacement == + std::vector({ + 0x03, 0x00, 0x01, 0x3c, + 0x40, 0x0d, 0x21, 0x34}) && + split_write(0x80004018ull) && + split_write(0x80004018ull)->replacement == + std::vector({ + 0x03, 0x00, 0x01, 0x3c, + 0x40, 0x0d, 0x21, 0x34}), + "typed MIPS split encodings must update every guarded pair"); + check(feature_reload.set_feature_option( + "parametric.mod", "numeric", "split", "270336", &error), + error.c_str()); + ModResolution partial_stock_split = + feature_reload.resolve("SLUS-TEST"); + check(std::count_if( + partial_stock_split.writes.begin(), + partial_stock_split.writes.end(), + [](const ModResolution::Write& write) { + return write.package_id == "parametric.mod" && + (write.location == 0x80004010ull || + write.location == 0x80004018ull); + }) == 2, + "a nondefault split value must retain ownership of a pair whose " + "replacement happens to equal stock"); + check(feature_reload.set_feature_option( + "parametric.mod", "numeric", "split", "425984", &error), + error.c_str()); check(feature_reload.set_feature_enabled( "parametric.mod", "numeric-collision", true, &error), error.c_str()); @@ -547,6 +622,19 @@ int main() { check(feature_reload.set_feature_enabled( "parametric.mod", "numeric-collision", false, &error), error.c_str()); + check(feature_reload.set_feature_enabled( + "parametric.mod", "numeric", false, &error), error.c_str()); + check(feature_reload.set_feature_option( + "parametric.mod", "numeric", "word", "0", &error), + "disabled features may retain an invalid draft"); + check(!feature_reload.set_feature_enabled( + "parametric.mod", "numeric", true, &error), + "an invalid ordered integer draft must block feature enablement"); + check(feature_reload.set_feature_option( + "parametric.mod", "numeric", "word", "4660", &error), + error.c_str()); + check(feature_reload.set_feature_enabled( + "parametric.mod", "numeric", true, &error), error.c_str()); check(feature_reload.save_state(&error), error.c_str()); ModPackageManager parametric_reload(root); check(parametric_reload.scan(&error), error.c_str()); @@ -605,6 +693,36 @@ int main() { "address=2147487744\nexpected=\"00\"\n" "replace_from={option=\"value\",encoding=\"u8\",shift=1}\n"), "replace_from must reject unknown transform fields"); + check(reject_parametric_manifest( + "dynamic-mips-v2", + "format_version=2\n" + dynamic_prelude + + "[[patch]]\nfeature=\"bad\"\ntarget=\"main_exe\"\n" + "address=2147487744\nexpected=\"0600013c00802134\"\n" + "replace_from={option=\"value\"," + "encoding=\"mips_lui_ori_u32\"}\n"), + "typed MIPS pairs must require package format 3"); + check(reject_parametric_manifest( + "dynamic-mips-unlinked", + "format_version=3\n" + dynamic_prelude + + "[[patch]]\nfeature=\"bad\"\ntarget=\"main_exe\"\n" + "address=2147487744\nexpected=\"0600013c00802234\"\n" + "replace_from={option=\"value\"," + "encoding=\"mips_lui_ori_u32\"}\n"), + "typed MIPS pairs must reject unlinked registers"); + check(reject_parametric_manifest( + "constraint-inverted-default", + "format_version=3\n" + "id=\"bad.dynamic\"\nversion=\"1.0.0\"\nname=\"Bad\"\n" + "[[target]]\ngame_id=\"SLUS-TEST\"\n" + "[[feature]]\nid=\"bad\"\nname=\"Bad\"\n" + "[[option]]\nfeature=\"bad\"\nid=\"low\"\nlabel=\"Low\"\n" + "type=\"integer\"\nmin=0\nmax=10\ndefault=8\n" + "[[option]]\nfeature=\"bad\"\nid=\"high\"\nlabel=\"High\"\n" + "type=\"integer\"\nmin=0\nmax=10\ndefault=2\n" + "[[constraint]]\nfeature=\"bad\"\n" + "kind=\"ordered_integer\"\ndirection=\"nondecreasing\"\n" + "options=[\"low\",\"high\"]\n"), + "ordered integer defaults must satisfy their constraint"); check(reject_parametric_manifest( "dynamic-step-default", "format_version=2\n" From e04d1d38a598e8116dd64f65a6b053995521b98c Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 24 Jul 2026 11:45:28 -0700 Subject: [PATCH 11/12] runtime: pass active mod context to resolvers --- runtime/include/mod_packages.h | 10 + runtime/src/mod_packages.cpp | 292 +++++++++++++++++++--------- runtime/tests/test_mod_packages.cpp | 41 ++++ 3 files changed, 253 insertions(+), 90 deletions(-) diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index 6bfcebff4..cef87f58e 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -45,6 +45,7 @@ struct ModFeature { enum class ModConstraintKind { OrderedInteger, + RequiresFeature, }; enum class ModConstraintDirection { @@ -58,6 +59,9 @@ struct ModConstraint { ModConstraintDirection direction = ModConstraintDirection::Nondecreasing; std::vector options; + std::string required_feature_id; + std::string required_option_id; + std::string required_value; }; struct ModRequirement { @@ -204,9 +208,15 @@ struct ModResolution { std::vector errors; }; +struct ModBuiltinResolverContext { + const std::map* active_packages = nullptr; + const std::map* selections = nullptr; +}; + using ModBuiltinResolver = std::function& writes, std::vector& errors)>; diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 9c3fc958c..c03b3ce19 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -21,7 +21,7 @@ namespace PSXRecompV4 { namespace { constexpr uint32_t kMinFormatVersion = 1; -constexpr uint32_t kMaxFormatVersion = 3; +constexpr uint32_t kMaxFormatVersion = 4; constexpr uint64_t kMaxArchiveBytes = 256ull * 1024ull * 1024ull; constexpr uint32_t kMaxArchiveFiles = 4096; @@ -728,23 +728,107 @@ std::string effective_option_value(const ModPackage& package, return option ? option->default_value : std::string(); } -bool feature_constraints_satisfied( +bool prospective_feature_enabled( const ModPackage& package, const ModSelection& selection, - const std::string& feature_id, const std::string* override_id, + const std::string& feature_id, const std::string* override_feature_id, + const bool* override_enabled) { + if (override_feature_id && override_enabled && + feature_id == *override_feature_id) + return *override_enabled; + const ModFeature* feature = find_feature(package, feature_id); + return feature && is_feature_enabled(package, selection, *feature); +} + +std::string prospective_option_value( + const ModPackage& package, const ModSelection& selection, + const std::string& feature_id, const std::string& option_id, + const std::string* override_feature_id, + const std::string* override_option_id, + const std::string* override_value) { + if (override_feature_id && override_option_id && override_value && + feature_id == *override_feature_id && option_id == *override_option_id) + return *override_value; + return effective_option_value(package, selection, feature_id, option_id); +} + +bool runtime_constraint_satisfied( + const ModPackage& package, const ModSelection& selection, + const ModConstraint& constraint, + const std::string* override_feature_id, const bool* override_enabled, + const std::string* override_option_feature_id, + const std::string* override_option_id, const std::string* override_value, std::string* reason) { + if (!prospective_feature_enabled( + package, selection, constraint.feature_id, override_feature_id, + override_enabled)) + return true; + if (constraint.kind == ModConstraintKind::OrderedInteger) { + return constraint_satisfied( + package, constraint, + [&](const std::string& id) { + return prospective_option_value( + package, selection, constraint.feature_id, id, + override_option_feature_id, override_option_id, + override_value); + }, + reason); + } + if (constraint.kind == ModConstraintKind::RequiresFeature) { + const ModFeature* required = + find_feature(package, constraint.required_feature_id); + const std::string required_name = + required && !required->name.empty() + ? required->name + : constraint.required_feature_id; + if (!prospective_feature_enabled( + package, selection, constraint.required_feature_id, + override_feature_id, override_enabled)) { + if (reason) *reason = "requires " + required_name + " to be enabled"; + return false; + } + if (!constraint.required_option_id.empty()) { + const std::string actual = prospective_option_value( + package, selection, constraint.required_feature_id, + constraint.required_option_id, override_option_feature_id, + override_option_id, override_value); + if (actual != constraint.required_value) { + const ModOption* option = find_option( + package, constraint.required_feature_id, + constraint.required_option_id); + if (reason) { + *reason = + "requires " + required_name + " " + + (option && !option->label.empty() + ? option->label + : constraint.required_option_id) + + " = " + constraint.required_value; + } + return false; + } + } + return true; + } + if (reason) *reason = "unsupported constraint kind"; + return false; +} + +bool package_constraints_satisfied( + const ModPackage& package, const ModSelection& selection, + const std::string* override_feature_id, const bool* override_enabled, + const std::string* override_option_feature_id, + const std::string* override_option_id, + const std::string* override_value, + std::string* failing_feature_id, std::string* reason) { for (const ModConstraint& constraint : package.constraints) { - if (constraint.feature_id != feature_id) continue; - if (!constraint_satisfied( - package, constraint, - [&](const std::string& id) { - return override_id && override_value && - id == *override_id - ? *override_value - : effective_option_value( - package, selection, feature_id, id); - }, - reason)) + std::string local_reason; + if (!runtime_constraint_satisfied( + package, selection, constraint, override_feature_id, + override_enabled, override_option_feature_id, + override_option_id, override_value, &local_reason)) { + if (failing_feature_id) *failing_feature_id = constraint.feature_id; + if (reason) *reason = local_reason; return false; + } } return true; } @@ -1183,53 +1267,85 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, toml::find(v, "feature"); const std::string kind = toml::find(v, "kind"); - const std::string direction = - toml::find(v, "direction"); - constraint.options = - toml::find>(v, "options"); if (!find_feature(out, constraint.feature_id)) throw std::runtime_error( "constraint references unknown feature"); - if (kind != "ordered_integer") - throw std::runtime_error( - "constraint kind must be ordered_integer"); - if (direction == "nondecreasing") { - constraint.direction = - ModConstraintDirection::Nondecreasing; - } else if (direction == "nonincreasing") { - constraint.direction = - ModConstraintDirection::Nonincreasing; - } else { - throw std::runtime_error( - "ordered_integer direction must be " - "nondecreasing or nonincreasing"); - } - if (constraint.options.size() < 2) - throw std::runtime_error( - "ordered_integer requires at least two options"); - std::set option_ids; - for (const std::string& id : constraint.options) { - const ModOption* option = - find_option(out, constraint.feature_id, id); - if (!option || option->type != ModOptionType::Integer) + if (kind == "ordered_integer") { + const std::string direction = + toml::find(v, "direction"); + constraint.options = + toml::find>(v, "options"); + if (direction == "nondecreasing") { + constraint.direction = + ModConstraintDirection::Nondecreasing; + } else if (direction == "nonincreasing") { + constraint.direction = + ModConstraintDirection::Nonincreasing; + } else { + throw std::runtime_error( + "ordered_integer direction must be " + "nondecreasing or nonincreasing"); + } + if (constraint.options.size() < 2) + throw std::runtime_error( + "ordered_integer requires at least two options"); + std::set option_ids; + for (const std::string& id : constraint.options) { + const ModOption* option = + find_option(out, constraint.feature_id, id); + if (!option || option->type != ModOptionType::Integer) + throw std::runtime_error( + "ordered_integer must reference same-feature " + "integer options"); + if (!option_ids.insert(id).second) + throw std::runtime_error( + "ordered_integer contains a duplicate option"); + } + std::string reason; + if (!constraint_satisfied( + out, constraint, + [&](const std::string& id) { + return find_option( + out, constraint.feature_id, id) + ->default_value; + }, + &reason)) throw std::runtime_error( - "ordered_integer must reference same-feature " - "integer options"); - if (!option_ids.insert(id).second) + "constraint defaults are invalid: " + reason); + } else if (kind == "requires_feature") { + if (out.format_version < 4) throw std::runtime_error( - "ordered_integer contains a duplicate option"); + "requires_feature constraints require format_version 4"); + constraint.kind = ModConstraintKind::RequiresFeature; + constraint.required_feature_id = + toml::find(v, "requires_feature"); + if (!find_feature(out, constraint.required_feature_id)) + throw std::runtime_error( + "requires_feature references unknown feature"); + constraint.required_option_id = + toml::find_or(v, "requires_option", ""); + constraint.required_value = + toml::find_or(v, "requires_value", ""); + if (constraint.required_option_id.empty() != + constraint.required_value.empty()) + throw std::runtime_error( + "requires_feature option constraint requires both " + "requires_option and requires_value"); + if (!constraint.required_option_id.empty()) { + const ModOption* option = find_option( + out, constraint.required_feature_id, + constraint.required_option_id); + if (!option) + throw std::runtime_error( + "requires_feature references unknown option"); + if (!valid_option_value( + *option, constraint.required_value)) + throw std::runtime_error( + "requires_feature references invalid option value"); + } + } else { + throw std::runtime_error("unknown constraint kind"); } - std::string reason; - if (!constraint_satisfied( - out, constraint, - [&](const std::string& id) { - return find_option( - out, constraint.feature_id, id) - ->default_value; - }, - &reason)) - throw std::runtime_error( - "constraint defaults are invalid: " + reason); out.constraints.push_back(std::move(constraint)); } } @@ -1869,14 +1985,15 @@ bool ModPackageManager::set_feature_enabled(const std::string& package_id, return false; } ModSelection& package_selection = selections_[package_id]; - if (enabled) { - std::string reason; - if (!feature_constraints_satisfied( - *package, package_selection, feature_id, - nullptr, nullptr, &reason)) { - set_error(error, package_id + "/" + feature_id + ": " + reason); - return false; - } + std::string failing_feature; + std::string reason; + if (!package_constraints_satisfied( + *package, package_selection, &feature_id, &enabled, + nullptr, nullptr, nullptr, &failing_feature, &reason)) { + set_error(error, package_id + "/" + + (failing_feature.empty() ? feature_id : failing_feature) + + ": " + reason); + return false; } ModFeatureSelection& selection = package_selection.features[feature_id]; selection.enabled = enabled; @@ -1903,14 +2020,15 @@ bool ModPackageManager::set_feature_option(const std::string& package_id, return false; } ModSelection& package_selection = selections_[package_id]; - if (is_feature_enabled(*package, package_selection, *feature)) { - std::string reason; - if (!feature_constraints_satisfied( - *package, package_selection, feature_id, - &option_id, &value, &reason)) { - set_error(error, package_id + "/" + feature_id + ": " + reason); - return false; - } + std::string failing_feature; + std::string reason; + if (!package_constraints_satisfied( + *package, package_selection, nullptr, nullptr, + &feature_id, &option_id, &value, &failing_feature, &reason)) { + set_error(error, package_id + "/" + + (failing_feature.empty() ? feature_id : failing_feature) + + ": " + reason); + return false; } package_selection.features[feature_id].values[option_id] = value; return true; @@ -1997,24 +2115,13 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, selection == selections_.end() ? blank_selection : selection->second; - for (const ModConstraint& constraint : package->constraints) { - const ModFeature* feature = - find_feature(*package, constraint.feature_id); - if (!feature || - !is_feature_enabled( - *package, effective_selection, *feature)) - continue; - std::string reason; - if (!constraint_satisfied( - *package, constraint, - [&](const std::string& option_id) { - return effective_option_value( - *package, effective_selection, - constraint.feature_id, option_id); - }, - &reason)) - result.errors.push_back( - id + "/" + constraint.feature_id + ": " + reason); + std::string failing_feature; + std::string reason; + if (!package_constraints_satisfied( + *package, effective_selection, nullptr, nullptr, + nullptr, nullptr, nullptr, &failing_feature, &reason)) { + result.errors.push_back( + id + "/" + failing_feature + ": " + reason); } if (selection != selections_.end()) { for (const ModOption& option : package->options) { @@ -2074,6 +2181,9 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, return result; } + ModBuiltinResolverContext resolver_context; + resolver_context.active_packages = &active; + resolver_context.selections = &selections_; for (const ModPackage* package : result.ordered) { const auto selected_it = selections_.find(package->id); const ModSelection blank; @@ -2233,7 +2343,9 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, const std::string resolver_id = package->resolver.substr(8); const auto resolver = builtin_resolvers().find(resolver_id); if (resolver != builtin_resolvers().end() && - !resolver->second(*package, selected, result.writes, result.errors) && + !resolver->second( + *package, selected, resolver_context, + result.writes, result.errors) && result.errors.empty()) result.errors.push_back(package->id + ": built-in resolver failed"); } diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp index 7f7771ec7..4a4a20fa5 100644 --- a/runtime/tests/test_mod_packages.cpp +++ b/runtime/tests/test_mod_packages.cpp @@ -259,6 +259,47 @@ int main() { matrix.derived_discs[0].output_size == 222222, "multi-option derived-disc condition must match selected values"); + mod_clear_builtin_resolvers_for_tests(); + bool resolver_context_seen = false; + check(mod_register_builtin_resolver( + "context-test", + [&](const ModPackage& package, const ModSelection& selection, + const ModBuiltinResolverContext& context, + std::vector& writes, + std::vector& errors) { + (void)writes; + (void)errors; + resolver_context_seen = + package.id == "context.consumer" && + selection.enabled && + context.active_packages && + context.selections && + context.active_packages->count("context.provider") == 1 && + context.active_packages->count("context.consumer") == 1 && + context.selections->at("context.provider").enabled && + context.selections->at("context.consumer").enabled; + return resolver_context_seen; + }), + "test resolver must register"); + write_text(root / "packages/context.provider/1.0.0/manifest.toml", + manifest("context.provider", "1.0.0")); + write_text(root / "packages/context.consumer/1.0.0/manifest.toml", + "format_version = 1\n" + "id = \"context.consumer\"\n" + "version = \"1.0.0\"\n" + "name = \"context.consumer\"\n" + "resolver = \"builtin:context-test\"\n" + "[[target]]\n" + "game_id = \"SLUS-TEST\"\n"); + check(reload.scan(&error), error.c_str()); + check(reload.set_enabled("matrix.mod", false, &error), error.c_str()); + check(reload.set_enabled("context.provider", true, &error), error.c_str()); + check(reload.set_enabled("context.consumer", true, &error), error.c_str()); + ModResolution context_resolution = reload.resolve("SLUS-TEST"); + check(context_resolution.ok && resolver_context_seen, + "built-in resolver must receive active package selection context"); + mod_clear_builtin_resolvers_for_tests(); + write_text(root / "packages/features.mod/1.0.0/manifest.toml", manifest("features.mod", "1.0.0", "\n[[feature]]\n" From 5d6e0507f1f4cf924588e1ac1160e2221e8929ba Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 24 Jul 2026 15:08:53 -0700 Subject: [PATCH 12/12] Handle feature prerequisites in mod packages --- runtime/src/mod_packages.cpp | 96 +++++++++++++++++++++++++---- runtime/tests/test_mod_packages.cpp | 67 ++++++++++++++++++++ 2 files changed, 151 insertions(+), 12 deletions(-) diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index c03b3ce19..773a32bf6 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -833,6 +833,70 @@ bool package_constraints_satisfied( return true; } +void set_feature_selected(ModSelection& selection, + const std::string& feature_id, bool enabled) { + ModFeatureSelection& feature = selection.features[feature_id]; + feature.enabled = enabled; + feature.has_enabled = true; +} + +bool apply_feature_requirements(const ModPackage& package, + ModSelection& selection, + const std::string& feature_id, + std::set& visiting, + std::string* error) { + if (!visiting.insert(feature_id).second) return true; + for (const ModConstraint& constraint : package.constraints) { + if (constraint.kind != ModConstraintKind::RequiresFeature || + constraint.feature_id != feature_id) + continue; + const ModFeature* required = + find_feature(package, constraint.required_feature_id); + if (!required || required->legacy) { + set_error(error, feature_id + ": has an invalid feature requirement"); + visiting.erase(feature_id); + return false; + } + set_feature_selected(selection, constraint.required_feature_id, true); + if (!constraint.required_option_id.empty()) { + selection.features[constraint.required_feature_id] + .values[constraint.required_option_id] = + constraint.required_value; + } + if (!apply_feature_requirements( + package, selection, constraint.required_feature_id, + visiting, error)) { + visiting.erase(feature_id); + return false; + } + } + visiting.erase(feature_id); + return true; +} + +void cascade_unsatisfied_feature_requirements(const ModPackage& package, + ModSelection& selection) { + bool changed = false; + do { + changed = false; + for (const ModConstraint& constraint : package.constraints) { + if (constraint.kind != ModConstraintKind::RequiresFeature) + continue; + const ModFeature* dependent = + find_feature(package, constraint.feature_id); + if (!dependent || dependent->legacy || + !is_feature_enabled(package, selection, *dependent)) + continue; + if (runtime_constraint_satisfied( + package, selection, constraint, nullptr, nullptr, + nullptr, nullptr, nullptr, nullptr)) + continue; + set_feature_selected(selection, constraint.feature_id, false); + changed = true; + } + } while (changed); +} + bool conditions_match(const ModPackage& package, const ModSelection& selection, const std::string& feature_id, const std::map& conditions) { @@ -1641,8 +1705,7 @@ bool ModPackageManager::scan(std::string* error) { ModPackage package; std::string parse_error; if (!read_manifest(manifest, package, &parse_error)) { - set_error(error, parse_error); - return false; + continue; } if (package.id != id_dir.path().filename().string() || package.version != version_dir.path().filename().string()) { @@ -1984,20 +2047,27 @@ bool ModPackageManager::set_feature_enabled(const std::string& package_id, set_error(error, "unknown package feature"); return false; } - ModSelection& package_selection = selections_[package_id]; + ModSelection package_selection = selections_[package_id]; + if (enabled) { + std::set visiting; + if (!apply_feature_requirements( + *package, package_selection, feature_id, visiting, error)) + return false; + } + set_feature_selected(package_selection, feature_id, enabled); + if (!enabled) + cascade_unsatisfied_feature_requirements(*package, package_selection); std::string failing_feature; std::string reason; if (!package_constraints_satisfied( - *package, package_selection, &feature_id, &enabled, - nullptr, nullptr, nullptr, &failing_feature, &reason)) { + *package, package_selection, nullptr, nullptr, nullptr, nullptr, + nullptr, &failing_feature, &reason)) { set_error(error, package_id + "/" + (failing_feature.empty() ? feature_id : failing_feature) + ": " + reason); return false; } - ModFeatureSelection& selection = package_selection.features[feature_id]; - selection.enabled = enabled; - selection.has_enabled = true; + selections_[package_id] = std::move(package_selection); return true; } @@ -2019,18 +2089,20 @@ bool ModPackageManager::set_feature_option(const std::string& package_id, set_error(error, "invalid feature option value"); return false; } - ModSelection& package_selection = selections_[package_id]; + ModSelection package_selection = selections_[package_id]; + package_selection.features[feature_id].values[option_id] = value; + cascade_unsatisfied_feature_requirements(*package, package_selection); std::string failing_feature; std::string reason; if (!package_constraints_satisfied( - *package, package_selection, nullptr, nullptr, - &feature_id, &option_id, &value, &failing_feature, &reason)) { + *package, package_selection, nullptr, nullptr, nullptr, nullptr, + nullptr, &failing_feature, &reason)) { set_error(error, package_id + "/" + (failing_feature.empty() ? feature_id : failing_feature) + ": " + reason); return false; } - package_selection.features[feature_id].values[option_id] = value; + selections_[package_id] = std::move(package_selection); return true; } diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp index 4a4a20fa5..7b1977d1b 100644 --- a/runtime/tests/test_mod_packages.cpp +++ b/runtime/tests/test_mod_packages.cpp @@ -686,6 +686,73 @@ int main() { changed_parametric.fingerprint, "generated integer state and fingerprint must survive reload"); + write_text(root / "packages/requires.mod/1.0.0/manifest.toml", + "format_version = 4\n" + "id = \"requires.mod\"\n" + "version = \"1.0.0\"\n" + "name = \"Requires\"\n" + "[[target]]\n" + "game_id = \"SLUS-TEST\"\n" + "[[feature]]\n" + "id = \"prereq\"\n" + "name = \"Prerequisite\"\n" + "[[feature]]\n" + "id = \"dependent\"\n" + "name = \"Dependent\"\n" + "[[feature]]\n" + "id = \"optioned-dependent\"\n" + "name = \"Optioned Dependent\"\n" + "[[option]]\n" + "feature = \"prereq\"\n" + "id = \"availability\"\n" + "label = \"Available in\"\n" + "type = \"choice\"\n" + "default = \"main\"\n" + "[[option.choice]]\n" + "value = \"main\"\n" + "label = \"Main Stages\"\n" + "[[option.choice]]\n" + "value = \"everywhere\"\n" + "label = \"Everywhere\"\n" + "[[constraint]]\n" + "feature = \"dependent\"\n" + "kind = \"requires_feature\"\n" + "requires_feature = \"prereq\"\n" + "[[constraint]]\n" + "feature = \"optioned-dependent\"\n" + "kind = \"requires_feature\"\n" + "requires_feature = \"prereq\"\n" + "requires_option = \"availability\"\n" + "requires_value = \"everywhere\"\n"); + check(parametric_reload.scan(&error), error.c_str()); + check(parametric_reload.set_feature_enabled( + "requires.mod", "dependent", true, &error), error.c_str()); + check(parametric_reload.feature_enabled("requires.mod", "prereq") && + parametric_reload.feature_enabled("requires.mod", "dependent"), + "enabling a dependent feature must auto-enable its prerequisite"); + check(parametric_reload.set_feature_enabled( + "requires.mod", "optioned-dependent", true, &error), + error.c_str()); + check(parametric_reload.feature_enabled( + "requires.mod", "optioned-dependent") && + parametric_reload.feature_option_value( + "requires.mod", "prereq", "availability") == "everywhere", + "enabling an optioned dependent must auto-select the required " + "prerequisite value"); + check(parametric_reload.set_feature_option( + "requires.mod", "prereq", "availability", "main", &error), + error.c_str()); + check(parametric_reload.feature_enabled("requires.mod", "prereq") && + parametric_reload.feature_enabled("requires.mod", "dependent") && + !parametric_reload.feature_enabled( + "requires.mod", "optioned-dependent"), + "weakening a prerequisite option must disable invalid dependents"); + check(parametric_reload.set_feature_enabled( + "requires.mod", "prereq", false, &error), error.c_str()); + check(!parametric_reload.feature_enabled("requires.mod", "prereq") && + !parametric_reload.feature_enabled("requires.mod", "dependent"), + "disabling a prerequisite must disable downstream dependents"); + const auto reject_parametric_manifest = [&](const std::string& name, const std::string& body) { const fs::path path = root / (name + ".toml");