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..d40dc0450 --- /dev/null +++ b/docs/MOD_PACKAGES.md @@ -0,0 +1,280 @@ +# PSXRecomp mod packages and features + +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. + +Feature identity is always `(package_id, feature_id)`. Enabling one feature +never enables, disables, or reconfigures another feature. + +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.localization" +version = "1.2.0" +name = "Example Localization Pack" +author = "Example Author" +description = "Independent title and script features." +license = "MIT" +resolver = "declarative" + +[[target]] +game_id = "SLUS-00000" +# 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]] +feature = "title-screen" +id = "variant" +label = "Title artwork" +type = "choice" +default = "rockman" + +[[option.choice]] +value = "mega-man" +label = "Mega Man X6 (USA)" + +[[option.choice]] +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 = { 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 = "..." +``` + +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. + +## 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 02 24" +replace_from = { option = "count", encoding = "u16le", offset = 0 } + +[[patch]] +feature = "starting-lives" +target = "main_exe" +address = 0x8001DE70 +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`. `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 +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. + +## 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 +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. + +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. + +## 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 or absolute paths, limits archives to 4096 +files and 256 MiB expanded size, stages extraction, validates the manifest, and +publishes the version atomically. 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..cef87f58e --- /dev/null +++ b/runtime/include/mod_packages.h @@ -0,0 +1,284 @@ +#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 feature_id; + 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 ModFeature { + std::string id; + std::string name; + std::string description; + std::string group = "General"; + bool default_enabled = false; + bool legacy = false; +}; + +enum class ModConstraintKind { + OrderedInteger, + RequiresFeature, +}; + +enum class ModConstraintDirection { + Nondecreasing, + Nonincreasing, +}; + +struct ModConstraint { + std::string feature_id; + ModConstraintKind kind = ModConstraintKind::OrderedInteger; + ModConstraintDirection direction = + ModConstraintDirection::Nondecreasing; + std::vector options; + std::string required_feature_id; + std::string required_option_id; + std::string required_value; +}; + +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, +}; + +enum class ModValueEncoding { + U8, + U16LE, + U32LE, + MipsLuiOriU32, +}; + +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; + 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; + 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; + std::string patch_sha256; + uint64_t output_size = 0; + std::string output_sha256; + std::string when_option; + std::string when_value; + std::map when; +}; + +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 features; + std::vector options; + std::vector constraints; + 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 { + 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::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; + std::string patch_sha256; + uint64_t output_size = 0; + std::string output_sha256; + 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; +}; + +struct ModBuiltinResolverContext { + const std::map* active_packages = nullptr; + const std::map* selections = nullptr; +}; + +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); + 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 = {}, + 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..cd3aa6d33 --- /dev/null +++ b/runtime/include/mod_runtime.h @@ -0,0 +1,44 @@ +#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(); +const std::filesystem::path& mod_runtime_effective_disc_path(); + +#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..e8895f669 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() @@ -663,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/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 6cdf33cbb..30a1814ef 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 */ @@ -5217,6 +5218,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); @@ -5442,6 +5453,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; @@ -5608,6 +5620,27 @@ 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)) { + 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. */ @@ -5633,19 +5666,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. */ @@ -5780,6 +5813,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..773a32bf6 --- /dev/null +++ b/runtime/src/mod_packages.cpp @@ -0,0 +1,2549 @@ +#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 kMinFormatVersion = 1; +constexpr uint32_t kMaxFormatVersion = 4; +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 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()); + 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; +} + +bool parse_value_encoding(const std::string& text, ModValueEncoding& out) { + static const std::pair values[] = { + {"u8", ModValueEncoding::U8}, + {"u16le", ModValueEncoding::U16LE}, + {"u32le", ModValueEncoding::U32LE}, + {"mips_lui_ori_u32", ModValueEncoding::MipsLuiOriU32}, + }; + 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; + case ModValueEncoding::MipsLuiOriU32: + return 8; + } + return 0; +} + +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, + const std::vector& overlays, + 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); + 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 << ':' << 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 << ':' + << derived.patch_sha256 << ':' << derived.output_size << ':' + << derived.output_sha256 << ':' << derived.package_id << '\n'; + } + 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; +} + +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) { + 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 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 prospective_feature_enabled( + const ModPackage& package, const ModSelection& selection, + 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) { + 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; +} + +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) { + for (const auto& [id, value] : conditions) { + if (effective_option_value(package, selection, feature_id, id) != value) + return false; + } + 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, + 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.feature_id == feature_id && item.id == id; + }); + 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"); + } +} + +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; +} + +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; +} + +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) { + 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(); +} + +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; }); + 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 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)) + 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; + case ModValueEncoding::MipsLuiOriU32: + 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); + if (encoding == ModValueEncoding::MipsLuiOriU32) return false; + 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) { + 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 < 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"); + 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"); + + 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; + 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 (!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; + 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 || + !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 { + throw std::runtime_error("unknown option type"); + } + 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"); + if (!find_feature(out, constraint.feature_id)) + throw std::runtime_error( + "constraint references unknown feature"); + 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( + "constraint defaults are invalid: " + reason); + } else if (kind == "requires_feature") { + if (out.format_version < 4) + throw std::runtime_error( + "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"); + } + 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()) { + 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; + 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"); + if (!parse_hex_bytes(expected, patch.expected) || + patch.expected.empty()) + throw std::runtime_error( + "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 != "offset" && key != "addend" && + key != "omit_when_default") + 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"); + 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); + 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 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) + throw std::runtime_error( + "replace_from must reference a same-feature integer option"); + 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 value exceeds expected byte range"); + 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"); + 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 : + patch.target == ModPatchTarget::DiscUser ? 2048 : 0; + if (sector_size != 0 && + 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"); + 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; + } + } + 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"); + 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"); + 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; + 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; + } + 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()); + 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)) { + continue; + } + 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 && 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); + } + } + 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 = 2\n"; + for (const auto& [id, selection] : selections_) { + out << "\n[[package]]\n"; + out << "id = " << quote_toml(id) << "\n"; + if (!selection.version.empty()) + out << "version = " << quote_toml(selection.version) << "\n"; + 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()); + 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); + 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, 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); + 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) { + const auto pit = packages_.find(id); + if (pit == packages_.end()) { + 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; +} + +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; + } + 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.feature_id == "legacy" && option.id == option_id; + }); + if (oit == package->options.end()) { + set_error(error, "unknown package option"); + return false; + } + if (!valid_option_value(*oit, value)) { + set_error(error, "invalid option value"); + return false; + } + selections_[id].values[option_id] = value; + 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; + } + 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, nullptr, nullptr, nullptr, nullptr, + nullptr, &failing_feature, &reason)) { + set_error(error, package_id + "/" + + (failing_feature.empty() ? feature_id : failing_feature) + + ": " + reason); + return false; + } + selections_[package_id] = std::move(package_selection); + 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; + } + 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, nullptr, nullptr, + nullptr, &failing_feature, &reason)) { + set_error(error, package_id + "/" + + (failing_feature.empty() ? feature_id : failing_feature) + + ": " + reason); + return false; + } + selections_[package_id] = std::move(package_selection); + 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); +} + +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, 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) { + 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; + } + 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); + const ModSelection blank_selection; + const ModSelection& effective_selection = + selection == selections_.end() + ? blank_selection + : selection->second; + 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) { + 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 (!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) { + 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; + } + + 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; + const ModSelection& selected = + selected_it == selections_.end() ? blank : selected_it->second; + if (package->resolver == "declarative") { + for (const ModDerivedDisc& derived : package->derived_discs) { + 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; + 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) { + 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); + } + 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; + 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); + 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)) { + result.errors.push_back( + package->id + "/" + patch->feature_id + + ": could not encode replace_from option " + + patch->replace_from_option); + continue; + } + 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; + 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); + if (resolver != builtin_resolvers().end() && + !resolver->second( + *package, selected, resolver_context, + result.writes, result.errors) && + result.errors.empty()) + 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); + } + 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 + "/" + write.feature_id + + ": resolver emitted invalid write"); + continue; + } + bool duplicate = false; + for (const ModResolution::Write& previous : coalesced) { + if (identical_write(previous, write)) { + duplicate = true; + break; + } + if (!writes_overlap(previous, write)) continue; + uint64_t mismatch = 0; + if (!write_overlap_mismatch(previous, write, mismatch)) + continue; + ModResolution::Diagnostic diagnostic; + 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; + 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; + uint64_t mismatch = 0; + if (!write_overlay_mismatch(write, overlay, mismatch)) + continue; + ModResolution::Diagnostic diagnostic; + 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; + 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; + } + uint64_t mismatch = 0; + if (!overlay_overlap_mismatch(previous, overlay, mismatch)) + continue; + ModResolution::Diagnostic diagnostic; + 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; + 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.overlays, + result.derived_discs, + disc_sha256)); + 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..f8cad4987 --- /dev/null +++ b/runtime/src/mod_runtime.cpp @@ -0,0 +1,1059 @@ +#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 +#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); +extern "C" void dirty_ram_mark_executable_range(uint32_t phys, uint32_t len); + +namespace PSXRecompV4 { +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; + 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; + 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); +} + +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()) { + const auto value = selection->second.values.find(option.id); + if (value != selection->second.values.end()) return value->second; + } + 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; +} + +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; +} + +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 disc CUE: " + 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 = "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; + 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; + 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); + out->enabled = package_has_enabled_feature(*package); + 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; +} + +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); + 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; + out->removable = !out->selected || + !selected || !package_has_enabled_feature(*selected); + return 1; +} + +template +int mutate(Callback callback) { + std::string error; + if (!callback(error)) { + 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; +} + +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, + 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 + +} // 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.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(); + s.disc_sha256.clear(); + s.disc_path.clear(); + s.effective_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); + s.validation = plan; + 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; + } + 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; + return false; + } + if (!s.manager.save_state(&s.error)) { + if (error) *error = s.error; + 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(); + return true; +} + +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; +} +#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; + /* 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; + 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()); + } + } + 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/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..7b1977d1b --- /dev/null +++ b/runtime/tests/test_mod_packages.cpp @@ -0,0 +1,943 @@ +#include "mod_packages.h" +#include "psx_sha256.h" + +#include +#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_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" + "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" + "[[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")); + + 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.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; + + 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()); + + 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 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", + "\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.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()); + 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"); + + 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" + "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[[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" + "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" + "\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"); + 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.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()); + 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 == + partial_compatible.fingerprint, + "feature state must resolve deterministically after reload"); + + write_text(root / "packages/parametric.mod/1.0.0/manifest.toml", + "format_version = 3\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\"\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" + "type = \"integer\"\n" + "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" + "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\"\n" + "target = \"main_exe\"\n" + "address = 2147500040\n" + "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" + "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); + 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"); + 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"); + check(guarded_word_write && + 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()); + 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.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()); + 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"); + + 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"); + 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\",offset=2}\n"), + "replace_from value must stay inside 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-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" + "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}; + 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" + "[[feature]]\n" + "id = \"asset-a\"\n" + "name = \"Asset A\"\n" + "[[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" + "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" + "[[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() == 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 = + 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() == 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", + "\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" + "[[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..daaa2938e --- /dev/null +++ b/runtime/tests/test_mod_runtime.cpp @@ -0,0 +1,245 @@ +#include "mod_runtime.h" +#include "psx_sha256.h" + +#include +#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; +} + +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 = 2\n" + "id = \"runtime.test\"\n" + "version = \"1.0.0\"\n" + "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" + "[[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" + "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" + "[[patch]]\n" + "feature = \"user-byte\"\n" + "target = \"disc_user\"\n" + "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" + "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 = 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" + "[[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" + "[[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( + 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] = 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; + 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"); + + 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"); + + 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"; + 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())