diff --git a/README.md b/README.md index 3eb977ab..18dbd675 100644 --- a/README.md +++ b/README.md @@ -304,7 +304,7 @@ Choose the narrowest mechanism that describes the change: | A player-selectable boolean, choice, or number | Feature-local `[[option]]` plus `when`, `replace_from`, sparse `fields`, or `when_integer` | | Artwork, script, audio, or another large disc asset | Hashed file-backed `[[overlay]]`; do not rebuild the player's stock image | | Host setting or live game behavior | Trusted static `[[plugin]]`, compiled into the game and selected by a stable id | -| OpenGL bezel artwork | A disabled-by-default package using the trusted `psx.bezel` plugin and a package-local `bezel.png` | +| OpenGL bezel artwork | A disabled-by-default package using the trusted `psx.bezel` plugin and a user-selected image resource | | Several features composing one shared table, bitfield, routine, or allocation | Game-owned `resolver = "builtin:"`, only when declarative operations cannot express the composition | Format versions 2–4 add bounded integers, ordered constraints, linked MIPS diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index cd8a59e1..2f9676e5 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -351,6 +351,9 @@ enabled = true [feature.values] variant = "rockman" + +[feature.resources] +artwork = "C:/Users/You/Pictures/example-bezel.png" ``` State format 1 and package-only manifests remain readable as a migration aid. @@ -395,6 +398,28 @@ belong inside one feature as option values. ## Trusted adapters and archive safety +### Owner-selected resources + +Format-5 packages may declare feature-owned resources that the launcher renders +with its native file/folder picker: + +```toml +[[resource]] +feature = "bezel" +id = "artwork" +label = "Bezel image" +description = "Select an image to draw behind the game frame." +format = "file" +file_patterns = "*.png,*.jpg,*.jpeg,*.bmp" +file_description = "Image files" +required = false +``` + +Resources are paths selected by the player and persisted in `mods/state.toml`; +they are not copied into the package. Optional resources with no selected path +are omitted from the committed plan. Required resources reject launch while the +feature is enabled and unset. + `resolver = "builtin:"` selects a resolver statically registered by the game. Format-5 plugin ids likewise select only statically registered implementations. Packages cannot load arbitrary native code or select @@ -408,12 +433,13 @@ publishes the version atomically. ### Presentation bezel packages The framework registers `psx.bezel`, a trusted presentation plugin for OpenGL -margin artwork. A package using this plugin includes a `bezel.png` file beside -its `manifest.toml`; when the feature is enabled, the runtime draws that image -behind the game frame before presenting the normal 4:3 or widescreen content. +margin artwork. The built-in package declares an optional `artwork` image +resource; when the feature is enabled and the player has selected an image, the +runtime draws that image behind the game frame before presenting the normal 4:3 +or widescreen content. The built-in package `psx.presentation.bezel` targets every game but defaults to off, so the default presentation is unchanged: letterbox and pillarbox margins -remain black. Title authors or users can ship alternate `.psxmod` packages with -their own `bezel.png` and the same trusted plugin id. The package supplies data -only; archives still cannot load native code. +remain black. Enabling the feature without choosing artwork is also a no-op. +The package supplies only the declaration and trusted plugin selection; archives +still cannot load native code. diff --git a/docs/config_schema.md b/docs/config_schema.md index 583b9e15..cece6d6c 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -401,9 +401,9 @@ values, and leaves activation to the selected trusted plugin. Bezel artwork is intentionally not a `[video]` key. It is exposed as the disabled-by-default `psx.presentation.bezel` mod package, which draws a -package-local `bezel.png` behind the game image in OpenGL letterbox or -pillarbox margins. With the mod disabled, margins remain the historical black -clear. +user-selected image resource behind the game image in OpenGL letterbox or +pillarbox margins. With the mod disabled, or with no bezel image selected, +margins remain the historical black clear. Reserved future fields: - `default_disc_path` — game runtimes can pre-mount a disc diff --git a/mods/builtin/packages/psx.presentation.bezel/1.0.0/bezel.png b/mods/builtin/packages/psx.presentation.bezel/1.0.0/bezel.png deleted file mode 100644 index b93db0c0..00000000 Binary files a/mods/builtin/packages/psx.presentation.bezel/1.0.0/bezel.png and /dev/null differ diff --git a/mods/builtin/packages/psx.presentation.bezel/1.0.0/manifest.toml b/mods/builtin/packages/psx.presentation.bezel/1.0.0/manifest.toml index fd947b37..b782cac6 100644 --- a/mods/builtin/packages/psx.presentation.bezel/1.0.0/manifest.toml +++ b/mods/builtin/packages/psx.presentation.bezel/1.0.0/manifest.toml @@ -3,7 +3,7 @@ id = "psx.presentation.bezel" version = "1.0.0" name = "Bezel Artwork" author = "psxrecomp" -description = "Draws package-provided artwork behind the game image in OpenGL letterbox or pillarbox margins. Disabled by default; with the feature off, margins remain the normal black clear." +description = "Draws user-selected artwork behind the game image in OpenGL letterbox or pillarbox margins. Disabled by default; with the feature off, margins remain the normal black clear." resolver = "declarative" save_compatibility = "shared" @@ -13,10 +13,20 @@ game_id = "*" [[feature]] id = "bezel" name = "Bezel Artwork" -description = "Disabled by default. Shows this package's bezel.png behind the game image whenever the selected aspect ratio leaves side or top/bottom margins. This is presentation-only and does not change guest rendering." +description = "Disabled by default. Select an image file to draw behind the game image whenever the selected aspect ratio leaves side or top/bottom margins. This is presentation-only and does not change guest rendering." group = "Visual" default_enabled = false +[[resource]] +feature = "bezel" +id = "artwork" +label = "Bezel image" +description = "Select an image to draw behind the game frame in letterbox or pillarbox margins." +format = "file" +file_patterns = "*.png,*.jpg,*.jpeg,*.bmp" +file_description = "Image files" +required = false + [[plugin]] feature = "bezel" id = "psx.bezel" diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 0088aea4..04e4bc9d 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -400,6 +400,9 @@ if(BUILD_TESTING) find_package(Python3 COMPONENTS Interpreter) if(Python3_Interpreter_FOUND) + add_test(NAME mod_bezel_test + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_mod_bezel.py) add_test(NAME rewind_toggle_combo_test COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_rewind_toggle_combo.py) diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index 950d18d2..fedc97b3 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -162,6 +162,17 @@ struct ModPlugin { int64_t order = 0; }; +struct ModResource { + std::string feature_id; + std::string id; + std::string label; + std::string description; + std::string file_patterns; + std::string file_description; + std::string format = "file"; + bool required = false; +}; + struct ModDerivedDisc { std::string kind = "vcdiff"; std::filesystem::path patch; @@ -201,6 +212,7 @@ struct ModPackage { std::vector patches; std::vector overlays; std::vector plugins; + std::vector resources; std::vector derived_discs; }; @@ -208,6 +220,7 @@ struct ModFeatureSelection { bool enabled = false; bool has_enabled = false; std::map values; + std::map resources; }; struct ModSelection { @@ -261,6 +274,13 @@ struct ModResolution { std::string feature_id; }; std::vector plugins; + struct Resource { + std::string package_id; + std::string feature_id; + std::string id; + std::filesystem::path path; + }; + std::vector resources; struct Diagnostic { std::string message; std::string resource; @@ -316,6 +336,11 @@ public: const std::string& option_id, const std::string& value, std::string* error = nullptr); + bool set_feature_resource_path(const std::string& package_id, + const std::string& feature_id, + const std::string& resource_id, + const std::filesystem::path& path, + std::string* error = nullptr); const std::map>& packages() const { return packages_; @@ -329,6 +354,10 @@ public: std::string feature_option_value(const std::string& package_id, const std::string& feature_id, const std::string& option_id) const; + std::filesystem::path feature_resource_path( + const std::string& package_id, + const std::string& feature_id, + const std::string& resource_id) const; ModResolution resolve(const std::string& game_id, const std::string& exe_sha256 = {}, diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 3f4e4f0f..1b4be88a 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -81,12 +81,13 @@ int32_t psx_mod_widescreen_x_margin(void); int psx_mod_option_value(const char* package_id, const char* feature_id, const char* option_id, char* out, uint32_t out_size); /* - * Resolve a data file inside the package whose trusted plugin is currently - * running. This lets one trusted implementation serve many data-only packages - * without exposing native code or arbitrary host paths to archives. + * Read the committed owner-selected path for a resource declared by the + * package feature whose trusted plugin is currently running. Returns 0 when + * the feature has no selected path for that resource; plugins then leave the + * stock presentation unchanged. */ -int psx_mod_current_package_file(const char* relative_path, - char* out, uint32_t out_size); +int psx_mod_current_resource_path(const char* resource_id, + char* out, uint32_t out_size); /* * Request a fixed host display aspect before renderer/window initialization. @@ -131,8 +132,8 @@ int psx_mod_set_frame_interpolation_blend(uint32_t blend_mode); int psx_mod_set_auto_skip_fmv(int enabled); /* * Draw still artwork behind the game image in OpenGL letterbox/pillarbox - * margins. The image path is host-side package data selected by an enabled mod; - * with no enabled mod, the margins remain the historical black clear. + * margins. The image path is an owner-selected mod resource; with no enabled + * mod/resource path, the margins remain the historical black clear. */ int psx_mod_set_bezel_artwork(const char* path); diff --git a/runtime/src/mod_builtin_bezel.c b/runtime/src/mod_builtin_bezel.c index c347e305..82a5a4a3 100644 --- a/runtime/src/mod_builtin_bezel.c +++ b/runtime/src/mod_builtin_bezel.c @@ -1,10 +1,10 @@ /* * Framework-owned bezel presentation mod. * - * The renderer primitive is generic, but artwork is data. A trusted plugin - * resolves package-local bezel.png and asks the runtime to draw it behind the - * game image; package archives can ship different artwork without executing - * native code. + * The renderer primitive is generic, but artwork is an owner-selected resource. + * A trusted plugin reads the path committed by the launcher and asks the runtime + * to draw it behind the game image. With no selected path, the historical black + * margins remain unchanged. */ #include "mod_plugins.h" @@ -12,8 +12,8 @@ static void builtin_bezel_activate(void) { char path[1024] = ""; - if (!psx_mod_current_package_file("bezel.png", path, sizeof path)) { - fprintf(stderr, "psxrecomp: bezel package has no resolvable bezel.png\n"); + if (!psx_mod_current_resource_path("artwork", path, sizeof path)) { + fprintf(stdout, "psxrecomp: bezel artwork enabled with no image selected\n"); return; } (void)psx_mod_set_bezel_artwork(path); diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 7c5f1068..e0d7d381 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -680,6 +680,7 @@ std::string canonical_resolution(const std::vector& ordered, const std::vector& overlays, const std::vector& derived_discs, const std::vector& plugins, + const std::vector& resources, const std::string& source_disc_sha256) { std::ostringstream out; out << "source_disc=" << source_disc_sha256 << '\n'; @@ -753,6 +754,10 @@ std::string canonical_resolution(const std::vector& ordered, out << "plugin:" << plugin.id << ':' << plugin.package_id << ':' << plugin.feature_id << '\n'; } + for (const ModResolution::Resource& resource : resources) { + out << "resource:" << resource.package_id << ':' << resource.feature_id + << ':' << resource.id << '=' << resource.path.string() << '\n'; + } return out.str(); } @@ -772,6 +777,17 @@ const ModOption* find_option(const ModPackage& package, return option == package.options.end() ? nullptr : &*option; } +const ModResource* find_resource(const ModPackage& package, + const std::string& feature_id, + const std::string& id) { + const auto resource = std::find_if( + package.resources.begin(), package.resources.end(), + [&](const ModResource& item) { + return item.feature_id == feature_id && item.id == id; + }); + return resource == package.resources.end() ? nullptr : &*resource; +} + bool constraint_satisfied( const ModPackage& package, const ModConstraint& constraint, const std::function& value_for, @@ -824,6 +840,20 @@ std::string effective_option_value(const ModPackage& package, return option ? option->default_value : std::string(); } +fs::path effective_resource_path(const ModPackage& package, + const ModSelection& selection, + const std::string& feature_id, + const std::string& id) { + const ModFeature* feature = find_feature(package, feature_id); + if (!feature || feature->legacy) return {}; + const ModFeatureSelection* selected = + find_feature_selection(package, selection, feature_id); + if (!selected) return {}; + const auto resource = selected->resources.find(id); + return resource == selected->resources.end() ? fs::path{} : + fs::path(resource->second); +} + bool prospective_feature_enabled( const ModPackage& package, const ModSelection& selection, const std::string& feature_id, const std::string* override_feature_id, @@ -1633,6 +1663,46 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, "in the same feature"); } } + if (cfg.contains("resource")) { + if (out.format_version < 5) + throw std::runtime_error( + "resources require format_version 5"); + std::set> resource_ids; + for (const toml::value& v : + toml::find(cfg, "resource").as_array()) { + ModResource resource; + resource.feature_id = feature_style + ? toml::find(v, "feature") : "legacy"; + resource.id = toml::find(v, "id"); + resource.label = toml::find(v, "label"); + resource.description = + toml::find_or(v, "description", ""); + resource.file_patterns = + toml::find_or(v, "file_patterns", ""); + resource.file_description = + toml::find_or(v, "file_description", ""); + resource.format = + toml::find_or(v, "format", "file"); + resource.required = + toml::find_or(v, "required", false); + if (!find_feature(out, resource.feature_id)) + throw std::runtime_error( + "resource references unknown feature"); + if (!valid_id(resource.id) || + !resource_ids.insert( + {resource.feature_id, resource.id}).second) + throw std::runtime_error( + "invalid or duplicate resource id"); + if (resource.label.empty()) + throw std::runtime_error("resource label is empty"); + if (resource.format != "file" && + resource.format != "directory" && + resource.format != "folder") + throw std::runtime_error( + "resource format must be file or directory"); + out.resources.push_back(std::move(resource)); + } + } if (cfg.contains("constraint")) { if (out.format_version < 3) throw std::runtime_error( @@ -2325,6 +2395,18 @@ bool ModPackageManager::load_state(std::string* error) { "state feature option values must be scalar"); } } + if (v.contains("resources")) { + for (const auto& [key, value] : + toml::find(v, "resources").as_table()) { + if (!valid_id(key)) + throw std::runtime_error( + "invalid state feature resource id"); + if (!value.is_string()) + throw std::runtime_error( + "state feature resource paths must be strings"); + feature.resources[key] = toml::get(value); + } + } selections_[package_id].features[feature_id] = std::move(feature); } @@ -2388,6 +2470,11 @@ bool ModPackageManager::save_state(std::string* error) const { for (const auto& [key, value] : feature.values) out << key << " = " << quote_toml(value) << "\n"; } + if (!feature.resources.empty()) { + out << "[feature.resources]\n"; + for (const auto& [key, value] : feature.resources) + out << key << " = " << quote_toml(value) << "\n"; + } } } out.close(); @@ -2648,6 +2735,43 @@ bool ModPackageManager::set_feature_option(const std::string& package_id, return true; } +bool ModPackageManager::set_feature_resource_path( + const std::string& package_id, + const std::string& feature_id, + const std::string& resource_id, + const fs::path& path, + std::string* error) { + const ModPackage* package = selected_package(package_id); + const ModFeature* feature = + package ? find_feature(*package, feature_id) : nullptr; + const ModResource* resource = + package ? find_resource(*package, feature_id, resource_id) : nullptr; + if (!feature || feature->legacy || !resource) { + set_error(error, "unknown feature resource"); + return false; + } + if (path.empty()) { + set_error(error, "resource path is empty"); + return false; + } + std::error_code ec; + const bool valid = resource->format == "directory" || + resource->format == "folder" + ? fs::is_directory(path, ec) + : fs::is_regular_file(path, ec); + if (!valid) { + set_error(error, + resource->format == "directory" || resource->format == "folder" + ? "selected resource path is not a directory" + : "selected resource path is not a file"); + return false; + } + selections_[package_id] + .features[feature_id] + .resources[resource_id] = path.string(); + return true; +} + const ModPackage* ModPackageManager::selected_package(const std::string& id) const { const auto selection = selections_.find(id); const ModSelection blank; @@ -2685,6 +2809,19 @@ std::string ModPackageManager::feature_option_value( feature_id, option_id); } +fs::path ModPackageManager::feature_resource_path( + const std::string& package_id, + const std::string& feature_id, + const std::string& resource_id) const { + const ModPackage* package = selected_package(package_id); + if (!package) return {}; + const auto found = selections_.find(package_id); + const ModSelection blank; + return effective_resource_path( + *package, found == selections_.end() ? blank : found->second, + feature_id, resource_id); +} + ModResolution ModPackageManager::resolve(const std::string& game_id, const std::string& exe_sha256, const std::string& disc_sha256) const { @@ -3101,6 +3238,30 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, resolved.feature_id = plugin->feature_id; result.plugins.push_back(std::move(resolved)); } + for (const ModResource& resource : package->resources) { + const ModFeature* feature = + find_feature(*package, resource.feature_id); + if (!feature || + !is_feature_enabled(*package, selected, *feature)) + continue; + const fs::path path = effective_resource_path( + *package, selected, resource.feature_id, resource.id); + if (path.empty()) { + if (resource.required) { + result.errors.push_back( + package->id + "/" + resource.feature_id + + ": required resource not selected: " + + resource.id); + } + continue; + } + ModResolution::Resource resolved; + resolved.package_id = package->id; + resolved.feature_id = resource.feature_id; + resolved.id = resource.id; + resolved.path = path; + result.resources.push_back(std::move(resolved)); + } } if (result.derived_discs.size() > 1) { std::string providers; @@ -3286,12 +3447,13 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, result.overlays.clear(); result.derived_discs.clear(); result.plugins.clear(); + result.resources.clear(); return result; } result.fingerprint = fingerprint_text( canonical_resolution( result.ordered, selections_, result.writes, result.overlays, - result.derived_discs, result.plugins, + result.derived_discs, result.plugins, result.resources, disc_sha256)); result.ok = true; return result; diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 3ee7c3c6..b4d72770 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -838,6 +838,69 @@ int provider_feature_set_option(void*, const char* package_id, }); } +int provider_feature_resource_count(void*, const char* package_id, + const char* feature_id) { + if (!package_id || !feature_id) return 0; + const ModPackage* package = selected_package(package_id); + if (!package) return 0; + return (int)std::count_if( + package->resources.begin(), package->resources.end(), + [&](const ModResource& resource) { + return resource.feature_id == feature_id; + }); +} + +int provider_feature_resource_get(void*, const char* package_id, + const char* feature_id, int index, + RecompLauncherCModResource* out) { + if (!package_id || !feature_id || !out || index < 0) return 0; + const ModPackage* package = selected_package(package_id); + if (!package) return 0; + for (const ModResource& resource : package->resources) { + if (resource.feature_id != feature_id) continue; + if (index-- != 0) continue; + const std::filesystem::path path = + state().manager.feature_resource_path( + package_id, feature_id, resource.id); + std::error_code ec; + const bool directory = + resource.format == "directory" || resource.format == "folder"; + const bool verified = !path.empty() && + (directory ? std::filesystem::is_directory(path, ec) + : std::filesystem::is_regular_file(path, ec)); + std::memset(out, 0, sizeof(*out)); + copy_text(out->id, sizeof(out->id), resource.id); + copy_text(out->label, sizeof(out->label), resource.label); + copy_text(out->description, sizeof(out->description), + resource.description); + copy_text(out->path, sizeof(out->path), path.string()); + copy_text(out->status, sizeof(out->status), + path.empty() ? "Not selected" : + (verified ? "Selected" : "Selected path is missing")); + copy_text(out->file_patterns, sizeof(out->file_patterns), + resource.file_patterns); + copy_text(out->file_description, sizeof(out->file_description), + resource.file_description); + out->required = resource.required ? 1 : 0; + out->verified = verified ? 1 : 0; + copy_text(out->format, sizeof(out->format), resource.format); + return 1; + } + return 0; +} + +int provider_feature_resource_set_path(void*, const char* package_id, + const char* feature_id, + const char* resource_id, + const char* path) { + if (!package_id || !feature_id || !resource_id || !path) return 0; + return mutate([&](std::string& error) { + return state().manager.set_feature_resource_path( + package_id, feature_id, resource_id, + std::filesystem::path(path), &error); + }); +} + int provider_diagnostic_count(void*, const char* package_id, const char* feature_id) { if (!package_id || !feature_id) return 0; @@ -1003,6 +1066,9 @@ RecompLauncherCModProvider provider = { nullptr, /* archive_extension — PSX defaults */ nullptr, /* archive_description */ provider_commit_netplay, + provider_feature_resource_count, + provider_feature_resource_get, + provider_feature_resource_set_path, }; #endif @@ -1219,8 +1285,11 @@ extern "C" void mod_runtime_on_vblank(void) { using namespace PSXRecompV4; RuntimeMods& s = state(); if (!s.initialized || !s.plan.ok) return; - for (const ModResolution::Plugin& plugin : s.plan.plugins) + for (const ModResolution::Plugin& plugin : s.plan.plugins) { + s.current_plugin = &plugin; mod_invoke_vblank_plugin(plugin.id); + s.current_plugin = nullptr; + } } extern "C" int psx_mod_game_started(void) { @@ -1249,28 +1318,25 @@ extern "C" int psx_mod_option_value(const char* package_id, return 1; } -extern "C" int psx_mod_current_package_file(const char* relative_path, - char* out, uint32_t out_size) { +extern "C" int psx_mod_current_resource_path(const char* resource_id, + char* out, uint32_t out_size) { using namespace PSXRecompV4; if (out && out_size) out[0] = '\0'; - if (!relative_path || !relative_path[0] || !out || out_size == 0) + if (!resource_id || !resource_id[0] || !out || out_size == 0) return 0; RuntimeMods& s = state(); if (!s.initialized || !s.plan.ok || !s.current_plugin) return 0; - const ModPackage* package = - s.manager.selected_package(s.current_plugin->package_id); - if (!package) return 0; - std::filesystem::path rel(relative_path); - if (rel.is_absolute()) return 0; - for (const auto& part : rel) { - if (part == "." || part == "..") return 0; + for (const ModResolution::Resource& resource : s.plan.resources) { + if (resource.package_id != s.current_plugin->package_id || + resource.feature_id != s.current_plugin->feature_id || + resource.id != resource_id) + continue; + const std::string text = resource.path.string(); + if (text.empty() || text.size() + 1 > (size_t)out_size) return 0; + std::memcpy(out, text.c_str(), text.size() + 1); + return 1; } - const std::filesystem::path path = - (package->root / rel).lexically_normal(); - const std::string text = path.string(); - if (text.size() + 1 > (size_t)out_size) return 0; - std::memcpy(out, text.c_str(), text.size() + 1); - return 1; + return 0; } extern "C" uint8_t psx_mod_read_byte(uint32_t address) { diff --git a/runtime/tests/test_mod_bezel.py b/runtime/tests/test_mod_bezel.py index 9bf79b70..6e82f9f1 100644 --- a/runtime/tests/test_mod_bezel.py +++ b/runtime/tests/test_mod_bezel.py @@ -21,15 +21,23 @@ MANIFEST = ( assert "video_bezel" not in CONFIG_H assert 'video.contains("bezel")' not in CONFIG_CPP assert "psx_mod_set_bezel_artwork" in HEADER -assert "psx_mod_current_package_file" in HEADER +assert "psx_mod_current_package_file" not in HEADER +assert "psx_mod_current_resource_path" in HEADER assert "psx_mod_set_bezel_artwork" in MAIN assert "g_video_renderer = 1;" in MAIN assert "mod-owned OpenGL margin artwork" in MAIN assert "current_plugin" in MOD_RUNTIME -assert 'psx_mod_current_package_file("bezel.png"' in BUILTIN +assert 'psx_mod_current_resource_path("artwork"' in BUILTIN assert 'psx_mod_register_activation_plugin("psx.bezel"' in BUILTIN assert 'id = "psx.presentation.bezel"' in MANIFEST assert "default_enabled = false" in MANIFEST +assert "[[resource]]" in MANIFEST +assert 'id = "artwork"' in MANIFEST +assert 'file_patterns = "*.png,*.jpg,*.jpeg,*.bmp"' in MANIFEST assert 'id = "psx.bezel"' in MANIFEST +assert not ( + ROOT + / "mods/builtin/packages/psx.presentation.bezel/1.0.0/bezel.png" +).exists() print("mod-owned bezel guard passed") diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp index 47410382..a72ace43 100644 --- a/runtime/tests/test_mod_packages.cpp +++ b/runtime/tests/test_mod_packages.cpp @@ -513,6 +513,77 @@ int main() { partial_compatible.fingerprint, "feature state must resolve deterministically after reload"); + fs::create_directories(root / "selected", ec); + write_text(root / "selected/bezel.png", "not a decoded image"); + write_text(root / "packages/resource.mod/1.0.0/manifest.toml", + "format_version = 5\n" + "id = \"resource.mod\"\n" + "version = \"1.0.0\"\n" + "name = \"Resource Mod\"\n" + "resolver = \"declarative\"\n" + "[[target]]\n" + "game_id = \"SLUS-TEST\"\n" + "[[feature]]\n" + "id = \"bezel\"\n" + "name = \"Bezel\"\n" + "[[resource]]\n" + "feature = \"bezel\"\n" + "id = \"artwork\"\n" + "label = \"Artwork\"\n" + "description = \"Pick image\"\n" + "file_patterns = \"*.png,*.jpg\"\n" + "file_description = \"Image files\"\n" + "required = false\n"); + write_text(root / "packages/required-resource.mod/1.0.0/manifest.toml", + "format_version = 5\n" + "id = \"required-resource.mod\"\n" + "version = \"1.0.0\"\n" + "name = \"Required Resource Mod\"\n" + "resolver = \"declarative\"\n" + "[[target]]\n" + "game_id = \"SLUS-TEST\"\n" + "[[feature]]\n" + "id = \"bezel\"\n" + "name = \"Bezel\"\n" + "[[resource]]\n" + "feature = \"bezel\"\n" + "id = \"artwork\"\n" + "label = \"Artwork\"\n" + "required = true\n"); + check(feature_reload.scan(&error), error.c_str()); + check(feature_reload.set_feature_enabled( + "resource.mod", "bezel", true, &error), error.c_str()); + ModResolution resource_unset = feature_reload.resolve("SLUS-TEST"); + check(resource_unset.ok && resource_unset.resources.empty(), + "optional resources must be omitted when no path is selected"); + check(feature_reload.set_feature_resource_path( + "resource.mod", "bezel", "artwork", + root / "selected/bezel.png", &error), error.c_str()); + ModResolution resource_selected = feature_reload.resolve("SLUS-TEST"); + check(resource_selected.ok && resource_selected.resources.size() == 1 && + resource_selected.resources[0].id == "artwork" && + resource_selected.resources[0].path == + root / "selected/bezel.png", + "selected feature resource path must enter the committed plan"); + check(feature_reload.save_state(&error), error.c_str()); + ModPackageManager resource_reload(root); + check(resource_reload.scan(&error), error.c_str()); + check(resource_reload.load_state(&error), error.c_str()); + check(resource_reload.feature_resource_path( + "resource.mod", "bezel", "artwork") == + root / "selected/bezel.png", + "feature resource paths must survive save/reload"); + check(resource_reload.set_feature_enabled( + "required-resource.mod", "bezel", true, &error), error.c_str()); + ModResolution resource_required = resource_reload.resolve("SLUS-TEST"); + check(!resource_required.ok, + "required enabled resources must reject launch while unset"); + check(resource_reload.set_feature_resource_path( + "required-resource.mod", "bezel", "artwork", + root / "selected/bezel.png", &error), error.c_str()); + check(resource_reload.resolve("SLUS-TEST").ok, + "required enabled resources must resolve after selecting a path"); + write_text(root / "packages/parametric.mod/1.0.0/manifest.toml", "format_version = 3\n" "id = \"parametric.mod\"\n"