diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index f65268b8..c583b852 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -1,176 +1,170 @@ -# PSXRecomp mod packages +# PSXRecomp mod packages and features -PSXRecomp games may expose a shared Dear ImGui **Mods** view backed by versioned -`.psxmod` packages. A package is a ZIP archive with `manifest.toml` at its root. -Packages are installed under `mods/packages///`; the selected -versions and option values live in `mods/state.toml`. +A `.psxmod` is a versioned installation, provenance, and trust boundary. A +package may contribute any number of independently configurable **features**. +The launcher presents those features as the primary Mods list; package +installation, version selection, and removal are a secondary management view. -Mods are resolved and fingerprinted before boot. They never rewrite the user's -disc or the recomp executable. +Feature identity is always `(package_id, feature_id)`. Enabling one feature +never enables, disables, or reconfigures another feature. -## Minimal manifest +The player selects a verified stock BIN/CUE. Resolution produces guarded native +operations and sparse disc overlays without rewriting or replacing that stock +image. + +## Feature manifest ```toml format_version = 1 -id = "example.faster-charge" +id = "example.localization" version = "1.2.0" -name = "Faster Charge" +name = "Example Localization Pack" author = "Example Author" -description = "Shortens the charge delay." +description = "Independent title and script features." license = "MIT" resolver = "declarative" -save_compatibility = "shared" # or "isolated" -conflicts = ["example.incompatible"] [[target]] game_id = "SLUS-00000" -# Optional. When present, the selected image must have this digest. -exe_sha256 = "..." -disc_sha256 = "..." +# Required for disc overlays. Use the digest of the supported stock image. +disc_sha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" -[[dependency]] -id = "example.core" -version = "^1.0.0" +[[feature]] +id = "title-screen" +name = "Title Screen" +description = "Selects the title-screen artwork." +group = "Localization" +default_enabled = false + +[[feature]] +id = "retranslation" +name = "Retranslation" +description = "Uses the revised English script." +group = "Localization" [[option]] -id = "delay" -label = "Charge delay" -description = "Delay in frames." -group = "Game balance" +feature = "title-screen" +id = "variant" +label = "Title artwork" type = "choice" -default = "normal" +default = "rockman" [[option.choice]] -value = "normal" -label = "Normal" +value = "mega-man" +label = "Mega Man X6 (USA)" [[option.choice]] -value = "fast" -label = "Fast" +value = "rockman" +label = "Rockman X6 (Japan)" [[patch]] +feature = "title-screen" target = "main_exe" address = 0x80041234 expected = "2a 00 02 24" replace = "0e 00 02 24" -when_option = "delay" -when_value = "fast" -order = 10 +when = { variant = "rockman" } -# Optional: use this for structural changes that cannot be represented as -# equal-size sector writes. Multiple entries may select different recipes from -# one launcher option, but exactly one may resolve in the complete mod plan. -[[derived_disc]] -kind = "vcdiff" -patch = "assets/fast.xdelta3" -patch_sha256 = "..." -output_size = 600000000 -output_sha256 = "..." -when_option = "delay" -when_value = "fast" - -# For option matrices, use a condition table. All listed option values must -# match. `when_option` / `when_value` remain supported for single-option cases. -[[derived_disc]] -kind = "vcdiff" -patch = "assets/fast-rockman.xdelta3" -patch_sha256 = "..." -output_size = 600000000 -output_sha256 = "..." -when = { delay = "fast", title_screen = "rockman_japan" } +[[overlay]] +feature = "retranslation" +target = "disc_raw" +offset = 123456 +file = "assets/retranslated-script.bin" +sha256 = "..." +# Optional additional guard over the same range in the stock image. +expected_sha256 = "..." ``` -Option types are `boolean`, `choice`, and bounded `integer`. `when_option` / -`when_value` are optional; `when = { option = "value", ... }` can be used when a -patch or derived-disc recipe depends on multiple option values. An unconditional -patch omits both condition forms. +Every `[[option]]`, `[[patch]]`, and `[[overlay]]` in a feature-style manifest +must name its owning feature. Ambiguous operations are rejected. -## Patch targets +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. -- `main_exe`: `address` is a PSX guest virtual address. All expected bytes are - checked after the BIOS loads the PS-X EXE. The complete main-EXE plan is then - applied before the configured entry point executes. -- `disc_raw`: `offset` is in the canonical 2352-byte raw-sector stream - (`lba * 2352 + byte_in_sector`). -- `disc_user`: `offset` is in the canonical 2048-byte user-data stream - (`lba * 2048 + byte_in_sector`). +## Native operations -A disc operation may not cross a sector boundary. Use multiple operations. -Expected and replacement data are equal-length hexadecimal byte strings. +`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. -Changed main-EXE code is deliberately not represented by a precompiled -permutation. PSXRecomp's exact text-image guard sees the changed live RAM and -routes that code through the existing dirty-RAM interpreter/native overlay -cache. Untouched functions stay on the static native path. This makes runtime -cost proportional to the code actually changed, not to the number of possible -option combinations. +Small `disc_raw` and `disc_user` patches are equal-length guarded writes and may +not cross a sector boundary: -## Derived discs +- `disc_raw` offsets use `lba * 2352 + byte_in_sector`. +- `disc_user` offsets use `lba * 2048 + byte_in_sector`. -A `derived_disc` is a data-only VCDIFF recipe whose source is the verified stock -disc from `[[target]].disc_sha256`. It is intended for mods that relocate files, -grow the ISO, replace large assets, or otherwise change disc geometry. A package -may contain a matrix of conditional recipes for its own options, but exactly one -recipe may resolve in the complete mod plan. +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. -The launcher continues to display and persist the user's stock BIN/CUE. Before -boot, the runtime: +Feature disc overlays require an exact `disc_sha256` on every target entry. +`expected_sha256` can additionally guard the replaced stock range. -1. fingerprints that stock image and resolves package options; -2. verifies the package's VCDIFF payload; -3. invokes the release's trusted `xdelta3` binary (packages cannot provide an - executable); -4. verifies the derived size and SHA-256; and -5. atomically publishes and mounts - `mods/cache/.bin`. +## State and migration -Changing package versions or options changes the plan fingerprint and therefore -the cache key. A cached result is reused on later launches. Ordinary guarded -sector overlays may be applied on top of the derived image, so a structural -base package can support many small composable add-ons. +`mods/state.toml` format 2 stores selected package versions separately from +per-feature enabled states and values: -Release builders stage the trusted decoder with -`-DPSXRECOMP_XDELTA3_EXECUTABLE=/path/to/xdelta3`. More than one active -derived-disc recipe is rejected after option resolution; packages should use a -single owner package for structural transforms and dependencies/conflicts for -external ownership. +```toml +format_version = 2 -Do not split one structural option system into many mutually exclusive -full-disc packages. For example, a game-wide Tweaks system should be one package -with launcher options and a conditional recipe matrix. Choices that are -mutually exclusive by design belong inside that package as option values, while -unrelated mods should remain separate packages and compose normally. +[[package]] +id = "example.localization" +version = "1.2.0" -## Resolution rules +[[feature]] +package_id = "example.localization" +id = "title-screen" +enabled = true -- Installed versions are side-by-side. The launcher can select an older version - to roll back. -- Enabling or disabling one package changes only that package. It does not - silently toggle other packages. -- Enabled packages are topologically ordered by dependencies, then by stable - package/patch order. -- Missing dependencies, version mismatches, declared conflicts, dependency - cycles, overlapping writes, unavailable trusted resolvers, invalid option - values, multiple derived-disc providers, and target mismatches prevent launch. -- The resolved package versions, option values, writes, and derived-disc recipe - produce a canonical SHA-256 plan fingerprint suitable for diagnostics and - multiplayer agreement. -- Package and state changes apply on the next launch. There is no mid-frame - mutation. +[feature.values] +variant = "rockman" +``` -## Trusted adapters +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. -`resolver = "builtin:"` selects a resolver statically registered by the game -executable. This is for legacy patch systems whose dependency and composition -rules cannot be expressed as independent declarative writes. A package cannot -load native code or choose an arbitrary symbol: unregistered IDs fail closed. -The adapter emits the same expected-byte-guarded resolved writes as a -declarative package, so validation, overlap checks, fingerprinting, and runtime -execution remain shared. +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. -## Archive safety +## Resolution and diagnostics + +Before boot, the manager: + +1. verifies the selected stock game and revision; +2. expands only enabled features and their selected options; +3. orders active packages deterministically by dependencies; +4. verifies enabled payloads and operation bounds; +5. collision-checks the complete byte-range plan; +6. coalesces only truly identical target/range/expected/replacement writes or + identical overlays; and +7. produces a canonical SHA-256 plan fingerprint. + +Incompatible overlaps fail before launch. Structured diagnostics identify both +`(package, feature)` owners and the exact contested target range. The launcher +marks both feature rows and lets the user decide what to disable. It never +silently chooses a winner. + +Package-level dependencies and conflicts are reserved for actual implementation +relationships. Mutually exclusive choices such as US versus Japanese artwork +belong inside one feature as option values. + +## Trusted adapters and archive safety + +`resolver = "builtin:"` selects a resolver statically registered by the +game. Packages cannot load arbitrary native code or select arbitrary symbols. The installer accepts stored or DEFLATE-compressed ZIP entries, validates CRCs, -rejects encrypted entries and unsafe/absolute paths, limits archives to 4096 +rejects encrypted entries and unsafe or absolute paths, limits archives to 4096 files and 256 MiB expanded size, stages extraction, validates the manifest, and -publishes the version with an atomic rename. +publishes the version atomically. diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index b9b5facf..5a677527 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -21,6 +21,7 @@ struct ModChoice { }; struct ModOption { + std::string feature_id; std::string id; std::string label; std::string description; @@ -33,6 +34,15 @@ struct ModOption { std::vector choices; }; +struct ModFeature { + std::string id; + std::string name; + std::string description; + std::string group = "General"; + bool default_enabled = false; + bool legacy = false; +}; + struct ModRequirement { std::string id; std::string version; @@ -51,6 +61,7 @@ enum class ModPatchTarget { }; struct ModPatch { + std::string feature_id; ModPatchTarget target = ModPatchTarget::MainExe; uint64_t location = 0; /* guest address or canonical disc-stream byte offset */ std::vector expected; @@ -61,6 +72,18 @@ struct ModPatch { int64_t order = 0; }; +struct ModOverlay { + std::string feature_id; + ModPatchTarget target = ModPatchTarget::DiscRaw; + uint64_t location = 0; + std::filesystem::path file; + std::string sha256; + std::string expected_sha256; + uint64_t size = 0; + std::map when; + int64_t order = 0; +}; + struct ModDerivedDisc { std::string kind = "vcdiff"; std::filesystem::path patch; @@ -86,15 +109,25 @@ struct ModPackage { std::vector targets; std::vector dependencies; std::vector conflicts; + std::vector features; std::vector options; std::vector patches; + std::vector overlays; std::vector derived_discs; }; +struct ModFeatureSelection { + bool enabled = false; + bool has_enabled = false; + std::map values; +}; + struct ModSelection { + /* v1 migration state. Feature-style manifests do not use these fields. */ bool enabled = false; std::string version; std::map values; + std::map features; }; struct ModResolution { @@ -107,8 +140,19 @@ struct ModResolution { std::vector expected; std::vector replacement; std::string package_id; + std::string feature_id; }; std::vector writes; + struct Overlay { + ModPatchTarget target = ModPatchTarget::DiscRaw; + uint64_t location = 0; + std::vector payload; + std::string payload_sha256; + std::string expected_sha256; + std::string package_id; + std::string feature_id; + }; + std::vector overlays; struct DerivedDisc { std::string kind; std::filesystem::path patch; @@ -118,6 +162,15 @@ struct ModResolution { std::string package_id; }; std::vector derived_discs; + struct Diagnostic { + std::string message; + std::string resource; + std::string package_id; + std::string feature_id; + std::string other_package_id; + std::string other_feature_id; + }; + std::vector diagnostics; std::vector errors; }; @@ -150,12 +203,27 @@ public: std::string* error = nullptr); bool set_option(const std::string& id, const std::string& option, const std::string& value, std::string* error = nullptr); + bool set_feature_enabled(const std::string& package_id, + const std::string& feature_id, bool enabled, + std::string* error = nullptr); + bool set_feature_option(const std::string& package_id, + const std::string& feature_id, + const std::string& option_id, + const std::string& value, + std::string* error = nullptr); const std::map>& packages() const { return packages_; } const std::map& selections() const { return selections_; } const ModPackage* selected_package(const std::string& id) const; + const ModFeature* selected_feature(const std::string& package_id, + const std::string& feature_id) const; + bool feature_enabled(const std::string& package_id, + const std::string& feature_id) const; + std::string feature_option_value(const std::string& package_id, + const std::string& feature_id, + const std::string& option_id) const; ModResolution resolve(const std::string& game_id, const std::string& exe_sha256 = {}, diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 50bf59e7..aad03e2b 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -569,6 +569,7 @@ bool target_matches(const ModPackage& package, const std::string& game, std::string canonical_resolution(const std::vector& ordered, const std::map& selections, const std::vector& writes, + const std::vector& overlays, const std::vector& derived_discs, const std::string& source_disc_sha256) { std::ostringstream out; @@ -576,16 +577,49 @@ std::string canonical_resolution(const std::vector& ordered, for (const ModPackage* package : ordered) { out << package->id << '@' << package->version << '\n'; const auto sit = selections.find(package->id); - if (sit == selections.end()) continue; - for (const auto& [key, value] : sit->second.values) - out << key << '=' << value << '\n'; + for (const ModFeature& feature : package->features) { + bool enabled = feature.default_enabled; + const std::map* values = nullptr; + if (sit != selections.end()) { + if (feature.legacy) { + enabled = sit->second.enabled; + values = &sit->second.values; + } else { + const auto selected = sit->second.features.find(feature.id); + if (selected != sit->second.features.end()) { + if (selected->second.has_enabled) + enabled = selected->second.enabled; + values = &selected->second.values; + } + } + } + out << "feature:" << feature.id << '=' + << (enabled ? "enabled" : "disabled") << '\n'; + for (const ModOption& option : package->options) { + if (option.feature_id != feature.id) continue; + const auto selected = values ? values->find(option.id) : + std::map::const_iterator{}; + const std::string value = + values && selected != values->end() + ? selected->second : option.default_value; + out << "feature:" << feature.id << ':' << option.id + << '=' << value << '\n'; + } + } } for (const ModResolution::Write& write : writes) { out << (write.target == ModPatchTarget::MainExe ? "main_exe" : write.target == ModPatchTarget::DiscRaw ? "disc_raw" : "disc_user") << '@' << std::hex << write.location << std::dec << ':' << hex_bytes(write.expected) << '>' << hex_bytes(write.replacement) - << ':' << write.package_id << '\n'; + << ':' << write.package_id << ':' << write.feature_id << '\n'; + } + for (const ModResolution::Overlay& overlay : overlays) { + out << (overlay.target == ModPatchTarget::DiscRaw + ? "disc_raw_overlay" : "disc_user_overlay") + << '@' << std::hex << overlay.location << std::dec << ':' + << overlay.payload_sha256 << ':' << overlay.expected_sha256 << ':' + << overlay.package_id << ':' << overlay.feature_id << '\n'; } for (const ModResolution::DerivedDisc& derived : derived_discs) { out << "derived_disc:" << derived.kind << ':' @@ -595,26 +629,81 @@ std::string canonical_resolution(const std::vector& ordered, return out.str(); } +const ModFeature* find_feature(const ModPackage& package, const std::string& id) { + const auto found = std::find_if(package.features.begin(), package.features.end(), + [&](const ModFeature& feature) { return feature.id == id; }); + return found == package.features.end() ? nullptr : &*found; +} + +const ModOption* find_option(const ModPackage& package, + const std::string& feature_id, + const std::string& id) { + const auto option = std::find_if(package.options.begin(), package.options.end(), + [&](const ModOption& item) { + return item.feature_id == feature_id && item.id == id; + }); + return option == package.options.end() ? nullptr : &*option; +} + +const ModFeatureSelection* find_feature_selection(const ModPackage& package, + const ModSelection& selection, + const std::string& feature_id) { + const ModFeature* feature = find_feature(package, feature_id); + if (!feature) return nullptr; + if (feature->legacy) return nullptr; + const auto found = selection.features.find(feature_id); + return found == selection.features.end() ? nullptr : &found->second; +} + +bool is_feature_enabled(const ModPackage& package, const ModSelection& selection, + const ModFeature& feature) { + if (feature.legacy) return selection.enabled; + const ModFeatureSelection* selected = + find_feature_selection(package, selection, feature.id); + return selected && selected->has_enabled + ? selected->enabled : feature.default_enabled; +} + +bool has_enabled_feature(const ModPackage& package, + const ModSelection& selection) { + return std::any_of(package.features.begin(), package.features.end(), + [&](const ModFeature& feature) { + return is_feature_enabled(package, selection, feature); + }); +} + std::string effective_option_value(const ModPackage& package, const ModSelection& selection, + const std::string& feature_id, const std::string& id) { - const auto selected = selection.values.find(id); - if (selected != selection.values.end()) return selected->second; - const auto option = std::find_if(package.options.begin(), package.options.end(), - [&](const ModOption& item) { return item.id == id; }); - return option == package.options.end() ? std::string() : option->default_value; + const ModFeature* feature = find_feature(package, feature_id); + if (feature && feature->legacy) { + const auto selected = selection.values.find(id); + if (selected != selection.values.end()) return selected->second; + } else { + const ModFeatureSelection* selected = + find_feature_selection(package, selection, feature_id); + if (selected) { + const auto value = selected->values.find(id); + if (value != selected->values.end()) return value->second; + } + } + const ModOption* option = find_option(package, feature_id, id); + return option ? option->default_value : std::string(); } bool conditions_match(const ModPackage& package, const ModSelection& selection, + const std::string& feature_id, const std::map& conditions) { for (const auto& [id, value] : conditions) { - if (effective_option_value(package, selection, id) != value) + if (effective_option_value(package, selection, feature_id, id) != value) return false; } return true; } void read_conditions(const toml::value& value, const std::vector& options, + const std::string& feature_id, std::map& when, const char* label) { const std::string when_option = @@ -638,7 +727,9 @@ void read_conditions(const toml::value& value, const std::vector& opt (void)condition_value; const auto option = std::find_if( options.begin(), options.end(), - [&](const ModOption& item) { return item.id == id; }); + [&](const ModOption& item) { + return item.feature_id == feature_id && item.id == id; + }); if (option == options.end()) throw std::runtime_error(std::string(label) + " references unknown option"); } @@ -651,6 +742,50 @@ bool writes_overlap(const ModResolution::Write& a, const ModResolution::Write& b return a.location < b_end && b.location < a_end; } +bool ranges_overlap(ModPatchTarget a_target, uint64_t a_location, size_t a_size, + ModPatchTarget b_target, uint64_t b_location, size_t b_size) { + if (a_target != b_target) return false; + const uint64_t a_end = a_location + a_size; + const uint64_t b_end = b_location + b_size; + return a_location < b_end && b_location < a_end; +} + +bool identical_write(const ModResolution::Write& a, const ModResolution::Write& b) { + return a.target == b.target && a.location == b.location && + a.expected == b.expected && a.replacement == b.replacement; +} + +std::string overlap_resource(ModPatchTarget target, + uint64_t a_location, size_t a_size, + uint64_t b_location, size_t b_size) { + const char* name = target == ModPatchTarget::MainExe ? "main_exe" : + target == ModPatchTarget::DiscRaw ? "disc_raw" : + "disc_user"; + const uint64_t begin = std::max(a_location, b_location); + const uint64_t end = std::min( + a_location + a_size, b_location + b_size); + std::ostringstream out; + out << name << ":0x" << std::hex << begin << "-0x" << end; + return out.str(); +} + +bool valid_option_value(const ModOption& option, const std::string& value) { + if (option.type == ModOptionType::Boolean) + return value == "true" || value == "false"; + if (option.type == ModOptionType::Choice) + return std::any_of(option.choices.begin(), option.choices.end(), + [&](const ModChoice& choice) { return choice.value == value; }); + try { + size_t used = 0; + const int64_t parsed = std::stoll(value, &used); + return used == value.size() && parsed >= option.min_value && + parsed <= option.max_value && + ((parsed - option.min_value) % option.step) == 0; + } catch (...) { + return false; + } +} + std::string fingerprint_text(const std::string& text) { uint8_t digest[32]; psx_sha256_compute((const uint8_t*)text.data(), text.size(), digest); @@ -735,17 +870,53 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, for (const std::string& id : out.conflicts) if (!valid_id(id)) throw std::runtime_error("invalid conflict id"); + const bool feature_style = cfg.contains("feature"); + if (feature_style) { + std::set feature_ids; + for (const toml::value& v : toml::find(cfg, "feature").as_array()) { + ModFeature feature; + feature.id = toml::find(v, "id"); + feature.name = toml::find(v, "name"); + feature.description = v.contains("description") + ? toml::find(v, "description") : ""; + feature.group = v.contains("group") + ? toml::find(v, "group") : "General"; + feature.default_enabled = + toml::find_or(v, "default_enabled", false); + if (!valid_id(feature.id) || + !feature_ids.insert(feature.id).second) + throw std::runtime_error("invalid or duplicate feature id"); + if (feature.name.empty()) + throw std::runtime_error("feature name is empty"); + out.features.push_back(std::move(feature)); + } + if (out.features.empty()) + throw std::runtime_error("package has no [[feature]] entries"); + } else { + ModFeature feature; + feature.id = "legacy"; + feature.name = out.name; + feature.description = out.description; + feature.legacy = true; + out.features.push_back(std::move(feature)); + } + if (cfg.contains("option")) { - std::set option_ids; + std::set> option_ids; for (const toml::value& v : toml::find(cfg, "option").as_array()) { ModOption option; + option.feature_id = feature_style + ? toml::find(v, "feature") : "legacy"; option.id = toml::find(v, "id"); option.label = toml::find(v, "label"); option.description = v.contains("description") ? toml::find(v, "description") : ""; option.group = v.contains("group") ? toml::find(v, "group") : "General"; const std::string type = toml::find(v, "type"); - if (!valid_id(option.id) || !option_ids.insert(option.id).second) + if (!find_feature(out, option.feature_id)) + throw std::runtime_error("option references unknown feature"); + if (!valid_id(option.id) || + !option_ids.insert({option.feature_id, option.id}).second) throw std::runtime_error("invalid or duplicate option id"); if (type == "boolean") { option.type = ModOptionType::Boolean; @@ -785,6 +956,10 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, size_t declaration_index = 0; for (const toml::value& v : toml::find(cfg, "patch").as_array()) { ModPatch patch; + patch.feature_id = feature_style + ? toml::find(v, "feature") : "legacy"; + if (!find_feature(out, patch.feature_id)) + throw std::runtime_error("patch references unknown feature"); const std::string target = toml::find(v, "target"); if (target == "main_exe") { patch.target = ModPatchTarget::MainExe; @@ -822,7 +997,7 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, "disc patch may not cross a sector boundary"); patch.order = toml::find_or( v, "order", (int64_t)declaration_index); - read_conditions(v, out.options, patch.when, "patch"); + read_conditions(v, out.options, patch.feature_id, patch.when, "patch"); if (!patch.when.empty()) { patch.when_option = patch.when.begin()->first; patch.when_value = patch.when.begin()->second; @@ -831,7 +1006,77 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, ++declaration_index; } } + if (cfg.contains("overlay")) { + if (!feature_style) + throw std::runtime_error( + "disc overlays require explicit [[feature]] ownership"); + size_t declaration_index = 0; + for (const toml::value& v : toml::find(cfg, "overlay").as_array()) { + ModOverlay overlay; + overlay.feature_id = toml::find(v, "feature"); + if (!find_feature(out, overlay.feature_id)) + throw std::runtime_error("overlay references unknown feature"); + const std::string target = toml::find(v, "target"); + if (target == "disc_raw") + overlay.target = ModPatchTarget::DiscRaw; + else if (target == "disc_user") + overlay.target = ModPatchTarget::DiscUser; + else + throw std::runtime_error( + "overlay target must be disc_raw or disc_user"); + const int64_t offset = toml::find(v, "offset"); + if (offset < 0) + throw std::runtime_error("overlay offset is negative"); + overlay.location = (uint64_t)offset; + const std::string relative_file = + toml::find(v, "file"); + if (!safe_archive_name(relative_file)) + throw std::runtime_error("overlay file path is unsafe"); + overlay.file = out.root / fs::path(relative_file); + overlay.sha256 = toml::find(v, "sha256"); + overlay.expected_sha256 = v.contains("expected_sha256") + ? toml::find(v, "expected_sha256") : ""; + if (!valid_sha256(overlay.sha256) || + (!overlay.expected_sha256.empty() && + !valid_sha256(overlay.expected_sha256))) + throw std::runtime_error( + "overlay hashes must be lowercase SHA-256"); + std::string file_error; + std::vector payload; + if (!read_file(overlay.file, payload, &file_error)) + throw std::runtime_error(file_error); + if (payload.empty()) + throw std::runtime_error("overlay payload is empty"); + const std::string actual = fingerprint_text(std::string( + (const char*)payload.data(), payload.size())); + if (actual != overlay.sha256) + throw std::runtime_error("overlay payload checksum failed"); + overlay.size = payload.size(); + if (overlay.location > + std::numeric_limits::max() - overlay.size) + throw std::runtime_error("overlay range overflows"); + overlay.order = toml::find_or( + v, "order", (int64_t)declaration_index); + read_conditions(v, out.options, overlay.feature_id, + overlay.when, "overlay"); + out.overlays.push_back(std::move(overlay)); + ++declaration_index; + } + const bool guarded_stock = std::all_of( + out.targets.begin(), out.targets.end(), + [](const ModTarget& target) { + return valid_sha256(target.disc_sha256); + }); + if (!out.overlays.empty() && !guarded_stock) + throw std::runtime_error( + "feature disc overlays require an exact disc_sha256 " + "on every [[target]]"); + } if (cfg.contains("derived_disc")) { + if (feature_style) + throw std::runtime_error( + "derived_disc is a legacy conversion artifact and may not " + "be used by feature-style packages"); for (const toml::value& v : toml::find(cfg, "derived_disc").as_array()) { ModDerivedDisc derived; derived.kind = toml::find_or(v, "kind", "vcdiff"); @@ -850,7 +1095,8 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, if (output_size <= 0) throw std::runtime_error("derived_disc output_size must be positive"); derived.output_size = (uint64_t)output_size; - read_conditions(v, out.options, derived.when, "derived_disc"); + read_conditions(v, out.options, "legacy", + derived.when, "derived_disc"); if (!derived.when.empty()) { derived.when_option = derived.when.begin()->first; derived.when_value = derived.when.begin()->second; @@ -911,25 +1157,62 @@ bool ModPackageManager::load_state(std::string* error) { try { const toml::value cfg = toml::parse(path.string()); const int64_t version = toml::find(cfg, "format_version"); - if (version != 1) throw std::runtime_error("unsupported state format_version"); - if (!cfg.contains("package")) return true; - for (const toml::value& v : toml::find(cfg, "package").as_array()) { - const std::string id = toml::find(v, "id"); - if (!valid_id(id)) throw std::runtime_error("invalid state package id"); - ModSelection selection; - selection.enabled = toml::find_or(v, "enabled", false); - selection.version = toml::find_or(v, "version", ""); - if (v.contains("values")) { - for (const auto& [key, value] : toml::find(v, "values").as_table()) { - if (value.is_string()) selection.values[key] = toml::get(value); - else if (value.is_boolean()) - selection.values[key] = toml::get(value) ? "true" : "false"; - else if (value.is_integer()) - selection.values[key] = std::to_string(toml::get(value)); - else throw std::runtime_error("state option values must be scalar"); + if (version != 1 && version != 2) + throw std::runtime_error("unsupported state format_version"); + if (cfg.contains("package")) { + for (const toml::value& v : toml::find(cfg, "package").as_array()) { + const std::string id = toml::find(v, "id"); + if (!valid_id(id)) throw std::runtime_error("invalid state package id"); + ModSelection selection; + selection.enabled = toml::find_or(v, "enabled", false); + selection.version = toml::find_or(v, "version", ""); + if (v.contains("values")) { + for (const auto& [key, value] : toml::find(v, "values").as_table()) { + if (value.is_string()) + selection.values[key] = toml::get(value); + else if (value.is_boolean()) + selection.values[key] = + toml::get(value) ? "true" : "false"; + else if (value.is_integer()) + selection.values[key] = + std::to_string(toml::get(value)); + else + throw std::runtime_error( + "state option values must be scalar"); + } } + selections_[id] = std::move(selection); + } + } + if (version == 2 && cfg.contains("feature")) { + for (const toml::value& v : toml::find(cfg, "feature").as_array()) { + const std::string package_id = + toml::find(v, "package_id"); + const std::string feature_id = toml::find(v, "id"); + if (!valid_id(package_id) || !valid_id(feature_id)) + throw std::runtime_error("invalid state feature identity"); + ModFeatureSelection feature; + feature.enabled = toml::find(v, "enabled"); + feature.has_enabled = true; + if (v.contains("values")) { + for (const auto& [key, value] : + toml::find(v, "values").as_table()) { + if (value.is_string()) + feature.values[key] = toml::get(value); + else if (value.is_boolean()) + feature.values[key] = + toml::get(value) ? "true" : "false"; + else if (value.is_integer()) + feature.values[key] = + std::to_string(toml::get(value)); + else + throw std::runtime_error( + "state feature option values must be scalar"); + } + } + selections_[package_id].features[feature_id] = + std::move(feature); } - selections_[id] = std::move(selection); } return true; } catch (const std::exception& ex) { @@ -952,19 +1235,46 @@ bool ModPackageManager::save_state(std::string* error) const { set_error(error, "cannot write " + temp.string()); return false; } - out << "format_version = 1\n"; + out << "format_version = 2\n"; for (const auto& [id, selection] : selections_) { out << "\n[[package]]\n"; out << "id = " << quote_toml(id) << "\n"; - out << "enabled = " << (selection.enabled ? "true" : "false") << "\n"; if (!selection.version.empty()) out << "version = " << quote_toml(selection.version) << "\n"; - if (!selection.values.empty()) { + const ModPackage* package = find_selected(packages_, id, selection); + const bool legacy = package && package->features.size() == 1 && + package->features.front().legacy; + if (legacy) { + out << "enabled = " << (selection.enabled ? "true" : "false") << "\n"; + } + if (legacy && !selection.values.empty()) { out << "[package.values]\n"; for (const auto& [key, value] : selection.values) out << key << " = " << quote_toml(value) << "\n"; } } + for (const auto& [package_id, selection] : selections_) { + for (const auto& [feature_id, feature] : selection.features) { + bool enabled = feature.enabled; + if (!feature.has_enabled) { + const ModPackage* package = + find_selected(packages_, package_id, selection); + const ModFeature* manifest_feature = + package ? find_feature(*package, feature_id) : nullptr; + if (manifest_feature) + enabled = manifest_feature->default_enabled; + } + out << "\n[[feature]]\n"; + out << "package_id = " << quote_toml(package_id) << "\n"; + out << "id = " << quote_toml(feature_id) << "\n"; + out << "enabled = " << (enabled ? "true" : "false") << "\n"; + if (!feature.values.empty()) { + out << "[feature.values]\n"; + for (const auto& [key, value] : feature.values) + out << key << " = " << quote_toml(value) << "\n"; + } + } + } out.close(); if (!out) { set_error(error, "cannot finish " + temp.string()); @@ -1048,15 +1358,25 @@ bool ModPackageManager::install_archive(const fs::path& archive, bool ModPackageManager::remove_version(const std::string& id, const std::string& version, std::string* error) { const auto sit = selections_.find(id); - if (sit != selections_.end() && sit->second.enabled && - (sit->second.version.empty() || sit->second.version == version)) { + const ModSelection blank; + const ModSelection& current = + sit == selections_.end() ? blank : sit->second; + const ModPackage* selected = find_selected(packages_, id, current); + if (selected && selected->version == version && + has_enabled_feature(*selected, current)) { set_error(error, "cannot remove an active package version"); return false; } - for (const auto& [other_id, selection] : selections_) { - if (!selection.enabled || other_id == id) continue; - const ModPackage* package = find_selected(packages_, other_id, selection); - if (!package) continue; + for (const auto& [other_id, versions] : packages_) { + (void)versions; + const auto other_selection = selections_.find(other_id); + const ModSelection& selection = + other_selection == selections_.end() ? blank : + other_selection->second; + const ModPackage* package = + find_selected(packages_, other_id, selection); + if (!package || other_id == id || + !has_enabled_feature(*package, selection)) continue; for (const ModRequirement& dep : package->dependencies) { if (dep.id == id && version_satisfies(version, dep.version)) { set_error(error, "cannot remove a version required by " + other_id); @@ -1087,6 +1407,16 @@ bool ModPackageManager::set_enabled(const std::string& id, bool enabled, std::st set_error(error, "package is not installed"); return false; } + const ModPackage* package = selected_package(id); + if (!package) { + set_error(error, "package/version is not installed"); + return false; + } + if (package->features.size() != 1 || !package->features.front().legacy) { + set_error(error, + "feature-style packages must be enabled per feature"); + return false; + } selections_[id].enabled = enabled; return true; } @@ -1112,30 +1442,20 @@ bool ModPackageManager::set_option(const std::string& id, const std::string& opt set_error(error, "package/version is not installed"); return false; } + if (package->features.size() != 1 || !package->features.front().legacy) { + set_error(error, + "feature-style package options must name a feature"); + return false; + } const auto oit = std::find_if(package->options.begin(), package->options.end(), - [&](const ModOption& option) { return option.id == option_id; }); + [&](const ModOption& option) { + return option.feature_id == "legacy" && option.id == option_id; + }); if (oit == package->options.end()) { set_error(error, "unknown package option"); return false; } - bool valid = false; - if (oit->type == ModOptionType::Boolean) { - valid = value == "true" || value == "false"; - } else if (oit->type == ModOptionType::Choice) { - valid = std::any_of(oit->choices.begin(), oit->choices.end(), - [&](const ModChoice& choice) { return choice.value == value; }); - } else { - try { - size_t used = 0; - const int64_t parsed = std::stoll(value, &used); - valid = used == value.size() && parsed >= oit->min_value && - parsed <= oit->max_value && - ((parsed - oit->min_value) % oit->step) == 0; - } catch (...) { - valid = false; - } - } - if (!valid) { + if (!valid_option_value(*oit, value)) { set_error(error, "invalid option value"); return false; } @@ -1143,6 +1463,46 @@ bool ModPackageManager::set_option(const std::string& id, const std::string& opt return true; } +bool ModPackageManager::set_feature_enabled(const std::string& package_id, + const std::string& feature_id, + bool enabled, + std::string* error) { + const ModPackage* package = selected_package(package_id); + const ModFeature* feature = + package ? find_feature(*package, feature_id) : nullptr; + if (!feature || feature->legacy) { + set_error(error, "unknown package feature"); + return false; + } + ModFeatureSelection& selection = + selections_[package_id].features[feature_id]; + selection.enabled = enabled; + selection.has_enabled = true; + return true; +} + +bool ModPackageManager::set_feature_option(const std::string& package_id, + const std::string& feature_id, + const std::string& option_id, + const std::string& value, + std::string* error) { + const ModPackage* package = selected_package(package_id); + const ModFeature* feature = + package ? find_feature(*package, feature_id) : nullptr; + const ModOption* option = + package ? find_option(*package, feature_id, option_id) : nullptr; + if (!feature || feature->legacy || !option) { + set_error(error, "unknown feature option"); + return false; + } + if (!valid_option_value(*option, value)) { + set_error(error, "invalid feature option value"); + return false; + } + selections_[package_id].features[feature_id].values[option_id] = value; + return true; +} + const ModPackage* ModPackageManager::selected_package(const std::string& id) const { const auto selection = selections_.find(id); const ModSelection blank; @@ -1150,18 +1510,55 @@ const ModPackage* ModPackageManager::selected_package(const std::string& id) con selection == selections_.end() ? blank : selection->second); } +const ModFeature* ModPackageManager::selected_feature( + const std::string& package_id, const std::string& feature_id) const { + const ModPackage* package = selected_package(package_id); + return package ? find_feature(*package, feature_id) : nullptr; +} + +bool ModPackageManager::feature_enabled(const std::string& package_id, + const std::string& feature_id) const { + const ModPackage* package = selected_package(package_id); + const ModFeature* feature = + package ? find_feature(*package, feature_id) : nullptr; + if (!package || !feature) return false; + const auto found = selections_.find(package_id); + const ModSelection blank; + return is_feature_enabled( + *package, found == selections_.end() ? blank : found->second, *feature); +} + +std::string ModPackageManager::feature_option_value( + const std::string& package_id, const std::string& feature_id, + const std::string& option_id) const { + const ModPackage* package = selected_package(package_id); + if (!package) return {}; + const auto found = selections_.find(package_id); + const ModSelection blank; + return effective_option_value( + *package, found == selections_.end() ? blank : found->second, + feature_id, option_id); +} + ModResolution ModPackageManager::resolve(const std::string& game_id, const std::string& exe_sha256, const std::string& disc_sha256) const { ModResolution result; std::map active; - for (const auto& [id, selection] : selections_) { - if (!selection.enabled) continue; + for (const auto& [id, versions] : packages_) { + (void)versions; + const auto selected = selections_.find(id); + const ModSelection blank; + const ModSelection& selection = + selected == selections_.end() ? blank : selected->second; const ModPackage* package = find_selected(packages_, id, selection); if (!package) { - result.errors.push_back("selected package/version is not installed: " + id); + if (selected != selections_.end()) + result.errors.push_back( + "selected package/version is not installed: " + id); continue; } + if (!has_enabled_feature(*package, selection)) continue; if (!target_matches(*package, game_id, exe_sha256, disc_sha256)) { result.errors.push_back("package does not target this game/image: " + id); continue; @@ -1184,26 +1581,27 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, const auto selection = selections_.find(id); if (selection != selections_.end()) { for (const ModOption& option : package->options) { - const auto value = selection->second.values.find(option.id); - if (value == selection->second.values.end()) continue; - /* Reuse the public validation path without mutating by checking - * the same domain directly. Defaults require no state entry. */ - bool valid = false; - if (option.type == ModOptionType::Boolean) - valid = value->second == "true" || value->second == "false"; - else if (option.type == ModOptionType::Choice) - valid = std::any_of(option.choices.begin(), option.choices.end(), - [&](const ModChoice& c) { return c.value == value->second; }); - else { - try { - size_t used = 0; - const int64_t n = std::stoll(value->second, &used); - valid = used == value->second.size() && n >= option.min_value && - n <= option.max_value && - ((n - option.min_value) % option.step) == 0; - } catch (...) {} + const ModFeature* feature = + find_feature(*package, option.feature_id); + if (!feature || + !is_feature_enabled(*package, selection->second, *feature)) + continue; + const std::map* values = nullptr; + if (feature->legacy) { + values = &selection->second.values; + } else { + const auto feature_selection = + selection->second.features.find(feature->id); + if (feature_selection != selection->second.features.end()) + values = &feature_selection->second.values; } - if (!valid) result.errors.push_back(id + ": invalid value for " + option.id); + if (!values) continue; + const auto value = values->find(option.id); + if (value != values->end() && + !valid_option_value(option, value->second)) + result.errors.push_back( + id + "/" + feature->id + + ": invalid value for " + option.id); } } if (package->resolver.rfind("builtin:", 0) == 0) { @@ -1246,7 +1644,10 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, selected_it == selections_.end() ? blank : selected_it->second; if (package->resolver == "declarative") { for (const ModDerivedDisc& derived : package->derived_discs) { - if (!conditions_match(*package, selected, derived.when)) + const ModFeature& legacy = package->features.front(); + if (!legacy.legacy || + !is_feature_enabled(*package, selected, legacy) || + !conditions_match(*package, selected, "legacy", derived.when)) continue; ModResolution::DerivedDisc resolved; resolved.kind = derived.kind; @@ -1260,7 +1661,12 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, std::vector patches; patches.reserve(package->patches.size()); for (const ModPatch& patch : package->patches) { - if (!conditions_match(*package, selected, patch.when)) + const ModFeature* feature = + find_feature(*package, patch.feature_id); + if (!feature || + !is_feature_enabled(*package, selected, *feature) || + !conditions_match(*package, selected, + patch.feature_id, patch.when)) continue; patches.push_back(&patch); } @@ -1273,8 +1679,53 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, write.expected = patch->expected; write.replacement = patch->replacement; write.package_id = package->id; + write.feature_id = patch->feature_id; result.writes.push_back(std::move(write)); } + std::vector overlays; + overlays.reserve(package->overlays.size()); + for (const ModOverlay& overlay : package->overlays) { + const ModFeature* feature = + find_feature(*package, overlay.feature_id); + if (!feature || + !is_feature_enabled(*package, selected, *feature) || + !conditions_match(*package, selected, + overlay.feature_id, overlay.when)) + continue; + overlays.push_back(&overlay); + } + std::stable_sort(overlays.begin(), overlays.end(), + [](const ModOverlay* a, const ModOverlay* b) { + return a->order < b->order; + }); + for (const ModOverlay* overlay : overlays) { + std::vector payload; + std::string payload_error; + if (!read_file(overlay->file, payload, &payload_error)) { + result.errors.push_back( + package->id + "/" + overlay->feature_id + ": " + + payload_error); + continue; + } + const std::string actual = fingerprint_text(std::string( + (const char*)payload.data(), payload.size())); + if (payload.size() != overlay->size || + actual != overlay->sha256) { + result.errors.push_back( + package->id + "/" + overlay->feature_id + + ": overlay payload changed after installation"); + continue; + } + ModResolution::Overlay resolved; + resolved.target = overlay->target; + resolved.location = overlay->location; + resolved.payload = std::move(payload); + resolved.payload_sha256 = overlay->sha256; + resolved.expected_sha256 = overlay->expected_sha256; + resolved.package_id = package->id; + resolved.feature_id = overlay->feature_id; + result.overlays.push_back(std::move(resolved)); + } } else { const std::string resolver_id = package->resolver.substr(8); const auto resolver = builtin_resolvers().find(resolver_id); @@ -1293,31 +1744,113 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, result.errors.push_back( "more than one derived-disc provider is active: " + providers); } - for (size_t i = 0; i < result.writes.size(); ++i) { - const ModResolution::Write& write = result.writes[i]; + std::vector coalesced; + coalesced.reserve(result.writes.size()); + for (const ModResolution::Write& write : result.writes) { if (write.expected.empty() || write.expected.size() != write.replacement.size()) { - result.errors.push_back(write.package_id + ": resolver emitted invalid write"); + result.errors.push_back( + write.package_id + "/" + write.feature_id + + ": resolver emitted invalid write"); continue; } - for (size_t j = 0; j < i; ++j) { - if (writes_overlap(result.writes[j], write)) { - result.errors.push_back( - write.package_id + ": patch overlaps a write from " + - result.writes[j].package_id); + bool duplicate = false; + for (const ModResolution::Write& previous : coalesced) { + if (identical_write(previous, write)) { + duplicate = true; break; } + if (!writes_overlap(previous, write)) continue; + ModResolution::Diagnostic diagnostic; + diagnostic.resource = overlap_resource( + write.target, write.location, write.replacement.size(), + previous.location, previous.replacement.size()); + diagnostic.package_id = write.package_id; + diagnostic.feature_id = write.feature_id; + diagnostic.other_package_id = previous.package_id; + diagnostic.other_feature_id = previous.feature_id; + diagnostic.message = + write.package_id + "/" + write.feature_id + + " collides at " + diagnostic.resource + " with " + + previous.package_id + "/" + previous.feature_id; + result.diagnostics.push_back(diagnostic); + result.errors.push_back(diagnostic.message); + duplicate = true; + break; } + if (!duplicate) coalesced.push_back(write); } + result.writes = std::move(coalesced); + std::vector coalesced_overlays; + coalesced_overlays.reserve(result.overlays.size()); + for (const ModResolution::Overlay& overlay : result.overlays) { + bool claimed = false; + for (const ModResolution::Write& write : result.writes) { + if (!ranges_overlap( + overlay.target, overlay.location, overlay.payload.size(), + write.target, write.location, write.replacement.size())) + continue; + ModResolution::Diagnostic diagnostic; + diagnostic.resource = overlap_resource( + overlay.target, overlay.location, overlay.payload.size(), + write.location, write.replacement.size()); + diagnostic.package_id = overlay.package_id; + diagnostic.feature_id = overlay.feature_id; + diagnostic.other_package_id = write.package_id; + diagnostic.other_feature_id = write.feature_id; + diagnostic.message = + overlay.package_id + "/" + overlay.feature_id + + " collides at " + diagnostic.resource + " with " + + write.package_id + "/" + write.feature_id; + result.diagnostics.push_back(diagnostic); + result.errors.push_back(diagnostic.message); + claimed = true; + break; + } + if (claimed) continue; + for (const ModResolution::Overlay& previous : coalesced_overlays) { + if (!ranges_overlap( + overlay.target, overlay.location, overlay.payload.size(), + previous.target, previous.location, previous.payload.size())) + continue; + if (overlay.target == previous.target && + overlay.location == previous.location && + overlay.payload == previous.payload && + overlay.expected_sha256 == previous.expected_sha256) { + claimed = true; + break; + } + ModResolution::Diagnostic diagnostic; + diagnostic.resource = overlap_resource( + overlay.target, overlay.location, overlay.payload.size(), + previous.location, previous.payload.size()); + diagnostic.package_id = overlay.package_id; + diagnostic.feature_id = overlay.feature_id; + diagnostic.other_package_id = previous.package_id; + diagnostic.other_feature_id = previous.feature_id; + diagnostic.message = + overlay.package_id + "/" + overlay.feature_id + + " collides at " + diagnostic.resource + " with " + + previous.package_id + "/" + previous.feature_id; + result.diagnostics.push_back(diagnostic); + result.errors.push_back(diagnostic.message); + claimed = true; + break; + } + if (!claimed) coalesced_overlays.push_back(overlay); + } + result.overlays = std::move(coalesced_overlays); if (!result.errors.empty()) { result.ordered.clear(); result.writes.clear(); + result.overlays.clear(); result.derived_discs.clear(); return result; } result.fingerprint = fingerprint_text( canonical_resolution( - result.ordered, selections_, result.writes, result.derived_discs, + result.ordered, selections_, result.writes, result.overlays, + result.derived_discs, disc_sha256)); result.ok = true; return result; diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index f819e24b..f8cad498 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -41,6 +41,11 @@ namespace { struct RuntimeMods { ModPackageManager manager; ModResolution plan; + ModResolution validation; + std::map> raw_disc_index; + std::map> user_disc_index; + std::map> raw_overlay_index; + std::map> user_overlay_index; std::string game_id; std::string error; std::string exe_sha256; @@ -63,6 +68,14 @@ const ModPackage* selected_package(const std::string& id) { return state().manager.selected_package(id); } +bool package_has_enabled_feature(const ModPackage& package) { + return std::any_of( + package.features.begin(), package.features.end(), + [&](const ModFeature& feature) { + return state().manager.feature_enabled(package.id, feature.id); + }); +} + std::string selected_value(const ModPackage& package, const ModOption& option) { const auto selection = state().manager.selections().find(package.id); if (selection != state().manager.selections().end()) { @@ -72,6 +85,32 @@ std::string selected_value(const ModPackage& package, const ModOption& option) { return option.default_value; } +void build_disc_index(RuntimeMods& s) { + s.raw_disc_index.clear(); + s.user_disc_index.clear(); + s.raw_overlay_index.clear(); + s.user_overlay_index.clear(); + for (size_t i = 0; i < s.plan.writes.size(); ++i) { + const ModResolution::Write& write = s.plan.writes[i]; + if (write.target == ModPatchTarget::DiscRaw) + s.raw_disc_index[(uint32_t)(write.location / 2352)].push_back(i); + else if (write.target == ModPatchTarget::DiscUser) + s.user_disc_index[(uint32_t)(write.location / 2048)].push_back(i); + } + for (size_t i = 0; i < s.plan.overlays.size(); ++i) { + const ModResolution::Overlay& overlay = s.plan.overlays[i]; + const uint64_t sector_size = + overlay.target == ModPatchTarget::DiscRaw ? 2352 : 2048; + auto& index = overlay.target == ModPatchTarget::DiscRaw + ? s.raw_overlay_index : s.user_overlay_index; + const uint64_t first = overlay.location / sector_size; + const uint64_t last = + (overlay.location + overlay.payload.size() - 1) / sector_size; + for (uint64_t lba = first; lba <= last; ++lba) + index[(uint32_t)lba].push_back(i); + } +} + void set_error(const std::string& error) { state().error = error; } @@ -158,7 +197,7 @@ std::filesystem::path raw_image_path(const std::filesystem::path& path, if (extension != ".cue") return path; std::ifstream cue(path); if (!cue) { - if (error) *error = "cannot open CUE for derived disc: " + path.string(); + if (error) *error = "cannot open disc CUE: " + path.string(); return {}; } std::string line; @@ -182,10 +221,67 @@ std::filesystem::path raw_image_path(const std::filesystem::path& path, } return (path.parent_path() / name).lexically_normal(); } - if (error) *error = "CUE has no source file for derived disc: " + path.string(); + if (error) *error = "disc CUE has no source file: " + path.string(); return {}; } +bool sha256_disc_range(const std::filesystem::path& image, + ModPatchTarget target, uint64_t location, size_t size, + std::string& out, std::string* error) { + const std::filesystem::path source = raw_image_path(image, error); + if (source.empty()) return false; + std::ifstream file(source, std::ios::binary); + if (!file) { + if (error) *error = "cannot open stock image range: " + source.string(); + return false; + } + file.seekg(0, std::ios::end); + const std::streamoff file_size = file.tellg(); + if (file_size < 0) { + if (error) *error = "cannot size stock image: " + source.string(); + return false; + } + psx_sha256_ctx hash; + psx_sha256_init(&hash); + std::array bytes{}; + size_t remaining = size; + uint64_t at = location; + const bool raw_source = file_size > 0 && + ((uint64_t)file_size % 2352u) == 0; + while (remaining != 0) { + uint64_t physical = at; + size_t chunk = remaining; + if (target == ModPatchTarget::DiscUser && raw_source) { + const uint64_t lba = at / 2048u; + const size_t within = (size_t)(at % 2048u); + physical = lba * 2352u + 24u + within; + chunk = std::min(chunk, 2048u - within); + } + chunk = std::min(chunk, bytes.size()); + if (physical > (uint64_t)file_size || + chunk > (uint64_t)file_size - physical) { + if (error) *error = "overlay expected range exceeds stock image"; + return false; + } + file.clear(); + file.seekg((std::streamoff)physical); + if (!file.read((char*)bytes.data(), (std::streamsize)chunk)) { + if (error) *error = "cannot read stock image overlay range"; + return false; + } + psx_sha256_update(&hash, bytes.data(), chunk); + at += chunk; + remaining -= chunk; + } + uint8_t digest[32]; + psx_sha256_final(&hash, digest); + std::ostringstream text; + for (uint8_t byte : digest) + text << std::hex << std::setw(2) << std::setfill('0') << (unsigned)byte; + out = text.str(); + return true; +} + #if defined(_WIN32) std::wstring quote_windows_argument(const std::wstring& value) { if (value.find_first_of(L" \t\n\v\"") == std::wstring::npos) return value; @@ -382,9 +478,7 @@ int provider_package_get(void*, int index, RecompLauncherCModPackage* out) { copy_text(out->author, sizeof(out->author), package->author); copy_text(out->description, sizeof(out->description), package->description); copy_text(out->license, sizeof(out->license), package->license); - const auto selection = state().manager.selections().find(package->id); - out->enabled = selection != state().manager.selections().end() && - selection->second.enabled; + out->enabled = package_has_enabled_feature(*package); out->option_count = (int)package->options.size(); out->removable = !out->enabled; return 1; @@ -427,6 +521,201 @@ int provider_choice_get(void*, const char* package_id, const char* option_id, return 1; } +template +int mutate(Callback callback); + +bool provider_feature_at(int index, const ModPackage*& package, + const ModFeature*& feature) { + if (index < 0) return false; + for (const auto& [package_id, versions] : state().manager.packages()) { + (void)versions; + const ModPackage* selected = selected_package(package_id); + if (!selected) continue; + for (const ModFeature& candidate : selected->features) { + if (index-- == 0) { + package = selected; + feature = &candidate; + return true; + } + } + } + return false; +} + +std::vector provider_feature_options( + const ModPackage& package, const std::string& feature_id) { + std::vector out; + for (const ModOption& option : package.options) + if (option.feature_id == feature_id) out.push_back(&option); + return out; +} + +bool diagnostic_matches(const ModResolution::Diagnostic& diagnostic, + const std::string& package_id, + const std::string& feature_id) { + return (diagnostic.package_id == package_id && + diagnostic.feature_id == feature_id) || + (diagnostic.other_package_id == package_id && + diagnostic.other_feature_id == feature_id); +} + +int provider_feature_count(void*) { + int count = 0; + for (const auto& [package_id, versions] : state().manager.packages()) { + (void)versions; + const ModPackage* package = selected_package(package_id); + if (package) count += (int)package->features.size(); + } + return count; +} + +int provider_feature_get(void*, int index, RecompLauncherCModFeature* out) { + if (!out) return 0; + const ModPackage* package = nullptr; + const ModFeature* feature = nullptr; + if (!provider_feature_at(index, package, feature)) return 0; + std::memset(out, 0, sizeof(*out)); + copy_text(out->id, sizeof(out->id), feature->id); + copy_text(out->package_id, sizeof(out->package_id), package->id); + copy_text(out->package_version, sizeof(out->package_version), package->version); + copy_text(out->package_name, sizeof(out->package_name), package->name); + copy_text(out->name, sizeof(out->name), feature->name); + copy_text(out->author, sizeof(out->author), package->author); + copy_text(out->description, sizeof(out->description), feature->description); + copy_text(out->group, sizeof(out->group), feature->group); + out->enabled = + state().manager.feature_enabled(package->id, feature->id) ? 1 : 0; + out->option_count = + (int)provider_feature_options(*package, feature->id).size(); + for (const ModResolution::Diagnostic& diagnostic : + state().validation.diagnostics) { + if (!diagnostic_matches(diagnostic, package->id, feature->id)) continue; + out->has_error = 1; + copy_text(out->status, sizeof(out->status), diagnostic.message); + break; + } + return 1; +} + +int provider_feature_option_get(void*, const char* package_id, + const char* feature_id, int index, + RecompLauncherCModOption* out) { + if (!package_id || !feature_id || !out || index < 0) return 0; + const ModPackage* package = selected_package(package_id); + if (!package) return 0; + const auto options = provider_feature_options(*package, feature_id); + if ((size_t)index >= options.size()) return 0; + const ModOption& option = *options[(size_t)index]; + std::memset(out, 0, sizeof(*out)); + copy_text(out->id, sizeof(out->id), option.id); + copy_text(out->label, sizeof(out->label), option.label); + copy_text(out->description, sizeof(out->description), option.description); + copy_text(out->group, sizeof(out->group), option.group); + copy_text(out->value, sizeof(out->value), + state().manager.feature_option_value( + package_id, feature_id, option.id)); + copy_text(out->default_value, sizeof(out->default_value), + option.default_value); + out->type = option.type == ModOptionType::Boolean + ? RECOMP_MOD_OPTION_BOOLEAN + : option.type == ModOptionType::Choice + ? RECOMP_MOD_OPTION_CHOICE : RECOMP_MOD_OPTION_INTEGER; + out->min_value = option.min_value; + out->max_value = option.max_value; + out->step = option.step; + out->choice_count = (int)option.choices.size(); + return 1; +} + +int provider_feature_choice_get(void*, const char* package_id, + const char* feature_id, + const char* option_id, int index, + RecompLauncherCModChoice* out) { + if (!package_id || !feature_id || !option_id || !out || index < 0) + return 0; + const ModPackage* package = selected_package(package_id); + if (!package) return 0; + const auto option = std::find_if( + package->options.begin(), package->options.end(), + [&](const ModOption& value) { + return value.feature_id == feature_id && value.id == option_id; + }); + if (option == package->options.end() || + (size_t)index >= option->choices.size()) return 0; + std::memset(out, 0, sizeof(*out)); + copy_text(out->value, sizeof(out->value), + option->choices[(size_t)index].value); + copy_text(out->label, sizeof(out->label), + option->choices[(size_t)index].label); + return 1; +} + +int provider_feature_enable(void*, const char* package_id, + const char* feature_id, int enabled) { + if (!package_id || !feature_id) return 0; + return mutate([&](std::string& error) { + const ModFeature* feature = + state().manager.selected_feature(package_id, feature_id); + if (feature && feature->legacy) + return state().manager.set_enabled( + package_id, enabled != 0, &error); + return state().manager.set_feature_enabled( + package_id, feature_id, enabled != 0, &error); + }); +} + +int provider_feature_set_option(void*, const char* package_id, + const char* feature_id, + const char* option_id, + const char* value) { + if (!package_id || !feature_id || !option_id || !value) return 0; + return mutate([&](std::string& error) { + const ModFeature* feature = + state().manager.selected_feature(package_id, feature_id); + if (feature && feature->legacy) + return state().manager.set_option( + package_id, option_id, value, &error); + return state().manager.set_feature_option( + package_id, feature_id, option_id, value, &error); + }); +} + +int provider_diagnostic_count(void*, const char* package_id, + const char* feature_id) { + if (!package_id || !feature_id) return 0; + return (int)std::count_if( + state().validation.diagnostics.begin(), + state().validation.diagnostics.end(), + [&](const ModResolution::Diagnostic& diagnostic) { + return diagnostic_matches(diagnostic, package_id, feature_id); + }); +} + +int provider_diagnostic_get(void*, const char* package_id, + const char* feature_id, int index, + RecompLauncherCModDiagnostic* out) { + if (!package_id || !feature_id || !out || index < 0) return 0; + for (const ModResolution::Diagnostic& diagnostic : + state().validation.diagnostics) { + if (!diagnostic_matches(diagnostic, package_id, feature_id)) continue; + if (index-- != 0) continue; + std::memset(out, 0, sizeof(*out)); + out->severity = 2; + copy_text(out->resource, sizeof(out->resource), diagnostic.resource); + copy_text(out->message, sizeof(out->message), diagnostic.message); + const bool primary = diagnostic.package_id == package_id && + diagnostic.feature_id == feature_id; + copy_text(out->related_package_id, sizeof(out->related_package_id), + primary ? diagnostic.other_package_id : + diagnostic.package_id); + copy_text(out->related_feature_id, sizeof(out->related_feature_id), + primary ? diagnostic.other_feature_id : + diagnostic.feature_id); + return 1; + } + return 0; +} + int provider_version_count(void*, const char* package_id) { if (!package_id) return 0; const auto package = state().manager.packages().find(package_id); @@ -445,9 +734,8 @@ int provider_version_get(void*, const char* package_id, int index, copy_text(out->version, sizeof(out->version), version->first); const ModPackage* selected = selected_package(package_id); out->selected = selected && selected->version == version->first; - const auto selection = state().manager.selections().find(package_id); - out->removable = selection == state().manager.selections().end() || - !selection->second.enabled || !out->selected; + out->removable = !out->selected || + !selected || !package_has_enabled_feature(*selected); return 1; } @@ -458,6 +746,11 @@ int mutate(Callback callback) { set_error(error); return 0; } + if (!state().disc_path.empty()) + state().validation = state().manager.resolve( + state().game_id, state().exe_sha256, state().disc_sha256); + else + state().validation = {}; state().error.clear(); return 1; } @@ -530,6 +823,14 @@ RecompLauncherCModProvider provider = { provider_set_option, provider_commit, provider_error, + provider_feature_count, + provider_feature_get, + provider_feature_option_get, + provider_feature_choice_get, + provider_feature_enable, + provider_feature_set_option, + provider_diagnostic_count, + provider_diagnostic_get, }; #endif @@ -543,6 +844,11 @@ bool mod_runtime_initialize(const std::filesystem::path& root, RuntimeMods& s = state(); s.manager.set_root({}); s.plan = {}; + s.validation = {}; + s.raw_disc_index.clear(); + s.user_disc_index.clear(); + s.raw_overlay_index.clear(); + s.user_overlay_index.clear(); s.game_id.clear(); s.error.clear(); s.exe_sha256.clear(); @@ -583,6 +889,7 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err } ModResolution plan = s.manager.resolve(s.game_id, s.exe_sha256, s.disc_sha256); + s.validation = plan; if (!plan.ok) { s.error.clear(); for (const std::string& item : plan.errors) { @@ -592,6 +899,20 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err if (error) *error = s.error; return false; } + for (const ModResolution::Overlay& overlay : plan.overlays) { + if (overlay.expected_sha256.empty()) continue; + std::string actual; + if (!sha256_disc_range( + s.disc_path, overlay.target, overlay.location, + overlay.payload.size(), actual, &s.error) || + actual != overlay.expected_sha256) { + if (s.error.empty()) + s.error = overlay.package_id + "/" + overlay.feature_id + + ": stock overlay range checksum failed"; + if (error) *error = s.error; + return false; + } + } std::filesystem::path effective_disc; if (!materialize_derived_disc(s, plan, effective_disc, &s.error)) { if (error) *error = s.error; @@ -602,6 +923,7 @@ bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* err return false; } s.plan = std::move(plan); + build_disc_index(s); s.effective_disc_path = std::move(effective_disc); s.main_applied = false; s.error.clear(); @@ -670,29 +992,68 @@ extern "C" void mod_runtime_patch_disc_sector(uint32_t lba, int raw_sector, RuntimeMods& s = state(); if (!s.initialized || !s.disc_enabled || s.disc_guard_failed || !bytes || size == 0) return; + /* Raw Mode2 Form1 reads are also the source of the 2048-byte logical + * stream consumed by the emulated CD controller. Apply raw claims to the + * complete sector, then user-data claims to its payload window. Form2/XA + * and CDDA sectors deliberately do not receive disc_user overlays. */ + const bool has_mode2_form1_user_data = + raw_sector && size >= 2072 && bytes[15] == 2 && + (bytes[18] & 0x20u) == 0; const ModPatchTarget target = raw_sector ? ModPatchTarget::DiscRaw : ModPatchTarget::DiscUser; const uint64_t base = (uint64_t)lba * size; const uint64_t end = base + size; - for (const ModResolution::Write& write : s.plan.writes) { - if (write.target != target || write.location < base || - write.location + write.replacement.size() > end) continue; - const size_t offset = (size_t)(write.location - base); - if (std::memcmp(bytes + offset, write.expected.data(), - write.expected.size()) != 0) { - std::fprintf(stderr, - "psxrecomp: disc mod plan %s rejected at LBA %u+%zu " - "(expected-byte guard failed; disc overlay disabled)\n", - s.plan.fingerprint.c_str(), lba, offset); - s.disc_guard_failed = true; - return; + const auto& index = raw_sector ? s.raw_disc_index : s.user_disc_index; + const auto sector = index.find(lba); + const auto& overlay_index = + raw_sector ? s.raw_overlay_index : s.user_overlay_index; + const auto overlay_sector = overlay_index.find(lba); + if (sector == index.end() && overlay_sector == overlay_index.end()) { + if (has_mode2_form1_user_data) + mod_runtime_patch_disc_sector(lba, 0, bytes + 24, 2048); + return; + } + if (sector != index.end()) { + for (size_t write_index : sector->second) { + const ModResolution::Write& write = s.plan.writes[write_index]; + if (write.target != target || write.location < base || + write.location + write.replacement.size() > end) continue; + const size_t offset = (size_t)(write.location - base); + if (std::memcmp(bytes + offset, write.expected.data(), + write.expected.size()) != 0) { + std::fprintf(stderr, + "psxrecomp: disc mod plan %s rejected at LBA %u+%zu " + "(expected-byte guard failed; disc overlay disabled)\n", + s.plan.fingerprint.c_str(), lba, offset); + s.disc_guard_failed = true; + return; + } + } + for (size_t write_index : sector->second) { + const ModResolution::Write& write = s.plan.writes[write_index]; + if (write.target != target || write.location < base || + write.location + write.replacement.size() > end) continue; + const size_t offset = (size_t)(write.location - base); + std::memcpy(bytes + offset, write.replacement.data(), + write.replacement.size()); } } - for (const ModResolution::Write& write : s.plan.writes) { - if (write.target != target || write.location < base || - write.location + write.replacement.size() > end) continue; - const size_t offset = (size_t)(write.location - base); - std::memcpy(bytes + offset, write.replacement.data(), - write.replacement.size()); + if (overlay_sector != overlay_index.end()) { + for (size_t overlay_index_value : overlay_sector->second) { + const ModResolution::Overlay& overlay = + s.plan.overlays[overlay_index_value]; + const uint64_t overlay_end = + overlay.location + overlay.payload.size(); + const uint64_t copy_begin = std::max(base, overlay.location); + const uint64_t copy_end = std::min(end, overlay_end); + if (copy_begin >= copy_end) continue; + const size_t destination = (size_t)(copy_begin - base); + const size_t source = (size_t)(copy_begin - overlay.location); + const size_t count = (size_t)(copy_end - copy_begin); + std::memcpy(bytes + destination, + overlay.payload.data() + source, count); + } } + if (has_mode2_form1_user_data && !s.disc_guard_failed) + mod_runtime_patch_disc_sector(lba, 0, bytes + 24, 2048); } diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp index 19680683..55599a40 100644 --- a/runtime/tests/test_mod_packages.cpp +++ b/runtime/tests/test_mod_packages.cpp @@ -26,6 +26,24 @@ static void write_text(const fs::path& path, const std::string& text) { out << text; } +static void write_bytes(const fs::path& path, const std::vector& bytes) { + fs::create_directories(path.parent_path()); + std::ofstream out(path, std::ios::binary); + out.write((const char*)bytes.data(), (std::streamsize)bytes.size()); +} + +static std::string sha256_hex(const std::vector& bytes) { + uint8_t digest[32]; + psx_sha256_compute(bytes.data(), bytes.size(), digest); + static const char hex[] = "0123456789abcdef"; + std::string out(64, '0'); + for (size_t i = 0; i < 32; ++i) { + out[i * 2] = hex[digest[i] >> 4]; + out[i * 2 + 1] = hex[digest[i] & 15]; + } + return out; +} + static void write_deflated_package(const fs::path& path) { static const char* compressed_hex = "4bcb2fca4d2c892f4b2d2acecccf53b05530e4ca4c01524a5599057ab9f929" @@ -240,6 +258,180 @@ int main() { matrix.derived_discs[0].output_size == 222222, "multi-option derived-disc condition must match selected values"); + write_text(root / "packages/features.mod/1.0.0/manifest.toml", + manifest("features.mod", "1.0.0", + "\n[[feature]]\n" + "id = \"title-screen\"\n" + "name = \"Title Screen\"\n" + "group = \"Localization\"\n" + "\n[[feature]]\n" + "id = \"retranslation\"\n" + "name = \"Retranslation\"\n" + "group = \"Localization\"\n" + "\n[[feature]]\n" + "id = \"title-collision\"\n" + "name = \"Title Collision\"\n" + "\n[[feature]]\n" + "id = \"title-identical\"\n" + "name = \"Title Identical\"\n" + "\n[[option]]\n" + "feature = \"title-screen\"\n" + "id = \"variant\"\n" + "label = \"Variant\"\n" + "type = \"choice\"\n" + "default = \"usa\"\n" + "[[option.choice]]\n" + "value = \"usa\"\n" + "label = \"Mega Man X6\"\n" + "[[option.choice]]\n" + "value = \"japan\"\n" + "label = \"Rockman X6\"\n" + "\n[[option]]\n" + "feature = \"retranslation\"\n" + "id = \"variant\"\n" + "label = \"Variant\"\n" + "type = \"boolean\"\n" + "default = \"true\"\n" + "\n[[patch]]\n" + "feature = \"title-screen\"\n" + "target = \"main_exe\"\n" + "address = 2147495936\n" + "expected = \"0102\"\n" + "replace = \"a1a2\"\n" + "when = { variant = \"japan\" }\n" + "\n[[patch]]\n" + "feature = \"retranslation\"\n" + "target = \"disc_raw\"\n" + "offset = 23520\n" + "expected = \"03\"\n" + "replace = \"b3\"\n" + "when = { variant = \"true\" }\n" + "\n[[patch]]\n" + "feature = \"title-collision\"\n" + "target = \"main_exe\"\n" + "address = 2147495937\n" + "expected = \"02\"\n" + "replace = \"ff\"\n" + "\n[[patch]]\n" + "feature = \"title-identical\"\n" + "target = \"main_exe\"\n" + "address = 2147495936\n" + "expected = \"0102\"\n" + "replace = \"a1a2\"\n")); + check(reload.scan(&error), error.c_str()); + check(!reload.set_enabled("features.mod", true, &error), + "feature-style package must not expose package enablement"); + check(reload.set_feature_option( + "features.mod", "title-screen", "variant", "japan", &error), + error.c_str()); + check(reload.set_feature_enabled( + "features.mod", "title-screen", true, &error), error.c_str()); + check(reload.set_feature_enabled( + "features.mod", "retranslation", true, &error), error.c_str()); + ModResolution features = reload.resolve("SLUS-TEST"); + check(features.ok && features.writes.size() == 2, + "independently enabled features must compose their operations"); + check(features.ok && features.writes[0].feature_id == "title-screen" && + features.writes[1].feature_id == "retranslation", + "resolved writes must retain feature ownership"); + check(reload.set_feature_enabled( + "features.mod", "title-collision", true, &error), error.c_str()); + ModResolution collision = reload.resolve("SLUS-TEST"); + check(!collision.ok && collision.diagnostics.size() == 1, + "overlapping feature writes must produce a structured diagnostic"); + check(!collision.diagnostics.empty() && + collision.diagnostics[0].feature_id == "title-collision" && + collision.diagnostics[0].other_feature_id == "title-screen" && + !collision.diagnostics[0].resource.empty(), + "collision diagnostic must identify both features and the resource"); + check(reload.set_feature_enabled( + "features.mod", "title-collision", false, &error), error.c_str()); + check(reload.set_feature_enabled( + "features.mod", "title-identical", true, &error), error.c_str()); + ModResolution identical = reload.resolve("SLUS-TEST"); + check(identical.ok && identical.writes.size() == 2, + "truly identical writes must coalesce deterministically"); + check(reload.save_state(&error), error.c_str()); + ModPackageManager feature_reload(root); + check(feature_reload.scan(&error), error.c_str()); + check(feature_reload.load_state(&error), error.c_str()); + check(feature_reload.feature_enabled("features.mod", "title-screen") && + feature_reload.feature_enabled("features.mod", "retranslation") && + !feature_reload.feature_enabled("features.mod", "title-collision"), + "per-feature enabled state must survive save/reload"); + check(feature_reload.feature_option_value( + "features.mod", "title-screen", "variant") == "japan", + "feature-scoped option values must survive save/reload"); + check(feature_reload.resolve("SLUS-TEST").fingerprint == + identical.fingerprint, + "feature state must resolve deterministically after reload"); + + const std::vector overlay_a = {1, 2, 3, 4}; + const std::vector overlay_b = {8, 9}; + const std::string overlay_disc_hash(64, '4'); + write_bytes(root / "packages/overlay.mod/1.0.0/assets/a.bin", overlay_a); + write_bytes(root / "packages/overlay.mod/1.0.0/assets/b.bin", overlay_b); + write_text(root / "packages/overlay.mod/1.0.0/manifest.toml", + manifest("overlay.mod", "1.0.0", + "disc_sha256 = \"" + overlay_disc_hash + "\"\n" + "[[feature]]\n" + "id = \"asset-a\"\n" + "name = \"Asset A\"\n" + "[[feature]]\n" + "id = \"asset-b\"\n" + "name = \"Asset B\"\n" + "[[overlay]]\n" + "feature = \"asset-a\"\n" + "target = \"disc_raw\"\n" + "offset = 100\n" + "file = \"assets/a.bin\"\n" + "sha256 = \"" + sha256_hex(overlay_a) + "\"\n" + "[[overlay]]\n" + "feature = \"asset-b\"\n" + "target = \"disc_raw\"\n" + "offset = 102\n" + "file = \"assets/b.bin\"\n" + "sha256 = \"" + sha256_hex(overlay_b) + "\"\n")); + check(feature_reload.scan(&error), error.c_str()); + const ModPackage* overlay_package = + feature_reload.selected_package("overlay.mod"); + check(overlay_package && overlay_package->overlays.size() == 2 && + overlay_package->overlays[0].size == overlay_a.size(), + "manifest scan must verify and retain overlay metadata"); + check(feature_reload.set_feature_enabled( + "overlay.mod", "asset-a", true, &error), error.c_str()); + check(feature_reload.set_feature_enabled( + "overlay.mod", "asset-b", true, &error), error.c_str()); + ModResolution overlay_collision = + feature_reload.resolve("SLUS-TEST", {}, overlay_disc_hash); + check(!overlay_collision.ok && + overlay_collision.diagnostics.size() == 1 && + overlay_collision.diagnostics[0].feature_id == "asset-b" && + overlay_collision.diagnostics[0].other_feature_id == "asset-a", + "overlapping file overlays must identify both owning features"); + check(feature_reload.set_feature_enabled( + "overlay.mod", "asset-b", false, &error), error.c_str()); + ModResolution one_overlay = + feature_reload.resolve("SLUS-TEST", {}, overlay_disc_hash); + check(one_overlay.ok && one_overlay.overlays.size() == 1 && + one_overlay.overlays[0].payload == overlay_a, + "only enabled overlay payloads must enter the resolved plan"); + + write_text(root / "feature-derived.toml", + manifest("bad.derived", "1.0.0", + "\n[[feature]]\n" + "id = \"bad\"\n" + "name = \"Bad\"\n" + "[[derived_disc]]\n" + "patch = \"bad.xdelta3\"\n" + "patch_sha256 = \"0000000000000000000000000000000000000000000000000000000000000000\"\n" + "output_size = 1\n" + "output_sha256 = \"1111111111111111111111111111111111111111111111111111111111111111\"\n")); + ModPackage feature_derived; + check(!ModPackageManager::read_manifest( + root / "feature-derived.toml", feature_derived, &error), + "feature-style packages must reject derived-disc operations"); + ModPackage invalid; write_text(root / "bad.toml", "format_version=1\nid=\"../bad\"\nversion=\"1.0.0\"\nname=\"Bad\"\n" diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index e8bacfc8..5ae841b9 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -1,4 +1,5 @@ #include "mod_runtime.h" +#include "psx_sha256.h" #include #include @@ -6,6 +7,7 @@ #include #include #include +#include namespace fs = std::filesystem; @@ -35,10 +37,36 @@ static void write_text(const fs::path& path, const std::string& text) { out << text; } +static void write_bytes(const fs::path& path, const std::vector& bytes) { + fs::create_directories(path.parent_path()); + std::ofstream out(path, std::ios::binary); + out.write((const char*)bytes.data(), (std::streamsize)bytes.size()); +} + +static std::string sha256_hex(const std::vector& bytes) { + uint8_t digest[32]; + psx_sha256_compute(bytes.data(), bytes.size(), digest); + static const char hex[] = "0123456789abcdef"; + std::string out(64, '0'); + for (size_t i = 0; i < 32; ++i) { + out[i * 2] = hex[digest[i] >> 4]; + out[i * 2 + 1] = hex[digest[i] & 15]; + } + return out; +} + int main() { const fs::path root = fs::temp_directory_path() / "psxrecomp-mod-runtime-test"; std::error_code ec; fs::remove_all(root, ec); + const std::vector stock(8 * 2352, 0); + std::vector overlay(3000); + for (size_t i = 0; i < overlay.size(); ++i) + overlay[i] = (uint8_t)(i * 17u + 3u); + const fs::path stock_path = root / "stock.bin"; + write_bytes(stock_path, stock); + write_bytes(root / "packages/runtime.test/1.0.0/assets/overlay.bin", + overlay); write_text(root / "packages/runtime.test/1.0.0/manifest.toml", "format_version = 1\n" "id = \"runtime.test\"\n" @@ -46,28 +74,72 @@ int main() { "name = \"Runtime Test\"\n" "[[target]]\n" "game_id = \"SLUS-RUNTIME\"\n" + "disc_sha256 = \"" + sha256_hex(stock) + "\"\n" + "[[feature]]\n" + "id = \"main-code\"\n" + "name = \"Main Code\"\n" + "[[feature]]\n" + "id = \"disc-byte\"\n" + "name = \"Disc Byte\"\n" + "[[feature]]\n" + "id = \"asset-overlay\"\n" + "name = \"Asset Overlay\"\n" + "[[feature]]\n" + "id = \"user-byte\"\n" + "name = \"User Byte\"\n" "[[patch]]\n" + "feature = \"main-code\"\n" "target = \"main_exe\"\n" "address = 2147487744\n" "expected = \"01020304\"\n" "replace = \"a1a2a3a4\"\n" "[[patch]]\n" + "feature = \"disc-byte\"\n" "target = \"disc_raw\"\n" "offset = 4714\n" "expected = \"aa\"\n" - "replace = \"bb\"\n"); + "replace = \"bb\"\n" + "[[patch]]\n" + "feature = \"user-byte\"\n" + "target = \"disc_user\"\n" + "offset = 6154\n" + "expected = \"cc\"\n" + "replace = \"dd\"\n" + "[[overlay]]\n" + "feature = \"asset-overlay\"\n" + "target = \"disc_raw\"\n" + "offset = 11408\n" + "file = \"assets/overlay.bin\"\n" + "sha256 = \"" + sha256_hex(overlay) + "\"\n" + "expected_sha256 = \"" + + sha256_hex(std::vector(overlay.size(), 0)) + "\"\n"); write_text(root / "state.toml", - "format_version = 1\n" + "format_version = 2\n" "[[package]]\n" "id = \"runtime.test\"\n" + "version = \"1.0.0\"\n" + "[[feature]]\n" + "package_id = \"runtime.test\"\n" + "id = \"main-code\"\n" "enabled = true\n" - "version = \"1.0.0\"\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"); std::string error; check(PSXRecompV4::mod_runtime_initialize( root, "SLUS-RUNTIME", 0x80002000, {}, &error), error.c_str()); - check(PSXRecompV4::mod_runtime_commit({}, &error), error.c_str()); + check(PSXRecompV4::mod_runtime_commit(stock_path, &error), error.c_str()); ram[0x1000] = 1; ram[0x1001] = 2; ram[0x1002] = 3; ram[0x1003] = 4; mod_runtime_on_dispatch(0x80001000); @@ -84,6 +156,42 @@ int main() { mod_runtime_patch_disc_sector(2, 1, sector.data(), (uint32_t)sector.size()); check(sector[10] == 0xbb, "raw disc overlay must patch matching sectors"); + std::array overlay_sector{}; + mod_runtime_patch_disc_sector( + 4, 1, overlay_sector.data(), (uint32_t)overlay_sector.size()); + check(overlay_sector[1999] == 0 && + overlay_sector[2000] == overlay[0] && + overlay_sector[2351] == overlay[351], + "file overlay must patch the tail of its first sector"); + overlay_sector.fill(0); + mod_runtime_patch_disc_sector( + 5, 1, overlay_sector.data(), (uint32_t)overlay_sector.size()); + check(overlay_sector.front() == overlay[352] && + overlay_sector.back() == overlay[2703], + "file overlay must patch complete middle sectors"); + overlay_sector.fill(0); + mod_runtime_patch_disc_sector( + 6, 1, overlay_sector.data(), (uint32_t)overlay_sector.size()); + check(overlay_sector[0] == overlay[2704] && + overlay_sector[295] == overlay[2999] && + overlay_sector[296] == 0, + "file overlay must patch the head of its final sector"); + + std::array mode2_sector{}; + mode2_sector[15] = 2; + mode2_sector[18] = 0; + mode2_sector[24 + 10] = 0xcc; + mod_runtime_patch_disc_sector( + 3, 1, mode2_sector.data(), (uint32_t)mode2_sector.size()); + check(mode2_sector[24 + 10] == 0xdd, + "disc_user operations must apply to raw Mode2 Form1 user data"); + std::array audio_sector{}; + audio_sector[24 + 10] = 0xcc; + mod_runtime_patch_disc_sector( + 3, 1, audio_sector.data(), (uint32_t)audio_sector.size()); + check(audio_sector[24 + 10] == 0xcc, + "disc_user operations must not modify CDDA/non-data sectors"); + fs::remove_all(root, ec); if (failures) return 1; std::cout << "mod runtime tests passed\n";