diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index 8709eaa3..504fe645 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -45,6 +45,7 @@ struct ModFeature { enum class ModConstraintKind { OrderedInteger, + RequiresFeature, }; enum class ModConstraintDirection { @@ -58,6 +59,9 @@ struct ModConstraint { ModConstraintDirection direction = ModConstraintDirection::Nondecreasing; std::vector options; + std::string required_feature_id; + std::string required_option_id; + std::string required_value; }; struct ModRequirement { @@ -235,9 +239,15 @@ struct ModResolution { std::vector errors; }; +struct ModBuiltinResolverContext { + const std::map* active_packages = nullptr; + const std::map* selections = nullptr; +}; + using ModBuiltinResolver = std::function& writes, std::vector& errors)>; diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index a5410c9d..9610a904 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -799,23 +799,107 @@ std::string effective_option_value(const ModPackage& package, return option ? option->default_value : std::string(); } -bool feature_constraints_satisfied( +bool prospective_feature_enabled( const ModPackage& package, const ModSelection& selection, - const std::string& feature_id, const std::string* override_id, + const std::string& feature_id, const std::string* override_feature_id, + const bool* override_enabled) { + if (override_feature_id && override_enabled && + feature_id == *override_feature_id) + return *override_enabled; + const ModFeature* feature = find_feature(package, feature_id); + return feature && is_feature_enabled(package, selection, *feature); +} + +std::string prospective_option_value( + const ModPackage& package, const ModSelection& selection, + const std::string& feature_id, const std::string& option_id, + const std::string* override_feature_id, + const std::string* override_option_id, + const std::string* override_value) { + if (override_feature_id && override_option_id && override_value && + feature_id == *override_feature_id && option_id == *override_option_id) + return *override_value; + return effective_option_value(package, selection, feature_id, option_id); +} + +bool runtime_constraint_satisfied( + const ModPackage& package, const ModSelection& selection, + const ModConstraint& constraint, + const std::string* override_feature_id, const bool* override_enabled, + const std::string* override_option_feature_id, + const std::string* override_option_id, const std::string* override_value, std::string* reason) { - for (const ModConstraint& constraint : package.constraints) { - if (constraint.feature_id != feature_id) continue; - if (!constraint_satisfied( - package, constraint, - [&](const std::string& id) { - return override_id && override_value && - id == *override_id - ? *override_value - : effective_option_value( - package, selection, feature_id, id); - }, - reason)) + if (!prospective_feature_enabled( + package, selection, constraint.feature_id, override_feature_id, + override_enabled)) + return true; + if (constraint.kind == ModConstraintKind::OrderedInteger) { + return constraint_satisfied( + package, constraint, + [&](const std::string& id) { + return prospective_option_value( + package, selection, constraint.feature_id, id, + override_option_feature_id, override_option_id, + override_value); + }, + reason); + } + if (constraint.kind == ModConstraintKind::RequiresFeature) { + const ModFeature* required = + find_feature(package, constraint.required_feature_id); + const std::string required_name = + required && !required->name.empty() + ? required->name + : constraint.required_feature_id; + if (!prospective_feature_enabled( + package, selection, constraint.required_feature_id, + override_feature_id, override_enabled)) { + if (reason) *reason = "requires " + required_name + " to be enabled"; return false; + } + if (!constraint.required_option_id.empty()) { + const std::string actual = prospective_option_value( + package, selection, constraint.required_feature_id, + constraint.required_option_id, override_option_feature_id, + override_option_id, override_value); + if (actual != constraint.required_value) { + const ModOption* option = find_option( + package, constraint.required_feature_id, + constraint.required_option_id); + if (reason) { + *reason = + "requires " + required_name + " " + + (option && !option->label.empty() + ? option->label + : constraint.required_option_id) + + " = " + constraint.required_value; + } + return false; + } + } + return true; + } + if (reason) *reason = "unsupported constraint kind"; + return false; +} + +bool package_constraints_satisfied( + const ModPackage& package, const ModSelection& selection, + const std::string* override_feature_id, const bool* override_enabled, + const std::string* override_option_feature_id, + const std::string* override_option_id, + const std::string* override_value, + std::string* failing_feature_id, std::string* reason) { + for (const ModConstraint& constraint : package.constraints) { + std::string local_reason; + if (!runtime_constraint_satisfied( + package, selection, constraint, override_feature_id, + override_enabled, override_option_feature_id, + override_option_id, override_value, &local_reason)) { + if (failing_feature_id) *failing_feature_id = constraint.feature_id; + if (reason) *reason = local_reason; + return false; + } } return true; } @@ -1365,53 +1449,85 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, toml::find(v, "feature"); const std::string kind = toml::find(v, "kind"); - const std::string direction = - toml::find(v, "direction"); - constraint.options = - toml::find>(v, "options"); if (!find_feature(out, constraint.feature_id)) throw std::runtime_error( "constraint references unknown feature"); - if (kind != "ordered_integer") - throw std::runtime_error( - "constraint kind must be ordered_integer"); - if (direction == "nondecreasing") { - constraint.direction = - ModConstraintDirection::Nondecreasing; - } else if (direction == "nonincreasing") { - constraint.direction = - ModConstraintDirection::Nonincreasing; + if (kind == "ordered_integer") { + const std::string direction = + toml::find(v, "direction"); + constraint.options = + toml::find>(v, "options"); + if (direction == "nondecreasing") { + constraint.direction = + ModConstraintDirection::Nondecreasing; + } else if (direction == "nonincreasing") { + constraint.direction = + ModConstraintDirection::Nonincreasing; + } else { + throw std::runtime_error( + "ordered_integer direction must be " + "nondecreasing or nonincreasing"); + } + if (constraint.options.size() < 2) + throw std::runtime_error( + "ordered_integer requires at least two options"); + std::set option_ids; + for (const std::string& id : constraint.options) { + const ModOption* option = + find_option(out, constraint.feature_id, id); + if (!option || option->type != ModOptionType::Integer) + throw std::runtime_error( + "ordered_integer must reference same-feature " + "integer options"); + if (!option_ids.insert(id).second) + throw std::runtime_error( + "ordered_integer contains a duplicate option"); + } + std::string reason; + if (!constraint_satisfied( + out, constraint, + [&](const std::string& id) { + return find_option( + out, constraint.feature_id, id) + ->default_value; + }, + &reason)) + throw std::runtime_error( + "constraint defaults are invalid: " + reason); + } else if (kind == "requires_feature") { + if (out.format_version < 4) + throw std::runtime_error( + "requires_feature constraints require format_version 4"); + constraint.kind = ModConstraintKind::RequiresFeature; + constraint.required_feature_id = + toml::find(v, "requires_feature"); + if (!find_feature(out, constraint.required_feature_id)) + throw std::runtime_error( + "requires_feature references unknown feature"); + constraint.required_option_id = + toml::find_or(v, "requires_option", ""); + constraint.required_value = + toml::find_or(v, "requires_value", ""); + if (constraint.required_option_id.empty() != + constraint.required_value.empty()) + throw std::runtime_error( + "requires_feature option constraint requires both " + "requires_option and requires_value"); + if (!constraint.required_option_id.empty()) { + const ModOption* option = find_option( + out, constraint.required_feature_id, + constraint.required_option_id); + if (!option) + throw std::runtime_error( + "requires_feature references unknown option"); + if (!valid_option_value( + *option, constraint.required_value)) + throw std::runtime_error( + "requires_feature references invalid option value"); + } } else { - throw std::runtime_error( - "ordered_integer direction must be " - "nondecreasing or nonincreasing"); + throw std::runtime_error("unknown constraint kind"); } - if (constraint.options.size() < 2) - throw std::runtime_error( - "ordered_integer requires at least two options"); - std::set option_ids; - for (const std::string& id : constraint.options) { - const ModOption* option = - find_option(out, constraint.feature_id, id); - if (!option || option->type != ModOptionType::Integer) - throw std::runtime_error( - "ordered_integer must reference same-feature " - "integer options"); - if (!option_ids.insert(id).second) - throw std::runtime_error( - "ordered_integer contains a duplicate option"); - } - std::string reason; - if (!constraint_satisfied( - out, constraint, - [&](const std::string& id) { - return find_option( - out, constraint.feature_id, id) - ->default_value; - }, - &reason)) - throw std::runtime_error( - "constraint defaults are invalid: " + reason); out.constraints.push_back(std::move(constraint)); } } @@ -2227,14 +2343,15 @@ bool ModPackageManager::set_feature_enabled(const std::string& package_id, return false; } ModSelection& package_selection = selections_[package_id]; - if (enabled) { - std::string reason; - if (!feature_constraints_satisfied( - *package, package_selection, feature_id, - nullptr, nullptr, &reason)) { - set_error(error, package_id + "/" + feature_id + ": " + reason); - return false; - } + std::string failing_feature; + std::string reason; + if (!package_constraints_satisfied( + *package, package_selection, &feature_id, &enabled, + nullptr, nullptr, nullptr, &failing_feature, &reason)) { + set_error(error, package_id + "/" + + (failing_feature.empty() ? feature_id : failing_feature) + + ": " + reason); + return false; } ModFeatureSelection& selection = package_selection.features[feature_id]; selection.enabled = enabled; @@ -2261,14 +2378,15 @@ bool ModPackageManager::set_feature_option(const std::string& package_id, return false; } ModSelection& package_selection = selections_[package_id]; - if (is_feature_enabled(*package, package_selection, *feature)) { - std::string reason; - if (!feature_constraints_satisfied( - *package, package_selection, feature_id, - &option_id, &value, &reason)) { - set_error(error, package_id + "/" + feature_id + ": " + reason); - return false; - } + std::string failing_feature; + std::string reason; + if (!package_constraints_satisfied( + *package, package_selection, nullptr, nullptr, + &feature_id, &option_id, &value, &failing_feature, &reason)) { + set_error(error, package_id + "/" + + (failing_feature.empty() ? feature_id : failing_feature) + + ": " + reason); + return false; } package_selection.features[feature_id].values[option_id] = value; return true; @@ -2355,24 +2473,13 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, selection == selections_.end() ? blank_selection : selection->second; - for (const ModConstraint& constraint : package->constraints) { - const ModFeature* feature = - find_feature(*package, constraint.feature_id); - if (!feature || - !is_feature_enabled( - *package, effective_selection, *feature)) - continue; - std::string reason; - if (!constraint_satisfied( - *package, constraint, - [&](const std::string& option_id) { - return effective_option_value( - *package, effective_selection, - constraint.feature_id, option_id); - }, - &reason)) - result.errors.push_back( - id + "/" + constraint.feature_id + ": " + reason); + std::string failing_feature; + std::string reason; + if (!package_constraints_satisfied( + *package, effective_selection, nullptr, nullptr, + nullptr, nullptr, nullptr, &failing_feature, &reason)) { + result.errors.push_back( + id + "/" + failing_feature + ": " + reason); } if (selection != selections_.end()) { for (const ModOption& option : package->options) { @@ -2432,6 +2539,9 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, return result; } + ModBuiltinResolverContext resolver_context; + resolver_context.active_packages = &active; + resolver_context.selections = &selections_; for (const ModPackage* package : result.ordered) { const auto selected_it = selections_.find(package->id); const ModSelection blank; @@ -2697,7 +2807,9 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, const std::string resolver_id = package->resolver.substr(8); const auto resolver = builtin_resolvers().find(resolver_id); if (resolver != builtin_resolvers().end() && - !resolver->second(*package, selected, result.writes, result.errors) && + !resolver->second( + *package, selected, resolver_context, + result.writes, result.errors) && result.errors.empty()) result.errors.push_back(package->id + ": built-in resolver failed"); } diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp index 25d45c2e..96125ee0 100644 --- a/runtime/tests/test_mod_packages.cpp +++ b/runtime/tests/test_mod_packages.cpp @@ -259,6 +259,47 @@ int main() { matrix.derived_discs[0].output_size == 222222, "multi-option derived-disc condition must match selected values"); + mod_clear_builtin_resolvers_for_tests(); + bool resolver_context_seen = false; + check(mod_register_builtin_resolver( + "context-test", + [&](const ModPackage& package, const ModSelection& selection, + const ModBuiltinResolverContext& context, + std::vector& writes, + std::vector& errors) { + (void)writes; + (void)errors; + resolver_context_seen = + package.id == "context.consumer" && + selection.enabled && + context.active_packages && + context.selections && + context.active_packages->count("context.provider") == 1 && + context.active_packages->count("context.consumer") == 1 && + context.selections->at("context.provider").enabled && + context.selections->at("context.consumer").enabled; + return resolver_context_seen; + }), + "test resolver must register"); + write_text(root / "packages/context.provider/1.0.0/manifest.toml", + manifest("context.provider", "1.0.0")); + write_text(root / "packages/context.consumer/1.0.0/manifest.toml", + "format_version = 1\n" + "id = \"context.consumer\"\n" + "version = \"1.0.0\"\n" + "name = \"context.consumer\"\n" + "resolver = \"builtin:context-test\"\n" + "[[target]]\n" + "game_id = \"SLUS-TEST\"\n"); + check(reload.scan(&error), error.c_str()); + check(reload.set_enabled("matrix.mod", false, &error), error.c_str()); + check(reload.set_enabled("context.provider", true, &error), error.c_str()); + check(reload.set_enabled("context.consumer", true, &error), error.c_str()); + ModResolution context_resolution = reload.resolve("SLUS-TEST"); + check(context_resolution.ok && resolver_context_seen, + "built-in resolver must receive active package selection context"); + mod_clear_builtin_resolvers_for_tests(); + write_text(root / "packages/features.mod/1.0.0/manifest.toml", manifest("features.mod", "1.0.0", "\n[[feature]]\n"