Preserve Windows UNC roots when persisting launcher paths

Use native path spelling for settings and BIOS/disc sidecars. Add save/reopen regressions for UNC selections.

Beads: beads-eio.3.286
This commit is contained in:
Matthew Stanley
2026-10-06 21:52:36 -07:00
parent 7b2539415a
commit 6e154bcf03
5 changed files with 77 additions and 13 deletions
+12
View File
@@ -1,10 +1,22 @@
#pragma once
#include <filesystem>
#include <string>
#include <system_error>
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.
+2 -3
View File
@@ -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
@@ -11,6 +11,7 @@
* anchoring resolves it on the new PC.
*/
#include "config_loader.h"
#include "host_path.h"
#include <cstdio>
#include <filesystem>
@@ -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");
+2 -1
View File
@@ -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. */
+33 -9
View File
@@ -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 <filesystem>
#include <fstream>
#include <iostream>
#include <string>
#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__':