feat: support multi-option mod conditions

This commit is contained in:
Matthew Stanley
2026-07-23 00:36:18 -07:00
parent 162194dc9b
commit 9ff287c1fa
4 changed files with 112 additions and 37 deletions
+19 -4
View File
@@ -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
+2
View File
@@ -57,6 +57,7 @@ struct ModPatch {
std::vector<uint8_t> replacement;
std::string when_option;
std::string when_value;
std::map<std::string, std::string> 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<std::string, std::string> when;
};
struct ModPackage {
+49 -33
View File
@@ -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<std::string, std::string>& 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<ModOption>& options,
std::map<std::string, std::string>& when,
const char* label) {
const std::string when_option =
value.contains("when_option") ? toml::find<std::string>(value, "when_option") : "";
const std::string when_value =
value.contains("when_value") ? toml::find<std::string>(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<std::map<std::string, std::string>>(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<std::string>(v, "when_option") : "";
patch.when_value =
v.contains("when_value") ? toml::find<std::string>(v, "when_value") : "";
patch.order = toml::find_or<int64_t>(
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<std::string>(v, "patch_sha256");
const int64_t output_size = toml::find<int64_t>(v, "output_size");
derived.output_sha256 = toml::find<std::string>(v, "output_sha256");
derived.when_option =
v.contains("when_option") ? toml::find<std::string>(v, "when_option") : "";
derived.when_value =
v.contains("when_value") ? toml::find<std::string>(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<const ModPatch*> 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);
}
+42
View File
@@ -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"