diff --git a/docs/HD_TEXTURE_PACKS.md b/docs/HD_TEXTURE_PACKS.md index 80179514..7f5d63dc 100644 --- a/docs/HD_TEXTURE_PACKS.md +++ b/docs/HD_TEXTURE_PACKS.md @@ -6,6 +6,10 @@ Use **OpenGL** to display replacements. Software and Vulkan can collect dumps but continue to display the original artwork. Tomba USA (`SCUS-94236`) is the first integrated game target; game projects can supply their own manifest for the same `psx.hd-textures` plugin. +A title manifest can also ship a pack inside its own package and point the +`pack` resource at it with `default = ""` (see +[MOD_PACKAGES.md](MOD_PACKAGES.md#owner-selected-resources)); a folder the +player selects still takes precedence. Tomba provides an [optional drop-in example pack](https://github.com/mstan/TombaRecomp/tree/master/examples/hd-texture-pack) with five synthetic checkerboards and a README inside its `SCUS-94236` pack diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index 623885c0..d7e7b857 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -869,6 +869,14 @@ 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. +A `file`, `directory` or `folder` resource may name a package-relative +`default` (for example `default = "pack"`): a file or folder the package ships +beside its manifest. It is used while the player has selected nothing, so a +required resource with a default resolves out of the box; a selected path +always wins. The default must be a safe relative path inside the package +(no absolute paths or `..`) and cannot be combined with verified media +(`size`/`sha256`), `input_only` or `shared_source`. + Format 8 adds engine-verified donor media to the same picker and state format: diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index 61784270..d989083e 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -241,6 +241,9 @@ struct ModResource { std::string shared_source; bool input_only = false; bool hidden = false; + // Package-relative path used when the player has not selected one (for + // example a pack folder the package ships). A selection always wins. + std::string default_path; }; struct ModDerivedDisc { diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 958d1e34..c7374f1c 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -869,10 +869,15 @@ fs::path effective_resource_path(const ModPackage& package, 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::u8path(resource->second); + if (selected) { + const auto resource = selected->resources.find(id); + if (resource != selected->resources.end() && !resource->second.empty()) + return fs::u8path(resource->second); + } + const ModResource* declared = find_resource(package, feature_id, id); + if (declared && !declared->default_path.empty() && !package.root.empty()) + return package.root / fs::u8path(declared->default_path); + return {}; } bool prospective_feature_enabled( @@ -1937,6 +1942,15 @@ bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, resource.shared_source = toml::find_or(v, "shared_source", ""); resource.input_only = toml::find_or(v, "input_only", false); resource.hidden = toml::find_or(v, "hidden", false); + resource.default_path = toml::find_or(v, "default", ""); + if (!resource.default_path.empty() && + (!safe_archive_name(resource.default_path) || + !resource.sha256.empty() || resource.input_only || + !resource.shared_source.empty() || + (resource.format != "file" && resource.format != "directory" && + resource.format != "folder"))) + throw std::runtime_error( + "resource default must be a safe package-relative file or folder"); if ((resource.input_only || resource.hidden || !resource.shared_source.empty()) && out.format_version < 9) throw std::runtime_error("source bindings require format_version 9"); if (!resource.shared_source.empty() && (!resource.input_only || !valid_id(resource.shared_source))) diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp index 5c3202c4..c14c462d 100644 --- a/runtime/tests/test_mod_packages.cpp +++ b/runtime/tests/test_mod_packages.cpp @@ -663,6 +663,77 @@ int main() { check(resource_reload.resolve("SLUS-TEST").ok, "required enabled resources must resolve after selecting a path"); + /* A package-relative default supplies a required folder the package + * ships; a player selection still wins, and clearing it restores it. */ + const fs::path default_pack = root / "installed/default-resource.mod/1.0.0/pack"; + fs::create_directories(default_pack, ec); + fs::create_directories(root / "selected/pack", ec); + write_text(root / "installed/default-resource.mod/1.0.0/manifest.toml", + "format_version = 5\n" + "id = \"default-resource.mod\"\n" + "version = \"1.0.0\"\n" + "name = \"Default Resource Mod\"\n" + "resolver = \"declarative\"\n" + "[[target]]\n" + "game_id = \"SLUS-TEST\"\n" + "[[feature]]\n" + "id = \"pack\"\n" + "name = \"Pack\"\n" + "default_enabled = true\n" + "[[resource]]\n" + "feature = \"pack\"\n" + "id = \"pack\"\n" + "label = \"Pack folder\"\n" + "format = \"directory\"\n" + "default = \"pack\"\n" + "required = true\n"); + ModPackageManager default_resource(root); + check(default_resource.scan(&error), error.c_str()); + check(default_resource.feature_resource_path( + "default-resource.mod", "pack", "pack") == default_pack, + "an unselected resource must use its package-relative default"); + ModResolution default_resolved = default_resource.resolve("SLUS-TEST"); + const bool default_in_plan = std::any_of( + default_resolved.resources.begin(), default_resolved.resources.end(), + [&](const ModResolution::Resource& r) { + return r.package_id == "default-resource.mod" && r.path == default_pack; + }); + check(default_in_plan, + "a required resource with a default must resolve without a selection"); + check(default_resource.set_feature_resource_path( + "default-resource.mod", "pack", "pack", + root / "selected/pack", &error), error.c_str()); + check(default_resource.feature_resource_path( + "default-resource.mod", "pack", "pack") == root / "selected/pack", + "a selected resource path must override the package default"); + fs::remove_all(root / "installed/default-resource.mod", ec); + { + const fs::path unsafe_root = root / "unsafe-default"; + write_text(unsafe_root / "installed/unsafe.mod/1.0.0/manifest.toml", + "format_version = 5\n" + "id = \"unsafe.mod\"\n" + "version = \"1.0.0\"\n" + "name = \"Unsafe\"\n" + "resolver = \"declarative\"\n" + "[[target]]\n" + "game_id = \"SLUS-TEST\"\n" + "[[feature]]\n" + "id = \"pack\"\n" + "name = \"Pack\"\n" + "[[resource]]\n" + "feature = \"pack\"\n" + "id = \"pack\"\n" + "label = \"Pack folder\"\n" + "format = \"directory\"\n" + "default = \"../outside\"\n"); + ModPackageManager unsafe(unsafe_root); + unsafe.scan(&error); + check(!unsafe.scan_errors().empty(), + "an unsafe resource default must be reported by the scan"); + check(unsafe.feature_resource_path("unsafe.mod", "pack", "pack").empty(), + "a resource default outside the package must be rejected"); + } + write_text(root / "packages/parametric.mod/1.0.0/manifest.toml", "format_version = 3\n" "id = \"parametric.mod\"\n"