diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index 03cb4f87..6dca140b 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -8,6 +8,7 @@ #include #include #include +#include #include namespace PSXRecompV4 { @@ -530,6 +531,14 @@ public: std::string feature_option_value(const std::string& package_id, const std::string& feature_id, const std::string& option_id) const; + /* Runtime-derived defaults are not player selections and are never saved. + * relative_path is rooted at this manager's mods root, captured as an + * absolute path at registration so subsequent cwd changes cannot rebase it. + * Existing explicit selections (including legacy absolute paths) win. */ + bool set_generated_feature_resource_default(const std::string& package_id, + const std::string& feature_id, const std::string& resource_id, + const std::filesystem::path& relative_path, std::string* error = nullptr); + std::filesystem::path feature_resource_path( const std::string& package_id, const std::string& feature_id, @@ -588,6 +597,10 @@ private: std::vector* diagnostics, std::vector* errors) const; + std::filesystem::path generated_resource_default(const std::string& package_id, + const std::string& feature_id, const std::string& resource_id) const; + std::map, std::filesystem::path> + generated_resource_defaults_; std::filesystem::path root_; bool developer_channel_ = kDeveloperChannelDefault; std::vector scan_errors_; diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index efa564e6..43353cf6 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -885,7 +885,8 @@ static fs::path package_default_path(const ModPackage& package, const ModResourc fs::path effective_resource_path(const ModPackage& package, const ModSelection& selection, const std::string& feature_id, - const std::string& id) { + const std::string& id, + const fs::path& generated_default = {}) { const ModFeature* feature = find_feature(package, feature_id); if (!feature || feature->legacy) return {}; const ModFeatureSelection* selected = @@ -898,7 +899,7 @@ fs::path effective_resource_path(const ModPackage& package, const ModResource* declared = find_resource(package, feature_id, id); if (declared && !declared->default_path.empty() && !package.root.empty()) return package_default_path(package, *declared); - return {}; + return generated_default; } bool prospective_feature_enabled( @@ -1724,6 +1725,7 @@ void ModPackageManager::set_root(fs::path mods_root) { packages_.clear(); selections_.clear(); sources_.clear(); + generated_resource_defaults_.clear(); } bool ModPackageManager::read_manifest(const fs::path& path, ModPackage& out, @@ -3344,6 +3346,35 @@ std::string ModPackageManager::feature_option_value( feature_id, option_id); } +bool ModPackageManager::set_generated_feature_resource_default( + const std::string& package_id, const std::string& feature_id, + const std::string& resource_id, const fs::path& relative_path, + std::string* error) { + const auto* package = selected_package(package_id); + const auto* resource = package ? find_resource(*package, feature_id, resource_id) : nullptr; + if (!resource || !resource->shared_source.empty() || relative_path.empty() || + relative_path.is_absolute() || relative_path.has_root_name() || relative_path.has_root_directory()) { + set_error(error, "invalid generated resource default"); + return false; + } + for (const auto& component : relative_path) if (component == "." || component == ".." || component.empty()) { + set_error(error, "generated resource default must stay relative to mods root"); + return false; + } + std::error_code ec; + const auto path = fs::absolute(root_ / relative_path, ec); + if (ec) { set_error(error, ec.message()); return false; } + generated_resource_defaults_[{package_id, feature_id, resource_id}] = path; + return true; +} + +fs::path ModPackageManager::generated_resource_default( + const std::string& package_id, const std::string& feature_id, + const std::string& resource_id) const { + const auto found = generated_resource_defaults_.find({package_id, feature_id, resource_id}); + return found == generated_resource_defaults_.end() ? fs::path() : found->second; +} + fs::path ModPackageManager::feature_resource_path( const std::string& package_id, const std::string& feature_id, @@ -3363,7 +3394,8 @@ fs::path ModPackageManager::feature_resource_path( const auto selected = selections_.find(id); if (selected == selections_.end()) continue; for (const auto& r : other->resources) if(r.shared_source == resource->shared_source) { - auto path = effective_resource_path(*other, selected->second, r.feature_id, r.id); + auto path = effective_resource_path(*other, selected->second, r.feature_id, r.id, + generated_resource_default(other->id, r.feature_id, r.id)); if (!path.empty()) return path; } } @@ -3373,7 +3405,8 @@ fs::path ModPackageManager::feature_resource_path( const ModSelection blank; return effective_resource_path( *package, found == selections_.end() ? blank : found->second, - feature_id, resource_id); + feature_id, resource_id, + generated_resource_default(package_id, feature_id, resource_id)); } std::map ModPackageManager::effective_selections( @@ -4036,7 +4069,8 @@ ModResolution ModPackageManager::resolve(const std::string& game_id, !is_feature_enabled(*package, selected, *feature)) continue; const fs::path path = effective_resource_path( - *package, selected, resource.feature_id, resource.id); + *package, selected, resource.feature_id, resource.id, + generated_resource_default(package->id, resource.feature_id, resource.id)); if (path.empty()) { if (resource.required) { result.errors.push_back( diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index fecff442..d1a24521 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -1583,15 +1583,19 @@ bool mod_runtime_initialize(const std::filesystem::path& root, }); if (resource == package->resources.end()) continue; std::error_code ec; - const auto pack = std::filesystem::absolute( - root / "texture-packs" / texture_game_id, ec); + std::string directory_error; + if (!s.manager.set_generated_feature_resource_default(package->id, + plugin.feature_id, "pack", std::filesystem::path("texture-packs") / texture_game_id, + &directory_error)) { + std::fprintf(stderr, "psxrecomp: texture pack folder: %s\n", directory_error.c_str()); + continue; + } + const auto pack = s.manager.feature_resource_path(package->id, plugin.feature_id, "pack"); if (!ec) std::filesystem::create_directories(pack / "replacements", ec); if (!ec) std::filesystem::create_directories(pack / "dumps", ec); - std::string directory_error; - if (ec || !s.manager.set_feature_resource_path(package->id, - plugin.feature_id, "pack", pack, &directory_error)) { + if (ec) { std::fprintf(stderr, "psxrecomp: texture pack folder: %s\n", - ec ? ec.message().c_str() : directory_error.c_str()); + ec.message().c_str()); } } } diff --git a/runtime/tests/test_mod_packages.cpp b/runtime/tests/test_mod_packages.cpp index e30c5cbc..0ca04947 100644 --- a/runtime/tests/test_mod_packages.cpp +++ b/runtime/tests/test_mod_packages.cpp @@ -2346,6 +2346,8 @@ int main() { } ModPackageManager manager(folder); check(manager.scan(&error) && manager.scan_errors().empty(), "source/preparation catalog parses"); + check(!manager.set_generated_feature_resource_default("cars", "content", "source", "generated/source", &error), + "generated defaults cannot appoint shared user sources"); check(manager.set_feature_resource_path("cars", "content", "source", source, &error), error.c_str()); check(manager.feature_resource_path("music", "content", "source") == source, "source picker prefills sibling package"); check(manager.set_feature_resource_path("music", "content", "source", replacement, &error), error.c_str()); diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 3d0d179a..affe6140 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -2058,6 +2058,84 @@ int main(int argc, char** argv) { check(!psx_mod_netplay_is_active() && psx_mod_netplay_savestates_supported() && PSXRecompV4::mod_runtime_fingerprint().empty(), "profile end clears hooks and snapshot policy"); check(read_selection() == offline_selection, "profile end preserves offline selection file"); + + { + const auto a = root / "portable-default-a"; + const auto b = root / "portable-default-b"; + const auto manifest = a / "bundled/portable.hd/1.0.0/manifest.toml"; + write_text(manifest, + "format_version = 5\nid = \"portable.hd\"\nversion = \"1.0.0\"\nname = \"Portable HD fixture\"\nresolver = \"declarative\"\nsave_compatibility = \"shared\"\n" + "[[target]]\ngame_id = \"SLUS-PORTABLE\"\n" + "[[feature]]\nid = \"hd\"\nname = \"HD\"\ndefault_enabled = false\n" + "[[resource]]\nfeature = \"hd\"\nid = \"pack\"\nlabel = \"Pack\"\nformat = \"directory\"\nrequired = true\n" + "[[option]]\nfeature = \"hd\"\nid = \"replacements\"\nlabel = \"Load\"\ntype = \"boolean\"\ndefault = \"true\"\n" + "[[option]]\nfeature = \"hd\"\nid = \"dump\"\nlabel = \"Dump\"\ntype = \"boolean\"\ndefault = \"false\"\n" + "[[plugin]]\nfeature = \"hd\"\nid = \"psx.hd-textures\"\n"); + check(PSXRecompV4::mod_runtime_initialize(a, "SLUS-PORTABLE", 0x80002000, {}, &error), error.c_str()); + check(fs::is_directory(a / "texture-packs/SLUS-PORTABLE/replacements"), "disabled default was actually initialized"); + check(PSXRecompV4::mod_runtime_commit({}, &error), error.c_str()); + fs::copy(a, b, fs::copy_options::recursive, ec); + check(!ec, "copy default-disabled fixture to relocated root"); + fs::rename(a, root / "unavailable-default-a", ec); + check(!ec && !fs::exists(a), "old install root is unavailable"); + check(PSXRecompV4::mod_runtime_initialize(b, "SLUS-PORTABLE", 0x80002000, {}, &error), error.c_str()); +#if defined(RECOMP_LAUNCHER) + const auto* portable_provider = PSXRecompV4::mod_runtime_launcher_provider(); + RecompLauncherCModResource portable_resource{}; + check(portable_provider && portable_provider->feature_resource_get(portable_provider->ctx, + "portable.hd", "hd", 0, &portable_resource) && + fs::path(portable_resource.path) == fs::absolute(b / "texture-packs/SLUS-PORTABLE"), + "live launcher getter exposes exact relocated B default while disabled"); +#endif + check(PSXRecompV4::mod_runtime_commit({}, &error), error.c_str()); + PSXRecompV4::ModPackageManager readback(b); + check(readback.scan(&error) && readback.load_state(&error), error.c_str()); + check(readback.selected_package("portable.hd") != nullptr, "portable fixture package remains selected"); + const auto retained = readback.feature_resource_path("portable.hd", "hd", "pack"); + check(retained.empty() || retained.lexically_relative(a).empty() || + *retained.lexically_relative(a).begin() == "..", + "disabled generated default must not retain unavailable install root"); + check(!fs::exists(a), "relocation does not recreate unavailable old root"); + check(readback.set_feature_enabled("portable.hd", "hd", true, &error) && readback.save_state(&error), error.c_str()); + check(PSXRecompV4::mod_runtime_initialize(b, "SLUS-PORTABLE", 0x80002000, {}, &error), error.c_str()); + const auto before_cwd = fs::current_path(); + fs::current_path(root); + check(PSXRecompV4::mod_runtime_commit({}, &error), error.c_str()); + mod_runtime_activate_plugins(); + check(fs::path(hd_root) == fs::absolute(b / "texture-packs/SLUS-PORTABLE"), + "enabled relocated plugin receives B default despite changed cwd"); + fs::current_path(before_cwd); + + PSXRecompV4::ModPackageManager generated(b); + check(generated.scan(&error) && generated.load_state(&error), error.c_str()); + check(generated.set_generated_feature_resource_default("portable.hd", "hd", "pack", + "texture-packs/SLUS-PORTABLE", &error), error.c_str()); + const auto expected = fs::absolute(b / "texture-packs/SLUS-PORTABLE"); + check(generated.feature_resource_path("portable.hd", "hd", "pack") == expected, + "manager getter returns exact relocated default"); + const auto resolved = generated.resolve("SLUS-PORTABLE"); + check(resolved.ok && resolved.resources.size() == 1 && resolved.resources[0].path == expected, + "committed resolver and getter agree on B"); + for (const auto& invalid : {fs::path("../escape"), fs::path("./escape"), expected}) + check(!generated.set_generated_feature_resource_default("portable.hd", "hd", "pack", invalid, &error), + "generated defaults reject rooted or traversal paths"); + const auto external = root / "explicit-external-pack"; + fs::create_directories(external); + check(generated.set_feature_resource_path("portable.hd", "hd", "pack", external, &error) && + generated.save_state(&error), error.c_str()); + fs::rename(external, root / "unavailable-external-pack", ec); + check(!ec, "explicit external fixture made unavailable"); + PSXRecompV4::ModPackageManager legacy(b); + check(legacy.scan(&error) && legacy.load_state(&error) && + legacy.set_generated_feature_resource_default("portable.hd", "hd", "pack", + "texture-packs/SLUS-PORTABLE", &error), error.c_str()); + check(legacy.feature_resource_path("portable.hd", "hd", "pack") == external, + "missing legacy absolute user selection remains unchanged"); + const auto missing_plan = legacy.resolve("SLUS-PORTABLE"); + check(missing_plan.resources.size() == 1 && missing_plan.resources[0].path == external, + "resolver preserves missing selection for existing downstream validation"); + } + fs::remove_all(root, ec); if (failures) return 1; std::cout << "mod runtime tests passed\n";