diff --git a/recompiler/include/host_path.h b/recompiler/include/host_path.h index a4a665ca..1dc0b965 100644 --- a/recompiler/include/host_path.h +++ b/recompiler/include/host_path.h @@ -1,10 +1,22 @@ #pragma once #include +#include #include namespace PSXRecompV4 { +// generic_string() can collapse a MinGW UNC root from "//server" to +// "/server". Persist the native spelling, changing separators only on +// Windows; a backslash in a POSIX filename is an ordinary character. +inline std::string host_path_forward_slashes(const std::filesystem::path& path) { + std::string result = path.string(); +#ifdef _WIN32 + for (char& c : result) if (c == '\\') c = '/'; +#endif + return result; +} + // Some MinGW libstdc++ builds treat a readable UNC path as drive-relative: // absolute("\\\\server\\share\\file") becomes "D:\\server\\share\\file". // Windows already considers UNC and device namespace paths fully qualified. diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 438cc04b..60d064fb 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -3016,10 +3016,9 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) { std::ofstream f(path, std::ios::trunc); if (!f.is_open()) return false; - // TOML strings use forward slashes so backslash escaping is never an issue. + // Windows paths use forward slashes so separators need no TOML escaping. auto fwd = [](const fs::path& p) { - std::string str = p.generic_string(); - return str; + return host_path_forward_slashes(p); }; // Paths inside the game folder are stored relative to it, so a portable // folder still finds its disc, BIOS and memory cards after it is moved or diff --git a/recompiler/tests/portable_settings_paths_test.cpp b/recompiler/tests/portable_settings_paths_test.cpp index ddfba54f..ec8bbe95 100644 --- a/recompiler/tests/portable_settings_paths_test.cpp +++ b/recompiler/tests/portable_settings_paths_test.cpp @@ -11,6 +11,7 @@ * anchoring resolves it on the new PC. */ #include "config_loader.h" +#include "host_path.h" #include #include @@ -71,6 +72,33 @@ int main() { check(back.has_memcard1_path && back.memcard1_path == fs::path("saves/card1.mcd"), "relative card path reads back unchanged"); +#ifdef _WIN32 + // A NAS selection must survive a launcher save and reopen. No network + // asset is opened: only the task-local settings file is written/read. + const fs::path expected("//server/share/Game files/disc.chd"); + for (const char* spelling : { + "//server/share/Game files/disc.chd", + R"(\\server\share\Game files\disc.chd)"}) { + PSXRecompV4::UserSettings network{}; + network.disc_path = spelling; network.has_disc_path = true; + network.bios_path = spelling; network.has_bios_path = true; + network.memcard1_path = spelling; network.has_memcard1_path = true; + check(PSXRecompV4::save_user_settings(settings, network), "UNC settings save"); + const auto restored = PSXRecompV4::load_user_settings(settings); + check(restored.has_disc_path && + restored.disc_path.native() == expected.native(), + "saved UNC disc keeps both leading separators"); + check(restored.has_bios_path && + restored.bios_path.native() == expected.native(), + "saved UNC BIOS keeps both leading separators"); + check(restored.has_memcard1_path && + restored.memcard1_path.native() == expected.native(), + "saved UNC memory card keeps both leading separators"); + check(PSXRecompV4::host_resolve(game, restored.disc_path).native() == expected.native(), + "reopened UNC disc never becomes a path on the current drive"); + } +#endif + fs::remove_all(game.parent_path(), ec); if (failures) return 1; std::printf("portable_settings_paths_test: all checks passed\n"); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 77907729..aa219a89 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -2610,7 +2610,8 @@ static void write_cached_path(const char* argv0, const char* filename, // Relative inside the game folder so a moved portable folder still works; // read_cached_path anchors relative paths on the exe directory. if (f.is_open()) - f << PSXRecompV4::relative_to_folder(path, exe_dir_from_argv(argv0)).generic_string() << "\n"; + f << PSXRecompV4::host_path_forward_slashes( + PSXRecompV4::relative_to_folder(path, exe_dir_from_argv(argv0))) << "\n"; } /* Nobody is at the screen: never block on a modal dialog or a file picker. */ diff --git a/runtime/tests/test_persisted_settings_paths.py b/runtime/tests/test_persisted_settings_paths.py index 9dca4abf..19ea37e0 100644 --- a/runtime/tests/test_persisted_settings_paths.py +++ b/runtime/tests/test_persisted_settings_paths.py @@ -1,10 +1,10 @@ """Relative paths persisted in settings.toml and the bios.cfg / disc.cfg sidecars resolve from the executable directory, not the working directory. -Compiles the real adapters from main.cpp (normalize_disc_path_for_launch, -resolve_persisted_disc_path, anchor_on_exe_dir) into a probe and runs it from -an unrelated working directory. Also checks that every persisted-path consumer -in main.cpp goes through an adapter. No game data is used. +Compiles the real adapters and sidecar writer/reader from main.cpp, plus +relative_to_folder from config_loader.cpp, into a probe and runs it from an +unrelated working directory. Checks save/reopen as well as path consumers. +No game data is used. """ import argparse import os @@ -46,6 +46,13 @@ def main(): 'anchor_on_exe_dir'): start = text.index('static std::filesystem::path ' + name + '(') adapters.append(text[start:text.index('\n}\n', start) + 2]) + config = (root.parent / 'recompiler/src/config_loader.cpp').read_text(encoding='utf-8') + start = config.index('fs::path relative_to_folder(') + relative_adapter = config[start:config.index('\n}\n', start) + 2] + for declaration in ('static std::filesystem::path read_cached_path(', + 'static void write_cached_path('): + start = text.index(declaration) + adapters.append(text[start:text.index('\n}\n', start) + 2]) with tempfile.TemporaryDirectory(prefix='persisted paths spaces ') as tmp: base = Path(tmp) product, cwd = base / 'moved product', base / 'unrelated cwd' @@ -58,22 +65,34 @@ def main(): # The probe's exe_dir_from_argv() stands in for the real one: the # "argv0" it receives is the product directory itself. source.write_text('''#include +#include #include #include #include "host_path.h" #include "disc_path.h" +namespace fs = std::filesystem; +namespace PSXRecompV4 { +''' + relative_adapter + ''' +} static std::filesystem::path exe_dir_from_argv(const char* dir) { return std::filesystem::path(dir); } +static std::filesystem::path sidecar_cfg_path(const char* dir, const char* filename) { + return exe_dir_from_argv(dir) / filename; +} ''' + '\n'.join(adapters) + ''' int main(int argc, char** argv) { (void)argc; const std::string mode = argv[1]; const std::filesystem::path path = argv[2]; - const std::filesystem::path resolved = + std::filesystem::path resolved = mode == "persisted" ? resolve_persisted_disc_path(path, argv[3]) : mode == "anchor" ? anchor_on_exe_dir(argv[3], path) : normalize_disc_path_for_launch(path); + if (mode == "sidecar") { + write_cached_path(argv[3], "disc.cfg", path); + resolved = read_cached_path(argv[3], "disc.cfg"); + } // libstdc++'s generic_string() collapses a UNC "\\\\server" root to "/server"; // print the native spelling with forward slashes so both C++ libraries agree. std::string out = resolved.string(); @@ -109,17 +128,22 @@ int main(int argc, char** argv) { check('persisted', '', '') check('persisted', 'inputs/missing.chd', (product/'inputs/missing.chd').as_posix()) check('cli', relative, (cwd/relative).as_posix()) + check('sidecar', product/relative, (product/relative).as_posix()) + check('sidecar', '', '') if os.name == 'nt': - check('persisted', r'\\server\share\Some Game [Disc 1].chd', - '//server/share/Some Game [Disc 1].chd') + for spelling in (r'\\server\share\Some Game [Disc 1].chd', + '//server/share/Some Game [Disc 1].chd'): + check('persisted', spelling, '//server/share/Some Game [Disc 1].chd') + check('sidecar', spelling, '//server/share/Some Game [Disc 1].chd') # [bios] path, bios.cfg and [memcard] dir/card1/card2 check_same('anchor', '.', product) check_same('anchor', 'cards/card1.mcd', product/'cards/card1.mcd') check_same('anchor', 'bios/SCPH1001.BIN', product/'bios/SCPH1001.BIN') check('anchor', product/'cards', (product/'cards').as_posix()) check('anchor', '', '') - print('persisted paths: disc (relative, absolute, empty, missing, CLI cwd, ' - 'Windows UNC) and BIOS/memory card (".", relative, absolute, empty) passed') + print('persisted paths: disc consumers and sidecar save/reopen ' + '(relative, absolute, empty, missing, CLI cwd, Windows UNC) ' + 'and BIOS/memory card (".", relative, absolute, empty) passed') if __name__ == '__main__':