From 9ff287c1fa00354150b799e4abb2d0eabc695d19 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 23 Jul 2026 00:36:18 -0700 Subject: [PATCH] feat: support multi-option mod conditions --- docs/MOD_PACKAGES.md | 23 ++++++-- runtime/include/mod_packages.h | 2 + runtime/src/mod_packages.cpp | 82 +++++++++++++++++------------ runtime/tests/test_mod_packages.cpp | 42 +++++++++++++++ 4 files changed, 112 insertions(+), 37 deletions(-) diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index 095bbc9d..da3bd0dd 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -68,10 +68,22 @@ output_size = 600000000 output_sha256 = "..." when_option = "delay" when_value = "fast" + +# For option matrices, use a condition table. All listed option values must +# match. `when_option` / `when_value` remain supported for single-option cases. +[[derived_disc]] +kind = "vcdiff" +patch = "assets/fast-rockman.xdelta3" +patch_sha256 = "..." +output_size = 600000000 +output_sha256 = "..." +when = { delay = "fast", title_screen = "rockman_japan" } ``` Option types are `boolean`, `choice`, and bounded `integer`. `when_option` / -`when_value` are optional; an unconditional patch omits both. +`when_value` are optional; `when = { option = "value", ... }` can be used when a +patch or derived-disc recipe depends on multiple option values. An unconditional +patch omits both condition forms. ## Patch targets @@ -97,7 +109,9 @@ option combinations. A `derived_disc` is a data-only VCDIFF recipe whose source is the verified stock disc from `[[target]].disc_sha256`. It is intended for mods that relocate files, -grow the ISO, replace large assets, or otherwise change disc geometry. +grow the ISO, replace large assets, or otherwise change disc geometry. A package +may contain a matrix of conditional recipes for its own options, but exactly one +recipe may resolve in the complete mod plan. The launcher continues to display and persist the user's stock BIN/CUE. Before boot, the runtime: @@ -117,8 +131,9 @@ base package can support many small composable add-ons. Release builders stage the trusted decoder with `-DPSXRECOMP_XDELTA3_EXECUTABLE=/path/to/xdelta3`. More than one active -derived-disc provider is rejected; packages should use dependencies and -conflicts to make ownership explicit. +derived-disc recipe is rejected after option resolution; packages should use a +single owner package for structural transforms and dependencies/conflicts for +external ownership. ## Resolution rules diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index 3d5b7077..b9b5facf 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -57,6 +57,7 @@ struct ModPatch { std::vector replacement; std::string when_option; std::string when_value; + std::map when; int64_t order = 0; }; @@ -68,6 +69,7 @@ struct ModDerivedDisc { std::string output_sha256; std::string when_option; std::string when_value; + std::map when; }; struct ModPackage { diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 852e351c..2c86e826 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -605,6 +605,45 @@ std::string effective_option_value(const ModPackage& package, return option == package.options.end() ? std::string() : option->default_value; } +bool conditions_match(const ModPackage& package, const ModSelection& selection, + const std::map& conditions) { + for (const auto& [id, value] : conditions) { + if (effective_option_value(package, selection, id) != value) + return false; + } + return true; +} + +void read_conditions(const toml::value& value, const std::vector& options, + std::map& when, + const char* label) { + const std::string when_option = + value.contains("when_option") ? toml::find(value, "when_option") : ""; + const std::string when_value = + value.contains("when_value") ? toml::find(value, "when_value") : ""; + if (when_option.empty() != when_value.empty()) + throw std::runtime_error( + std::string(label) + " condition requires both when_option and when_value"); + if (!when_option.empty()) when[when_option] = when_value; + if (value.contains("when")) { + const auto table = toml::find>(value, "when"); + for (const auto& [id, condition_value] : table) { + if (when.find(id) != when.end() && when[id] != condition_value) + throw std::runtime_error( + std::string(label) + " has conflicting conditions for " + id); + when[id] = condition_value; + } + } + for (const auto& [id, condition_value] : when) { + (void)condition_value; + const auto option = std::find_if( + options.begin(), options.end(), + [&](const ModOption& item) { return item.id == id; }); + if (option == options.end()) + throw std::runtime_error(std::string(label) + " references unknown option"); + } +} + bool writes_overlap(const ModResolution::Write& a, const ModResolution::Write& b) { if (a.target != b.target) return false; const uint64_t a_end = a.location + a.replacement.size(); @@ -781,21 +820,12 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, patch.location % sector_size + patch.replacement.size() > sector_size) throw std::runtime_error( "disc patch may not cross a sector boundary"); - patch.when_option = - v.contains("when_option") ? toml::find(v, "when_option") : ""; - patch.when_value = - v.contains("when_value") ? toml::find(v, "when_value") : ""; patch.order = toml::find_or( v, "order", (int64_t)declaration_index); - if (patch.when_option.empty() != patch.when_value.empty()) - throw std::runtime_error( - "patch condition requires both when_option and when_value"); - if (!patch.when_option.empty()) { - const auto option = std::find_if( - out.options.begin(), out.options.end(), - [&](const ModOption& item) { return item.id == patch.when_option; }); - if (option == out.options.end()) - throw std::runtime_error("patch references unknown option"); + read_conditions(v, out.options, patch.when, "patch"); + if (!patch.when.empty()) { + patch.when_option = patch.when.begin()->first; + patch.when_value = patch.when.begin()->second; } out.patches.push_back(std::move(patch)); ++declaration_index; @@ -809,10 +839,6 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, derived.patch_sha256 = toml::find(v, "patch_sha256"); const int64_t output_size = toml::find(v, "output_size"); derived.output_sha256 = toml::find(v, "output_sha256"); - derived.when_option = - v.contains("when_option") ? toml::find(v, "when_option") : ""; - derived.when_value = - v.contains("when_value") ? toml::find(v, "when_value") : ""; if (derived.kind != "vcdiff") throw std::runtime_error("derived_disc kind must be vcdiff"); if (!safe_archive_name(relative_patch)) @@ -824,16 +850,10 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, if (output_size <= 0) throw std::runtime_error("derived_disc output_size must be positive"); derived.output_size = (uint64_t)output_size; - if (derived.when_option.empty() != derived.when_value.empty()) - throw std::runtime_error( - "derived_disc condition requires both when_option and when_value"); - if (!derived.when_option.empty()) { - const auto option = std::find_if( - out.options.begin(), out.options.end(), - [&](const ModOption& item) { return item.id == derived.when_option; }); - if (option == out.options.end()) - throw std::runtime_error( - "derived_disc references unknown option"); + read_conditions(v, out.options, derived.when, "derived_disc"); + if (!derived.when.empty()) { + derived.when_option = derived.when.begin()->first; + derived.when_value = derived.when.begin()->second; } derived.patch = out.root / fs::path(relative_patch); if (!fs::is_regular_file(derived.patch)) @@ -1246,9 +1266,7 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, selected_it == selections_.end() ? blank : selected_it->second; if (package->resolver == "declarative") { for (const ModDerivedDisc& derived : package->derived_discs) { - if (!derived.when_option.empty() && - effective_option_value(*package, selected, derived.when_option) != - derived.when_value) + if (!conditions_match(*package, selected, derived.when)) continue; ModResolution::DerivedDisc resolved; resolved.kind = derived.kind; @@ -1262,9 +1280,7 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, std::vector patches; patches.reserve(package->patches.size()); for (const ModPatch& patch : package->patches) { - if (!patch.when_option.empty() && - effective_option_value(*package, selected, patch.when_option) != - patch.when_value) + if (!conditions_match(*package, selected, patch.when)) continue; patches.push_back(&patch); } diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp index aa9ff0d2..4c80a16c 100644 --- a/runtime/tests/test_mod_packages.cpp +++ b/runtime/tests/test_mod_packages.cpp @@ -195,6 +195,48 @@ int main() { reload.selections().at("conflict.b").enabled, "enabling a conflicting package must disable the previous package"); + write_text(root / "packages/matrix.mod/1.0.0/manifest.toml", + manifest("matrix.mod", "1.0.0", + "\n[[option]]\n" + "id = \"title\"\n" + "label = \"Title\"\n" + "type = \"choice\"\n" + "default = \"mega\"\n" + "[[option.choice]]\n" + "value = \"mega\"\n" + "label = \"Mega\"\n" + "[[option.choice]]\n" + "value = \"rockman\"\n" + "label = \"Rockman\"\n" + "\n[[option]]\n" + "id = \"script\"\n" + "label = \"Script\"\n" + "type = \"choice\"\n" + "default = \"original\"\n" + "[[option.choice]]\n" + "value = \"original\"\n" + "label = \"Original\"\n" + "[[option.choice]]\n" + "value = \"retranslation\"\n" + "label = \"Retranslation\"\n" + "\n[[derived_disc]]\n" + "kind = \"vcdiff\"\n" + "patch = \"assets/matrix.xdelta3\"\n" + "patch_sha256 = \"2222222222222222222222222222222222222222222222222222222222222222\"\n" + "output_size = 222222\n" + "output_sha256 = \"3333333333333333333333333333333333333333333333333333333333333333\"\n" + "when = { title = \"rockman\", script = \"retranslation\" }\n")); + write_text(root / "packages/matrix.mod/1.0.0/assets/matrix.xdelta3", "test"); + check(reload.scan(&error), error.c_str()); + check(reload.set_enabled("conflict.b", false, &error), error.c_str()); + check(reload.set_enabled("matrix.mod", true, &error), error.c_str()); + check(reload.set_option("matrix.mod", "title", "rockman", &error), error.c_str()); + check(reload.set_option("matrix.mod", "script", "retranslation", &error), error.c_str()); + ModResolution matrix = reload.resolve("SLUS-TEST"); + check(matrix.ok && matrix.derived_discs.size() == 1 && + matrix.derived_discs[0].output_size == 222222, + "multi-option derived-disc condition must match selected values"); + ModPackage invalid; write_text(root / "bad.toml", "format_version=1\nid=\"../bad\"\nversion=\"1.0.0\"\nname=\"Bad\"\n"