diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-17 00:53:20 -0500 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-17 00:53:20 -0500 |
| commit | 06c396fd27f55929b40161e587cb9b596b549f3f (patch) | |
| tree | 40fdb465d2fe6e7d4487bbd237582868c8332459 /src | |
| parent | e583d6dda3cde9e8bccee313fe1ad69634593708 (diff) | |
Symlink saves, rank prefixes by Steam app type, ntsync safeguards
- Replace copy-in/copy-out save deployment with a direct symlink from the
prefix's __MO_Saves to the profile's saves/. Writes land in the profile
immediately; afterRun reverts the symlink + prefix INI so a vanilla
launch outside MO2 uses the default Saves dir.
- Parse Steam's binary appinfo.vdf (v41) via a new Rust FFI crate backed
by steam-vdf-parser. Rank detected games by common.type so real Games
beat Tools/Editors when multiple prefixes share a My Games folder name
(fixes Creation Kit shadowing Skyrim SE). Top-ranked candidate
overwrites stale symlinks from earlier runs.
- Switch bundled Steam Linux Runtime downloader from sniper (steamrt3)
to steamrt4 so Proton 11+ works out of the box. steamrt4 omits xrandr
so we inject it from the Debian x11-xserver-utils .deb into a
dedicated dir and prepend it to PATH via `env PATH=...` inside the
pressure-vessel container (PATH env doesn't propagate).
- Auto-kill stale wineboot/wineserver/pv-adverb processes still bound
to the prefix before starting a new wineboot -u; match by
/proc/<pid>/environ WINEPREFIX / STEAM_COMPAT_DATA_PATH since Wine
cmdlines are Windows-style. Same sweep runs in destroyPrefix so a
delete actually frees file handles.
- Blacklist Proton 11 (Steam-bundled) — its current Wine tree deadlocks
during wineboot -u on modern kernels via ntsync / futex_wait_multiple
races. Use "waitforexitandrun" (not "run") for prefix init so
use_sessions=1 Protons don't fork into a persistent session manager.
- Drop WINEDEBUG=-all during prefix init so wineboot progress is
visible. Suppress focus-stealing by setting WA_ShowWithoutActivating
on the PrefixSetupDialog and SLR download progress dialogs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/src/CMakeLists.txt | 3 | ||||
| -rw-r--r-- | src/src/fluorineconfig.cpp | 59 | ||||
| -rw-r--r-- | src/src/instancemanagerdialog.cpp | 3 | ||||
| -rw-r--r-- | src/src/mainwindow.cpp | 3 | ||||
| -rw-r--r-- | src/src/organizercore.cpp | 14 | ||||
| -rw-r--r-- | src/src/prefixsetupdialog.cpp | 4 | ||||
| -rw-r--r-- | src/src/prefixsetuprunner.cpp | 96 | ||||
| -rw-r--r-- | src/src/prefixsetuprunner.h | 4 | ||||
| -rw-r--r-- | src/src/prefixsymlinks.cpp | 87 | ||||
| -rw-r--r-- | src/src/protonlauncher.cpp | 29 | ||||
| -rw-r--r-- | src/src/settingsdialogproton.cpp | 3 | ||||
| -rw-r--r-- | src/src/slrmanager.cpp | 61 | ||||
| -rw-r--r-- | src/src/steamappinfo.cpp | 64 | ||||
| -rw-r--r-- | src/src/steamappinfo.h | 26 | ||||
| -rw-r--r-- | src/src/steamdetection.cpp | 14 | ||||
| -rw-r--r-- | src/src/wineprefix.cpp | 97 | ||||
| -rw-r--r-- | src/src/wineprefix.h | 7 |
17 files changed, 517 insertions, 57 deletions
diff --git a/src/src/CMakeLists.txt b/src/src/CMakeLists.txt index b0eb235..e5a8230 100644 --- a/src/src/CMakeLists.txt +++ b/src/src/CMakeLists.txt @@ -27,7 +27,8 @@ list(REMOVE_ITEM ORGANIZER_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/vdfparser.cpp ${CMAKE_CURRENT_SOURCE_DIR}/iconextractor.cpp ${CMAKE_CURRENT_SOURCE_DIR}/prefixsymlinks.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/slrmanager.cpp) + ${CMAKE_CURRENT_SOURCE_DIR}/slrmanager.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/steamappinfo.cpp) file(GLOB ORGANIZER_HEADERS ${CMAKE_CURRENT_SOURCE_DIR}/*.h ${CMAKE_CURRENT_SOURCE_DIR}/shared/*.h diff --git a/src/src/fluorineconfig.cpp b/src/src/fluorineconfig.cpp index 7ca320e..9608267 100644 --- a/src/src/fluorineconfig.cpp +++ b/src/src/fluorineconfig.cpp @@ -7,6 +7,11 @@ #include <QJsonDocument> #include <QJsonObject> #include <QStandardPaths> +#include <QThread> +#include <uibase/log.h> + +#include <csignal> +#include <sys/types.h> namespace { @@ -122,10 +127,56 @@ QString FluorineConfig::compatDataPath() const void FluorineConfig::destroyPrefix() const { const QString compatData = compatDataPath(); - if (!compatData.isEmpty()) { - QDir dir(compatData); - if (dir.exists()) { - dir.removeRecursively(); + if (compatData.isEmpty()) { + deleteConfig(); + return; + } + + // Kill any wine processes still bound to this prefix. Otherwise they hold + // file handles that keep the files around (still on disk even after unlink + // until the last fd is closed) and can prevent directory removal on some + // filesystems. + const QString cleanCompat = QDir::cleanPath(compatData); + const QString cleanPrefix = QDir::cleanPath(compatData + "/pfx"); + QDir procDir("/proc"); + const QStringList pids = + procDir.entryList({QStringLiteral("[0-9]*")}, QDir::Dirs); + QList<qint64> victims; + for (const QString& pid : pids) { + QFile envF("/proc/" + pid + "/environ"); + if (!envF.open(QIODevice::ReadOnly)) + continue; + const QByteArray environ = envF.readAll(); + for (const QByteArray& kv : environ.split('\0')) { + QString val; + if (kv.startsWith("WINEPREFIX=")) + val = QString::fromUtf8(kv.mid(11)); + else if (kv.startsWith("STEAM_COMPAT_DATA_PATH=")) + val = QString::fromUtf8(kv.mid(23)); + else + continue; + const QString clean = QDir::cleanPath(val); + if (clean == cleanCompat || clean == cleanPrefix) { + bool ok = false; + const qint64 p = pid.toLongLong(&ok); + if (ok) + victims.append(p); + break; + } + } + } + + for (qint64 p : victims) + ::kill(static_cast<pid_t>(p), SIGKILL); + if (!victims.isEmpty()) + QThread::msleep(200); + + QDir dir(compatData); + if (dir.exists()) { + if (!dir.removeRecursively()) { + MOBase::log::warn("destroyPrefix: failed to remove '{}' — files may be " + "locked by lingering processes", + compatData.toStdString()); } } diff --git a/src/src/instancemanagerdialog.cpp b/src/src/instancemanagerdialog.cpp index ba0c900..46966c5 100644 --- a/src/src/instancemanagerdialog.cpp +++ b/src/src/instancemanagerdialog.cpp @@ -948,11 +948,12 @@ void InstanceManagerDialog::downloadSLRIfNeeded() }
auto* progress = new QProgressDialog(
- tr("Downloading Steam Linux Runtime (~180 MB)...\n"
+ tr("Downloading Steam Linux Runtime (~200 MB)...\n"
"This only happens once. Check the MO2 log for details."),
tr("Cancel"), 0, 0, this); // 0,0 = indeterminate
progress->setWindowTitle(tr("Steam Linux Runtime"));
progress->setWindowModality(Qt::WindowModal);
+ progress->setAttribute(Qt::WA_ShowWithoutActivating);
progress->setMinimumDuration(0);
auto* cancelFlag = new int(0);
diff --git a/src/src/mainwindow.cpp b/src/src/mainwindow.cpp index 1ef6fe1..126f1cd 100644 --- a/src/src/mainwindow.cpp +++ b/src/src/mainwindow.cpp @@ -2370,11 +2370,12 @@ void MainWindow::on_startButton_clicked() }
if (useSLR && !isSlrInstalled()) {
auto* progress = new QProgressDialog(
- tr("Downloading Steam Linux Runtime (~180 MB)...\n"
+ tr("Downloading Steam Linux Runtime (~200 MB)...\n"
"This is required to launch games. Check the MO2 log for details."),
tr("Cancel"), 0, 0, this);
progress->setWindowTitle(tr("Steam Linux Runtime"));
progress->setWindowModality(Qt::WindowModal);
+ progress->setAttribute(Qt::WA_ShowWithoutActivating);
progress->setMinimumDuration(0);
int cancelFlag = 0;
diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp index 1ec2c87..e70f68f 100644 --- a/src/src/organizercore.cpp +++ b/src/src/organizercore.cpp @@ -2683,6 +2683,20 @@ void OrganizerCore::afterRun(const QFileInfo& binary, DWORD exitCode) log::warn("Failed to sync saves back from prefix '{}' to '{}'",
prefixPathStr, profileSavesDir);
}
+
+ // Remove the __MO_Saves symlinks and revert the prefix INI so a
+ // vanilla launch outside MO2 uses the default Saves dir.
+ prefix.undeployProfileSaves(absoluteSaveDir);
+ const QStringList iniFiles = managedGame()->iniFiles();
+ if (!iniFiles.isEmpty()) {
+ const QString prefixIni =
+ QDir(resolvePrefixGameDocumentsDir(prefix, dataDirName))
+ .filePath(QFileInfo(iniFiles.first()).fileName());
+ MOBase::WriteRegistryValue("General", "sLocalSavePath",
+ "Saves\\", prefixIni);
+ log::debug("Restored prefix INI '{}': sLocalSavePath=Saves\\",
+ prefixIni);
+ }
}
if (m_CurrentProfile->localSettingsEnabled()) {
diff --git a/src/src/prefixsetupdialog.cpp b/src/src/prefixsetupdialog.cpp index 6395af0..781284f 100644 --- a/src/src/prefixsetupdialog.cpp +++ b/src/src/prefixsetupdialog.cpp @@ -32,6 +32,10 @@ PrefixSetupDialog::PrefixSetupDialog(const QString& prefixPath, setMinimumSize(700, 500); resize(800, 600); setModal(true); + // Don't steal focus from other applications when the window manager pops + // this on top — users frequently leave the prefix setup running in the + // background while doing other work. + setAttribute(Qt::WA_ShowWithoutActivating); buildUI(); diff --git a/src/src/prefixsetuprunner.cpp b/src/src/prefixsetuprunner.cpp index 53ab0bd..8543437 100644 --- a/src/src/prefixsetuprunner.cpp +++ b/src/src/prefixsetuprunner.cpp @@ -25,6 +25,9 @@ #include <log.h> +#include <csignal> +#include <sys/types.h> + // ============================================================================ // Constants // ============================================================================ @@ -784,6 +787,12 @@ bool PrefixSetupRunner::stepProtonInit() return false; } + // Kill any stale wineboot/wineserver/pv-adverb processes bound to this + // prefix. If a previous run was aborted mid-init, those processes hold + // registry/filesystem locks and any new wineboot -u deadlocks waiting + // for them. Nothing cleans them up automatically. + killStalePrefixProcesses(); + const QString steamPath = detectSteamPath(); // The compatdata path is the PARENT of the pfx directory. @@ -798,6 +807,13 @@ bool PrefixSetupRunner::stepProtonInit() env["DISPLAY"] = ""; env["WAYLAND_DISPLAY"] = ""; env["WINEDLLOVERRIDES"] = "msdia80.dll=n;conhost.exe=d;cmd.exe=d"; + // ntsync on kernel 7.0+ can deadlock wineboot -u during prefix init + // under Proton 11 (wineboot blocks in ntsync_char_ioctl forever). Force + // the older fsync/esync fallback for the init phase — once the prefix + // is created, regular game launches can use whatever sync they want. + env["WINE_DISABLE_FAST_SYNC"] = "1"; + env["PROTON_NO_NTSYNC"] = "1"; + env["WINENTSYNC"] = "0"; emit logMessage("Initializing Wine prefix with Proton..."); @@ -1726,6 +1742,86 @@ QString PrefixSetupRunner::fluorineBinDir() const return fluorineDataDir() + "/bin"; } +void PrefixSetupRunner::killStalePrefixProcesses() const +{ + if (m_prefixPath.isEmpty()) + return; + + const QString cleanPrefix = QDir::cleanPath(m_prefixPath); + const QString cleanCompat = QDir::cleanPath(QDir(m_prefixPath).filePath("..")); + + QDir procDir("/proc"); + const QStringList pids = + procDir.entryList({QStringLiteral("[0-9]*")}, QDir::Dirs); + + QList<qint64> victims; + for (const QString& pid : pids) { + // Read cmdline (fast filter for wine-like processes). + QFile cmdF("/proc/" + pid + "/cmdline"); + if (!cmdF.open(QIODevice::ReadOnly)) + continue; + QByteArray cmdline = cmdF.readAll(); + if (cmdline.isEmpty()) + continue; + const QString cmdStr = QString::fromUtf8(cmdline.replace('\0', ' ')); + const bool wineLike = cmdStr.contains("wineboot") || + cmdStr.contains("wineserver") || + cmdStr.contains("pv-adverb") || + cmdStr.contains("wine-preloader") || + cmdStr.contains("steam.exe"); + if (!wineLike) + continue; + + // Definitive match: process's own WINEPREFIX env points at our prefix. + // Wine processes have Windows-style cmdlines ("c:\windows\...") that + // don't include the Linux prefix path, so cmdline-matching misses them. + bool mine = cmdStr.contains(cleanPrefix) || cmdStr.contains(cleanCompat); + if (!mine) { + QFile envF("/proc/" + pid + "/environ"); + if (envF.open(QIODevice::ReadOnly)) { + QByteArray environ = envF.readAll(); + for (const QByteArray& kv : environ.split('\0')) { + if (kv.startsWith("WINEPREFIX=")) { + const QString val = QString::fromUtf8(kv.mid(11)); + if (QDir::cleanPath(val) == cleanPrefix) + mine = true; + break; + } + if (kv.startsWith("STEAM_COMPAT_DATA_PATH=")) { + const QString val = QString::fromUtf8(kv.mid(23)); + if (QDir::cleanPath(val) == cleanCompat) + mine = true; + } + } + } + } + + if (mine) { + bool ok = false; + const qint64 p = pid.toLongLong(&ok); + if (ok) + victims.append(p); + } + } + + if (victims.isEmpty()) + return; + + MOBase::log::warn("Found {} stale wine process(es) bound to prefix — killing", + victims.size()); + for (qint64 p : victims) + ::kill(static_cast<pid_t>(p), SIGTERM); + + QThread::msleep(300); + + for (qint64 p : victims) { + if (::kill(static_cast<pid_t>(p), 0) == 0) + ::kill(static_cast<pid_t>(p), SIGKILL); + } + + QThread::msleep(100); +} + QString PrefixSetupRunner::fluorineCacheDir() const { return fluorineDataDir() + "/cache"; diff --git a/src/src/prefixsetuprunner.h b/src/src/prefixsetuprunner.h index 5ac71c8..05d21cc 100644 --- a/src/src/prefixsetuprunner.h +++ b/src/src/prefixsetuprunner.h @@ -130,6 +130,10 @@ private: QString fluorineTmpDir() const; QMap<QString, QString> baseWineEnv() const; + /// Kill any wineboot/wineserver/pv-adverb processes still bound to + /// m_prefixPath from a previous aborted run. Safe to call when none exist. + void killStalePrefixProcesses() const; + // -- state ----------------------------------------------------------------- QString m_prefixPath; QString m_protonPath; diff --git a/src/src/prefixsymlinks.cpp b/src/src/prefixsymlinks.cpp index 2195e3e..3ab9d17 100644 --- a/src/src/prefixsymlinks.cpp +++ b/src/src/prefixsymlinks.cpp @@ -1,9 +1,11 @@ #include "prefixsymlinks.h" #include "gamedetection.h" +#include "steamappinfo.h" #include <QDir> #include <QFile> #include <QFileInfo> +#include <algorithm> #include <uibase/log.h> #include <unistd.h> @@ -39,14 +41,20 @@ QString findPrefixUsername(const QString& usersDir) return QStringLiteral("steamuser"); } -/// Create a symlink if the path doesn't already exist (or is already correct). -bool createSymlinkIfNeeded(const QString& linkPath, const QString& target) +/// Create a symlink. If a stale symlink already points elsewhere and +/// `replaceStale` is true, replace it. Never clobbers a real directory. +bool createSymlinkIfNeeded(const QString& linkPath, const QString& target, + bool replaceStale) { QFileInfo fi(linkPath); - if (fi.exists() || fi.isSymLink()) { - if (fi.isSymLink() && fi.symLinkTarget() == target) + if (fi.isSymLink()) { + if (fi.symLinkTarget() == target) return true; - return false; // something else exists + if (!replaceStale) + return false; + QFile::remove(linkPath); + } else if (fi.exists()) { + return false; // real directory — don't clobber } QDir().mkpath(QFileInfo(linkPath).absolutePath()); @@ -58,9 +66,11 @@ bool createSymlinkIfNeeded(const QString& linkPath, const QString& target) } /// Scan all subdirectories in gameBase and symlink them into nakBase. +/// `replaceStale` — when true, stale symlinks (e.g. left from a previous +/// run pointing to a lower-priority prefix) get overwritten. int scanAndLinkAll(const QString& nakBase, const QString& gameBase, const QString& label, const QString& gameName, - bool skipMyGames = false) + bool skipMyGames = false, bool replaceStale = false) { QDir dir(gameBase); if (!dir.exists()) @@ -76,7 +86,7 @@ int scanAndLinkAll(const QString& nakBase, const QString& gameBase, const QString linkPath = nakBase + "/" + folder; const QString target = gameBase + "/" + folder; - if (createSymlinkIfNeeded(linkPath, target)) { + if (createSymlinkIfNeeded(linkPath, target, replaceStale)) { MOBase::log::info("Linked {}/{} -> {} ({})", label, folder, target, gameName); ++count; } @@ -114,8 +124,51 @@ void createGameSymlinksAuto(const QString& prefixPath) QDir().mkpath(appdataLocal); QDir().mkpath(appdataRoaming); + // Sort detected games so that actual Games win over Tools/Editors when + // two prefixes share a folder name (e.g. Skyrim SE vs Creation Kit both + // have "My Games/Skyrim Special Edition"). Without this, scanAndLinkAll + // would pick whichever appeared first and shadow the real game. + // Locate Steam install (mirrors the path list in findSteamInstallations()). + QString steamPath; + { + const QString home = QDir::homePath(); + static const char* PATHS[] = { + ".local/share/Steam", ".steam/debian-installation", ".steam/steam", + ".var/app/com.valvesoftware.Steam/data/Steam", + ".var/app/com.valvesoftware.Steam/.local/share/Steam", + "snap/steam/common/.local/share/Steam", + }; + for (const char* rel : PATHS) { + const QString full = QDir(home).filePath(QString::fromLatin1(rel)); + if (QFileInfo::exists(full + "/appcache/appinfo.vdf")) { + steamPath = full; + break; + } + } + } + const QHash<quint32, SteamAppInfo>& appInfo = + steamPath.isEmpty() ? QHash<quint32, SteamAppInfo>{} + : loadSteamAppInfo(steamPath); + + auto appType = [&appInfo](const QString& appIdStr) -> QString { + bool ok = false; + const quint32 id = appIdStr.toUInt(&ok); + if (!ok) + return {}; + const auto it = appInfo.constFind(id); + return it == appInfo.constEnd() ? QString{} : it->type; + }; + + std::vector<DetectedGame> ranked(result.games.begin(), result.games.end()); + std::stable_sort(ranked.begin(), ranked.end(), + [&](const DetectedGame& a, const DetectedGame& b) { + return steamAppTypeRank(appType(a.app_id)) + < steamAppTypeRank(appType(b.app_id)); + }); + int linked = 0; - for (const DetectedGame& game : result.games) { + bool first = true; + for (const DetectedGame& game : ranked) { if (game.prefix_path.isEmpty()) continue; @@ -123,14 +176,24 @@ void createGameSymlinksAuto(const QString& prefixPath) const QString gameUsername = findPrefixUsername(gameUsersDir); const QString gameUserDir = gameUsersDir + "/" + gameUsername; + // Only the highest-ranked candidate is allowed to replace stale symlinks + // from previous runs. Lower-ranked tools/demos must not overwrite real + // games' links even if they happen to share a folder name. + const bool replaceStale = first; + first = false; + linked += scanAndLinkAll(myGames, gameUserDir + "/Documents/My Games", - QStringLiteral("Documents/My Games"), game.name); + QStringLiteral("Documents/My Games"), game.name, + false, replaceStale); linked += scanAndLinkAll(documents, gameUserDir + "/Documents", - QStringLiteral("Documents"), game.name, true); + QStringLiteral("Documents"), game.name, true, + replaceStale); linked += scanAndLinkAll(appdataLocal, gameUserDir + "/AppData/Local", - QStringLiteral("AppData/Local"), game.name); + QStringLiteral("AppData/Local"), game.name, + false, replaceStale); linked += scanAndLinkAll(appdataRoaming, gameUserDir + "/AppData/Roaming", - QStringLiteral("AppData/Roaming"), game.name); + QStringLiteral("AppData/Roaming"), game.name, + false, replaceStale); } if (linked > 0) diff --git a/src/src/protonlauncher.cpp b/src/src/protonlauncher.cpp index 976eed7..458d20f 100644 --- a/src/src/protonlauncher.cpp +++ b/src/src/protonlauncher.cpp @@ -480,7 +480,24 @@ bool ProtonLauncher::launchWithProton(qint64& pid) const slrArgs << QStringLiteral("--filesystem=%1").arg(protonDir); } - slrArgs << "--" << protonScript << protonArgs; + // Expose dedicated xrandr dir so container sees our injected xrandr + // (steamrt4 ships without it, required by Proton-GE protonfixes). + // Pressure-vessel forces PATH=/usr/bin:/bin inside the container and + // ignores host PATH, so we prepend via `env PATH=...` as the command. + QStringList containerCmd; + { + const QString xrandrDir = + QDir::homePath() + "/.local/share/fluorine/steamrt/xrandr-bin"; + if (QDir(xrandrDir).exists()) { + slrArgs << QStringLiteral("--filesystem=%1").arg(xrandrDir); + containerCmd << QStringLiteral("/usr/bin/env") + << QStringLiteral("PATH=%1:/usr/bin:/bin").arg(xrandrDir); + } + } + containerCmd << protonScript << protonArgs; + + slrArgs << "--"; + slrArgs.append(containerCmd); wrapProgram(m_wrapperCommands, runScript, slrArgs, program, arguments); } else { MOBase::log::warn("SLR enabled but run script not found — launching without SLR"); @@ -494,6 +511,16 @@ bool ProtonLauncher::launchWithProton(qint64& pid) const env.remove("PYTHONHOME"); cleanAppImageEnv(env); + // Prepend fluorine's bin dir to PATH so the container sees our injected + // xrandr (steamrt4 ships without it; Proton-GE protonfixes require it). + { + const QString fluorineBin = + QDir::homePath() + "/.local/share/fluorine/bin"; + const QString existing = env.value("PATH"); + env.insert("PATH", existing.isEmpty() ? fluorineBin + : fluorineBin + ":" + existing); + } + if (!m_prefixPath.isEmpty()) { env.insert("WINEPREFIX", m_prefixPath); } diff --git a/src/src/settingsdialogproton.cpp b/src/src/settingsdialogproton.cpp index 5217092..896bcb9 100644 --- a/src/src/settingsdialogproton.cpp +++ b/src/src/settingsdialogproton.cpp @@ -291,11 +291,12 @@ void ProtonSettingsTab::onDownloadSLR() ui->downloadSLRButton->setEnabled(false); auto* progress = new QProgressDialog( - tr("Downloading Steam Linux Runtime (~180 MB)...\n" + tr("Downloading Steam Linux Runtime (~200 MB)...\n" "Check the MO2 log for progress details."), tr("Cancel"), 0, 0, parentWidget()); progress->setWindowTitle(tr("Steam Linux Runtime")); progress->setWindowModality(Qt::WindowModal); + progress->setAttribute(Qt::WA_ShowWithoutActivating); progress->setMinimumDuration(0); auto* cancelFlag = new int(0); diff --git a/src/src/slrmanager.cpp b/src/src/slrmanager.cpp index 94e4cbf..d2415a0 100644 --- a/src/src/slrmanager.cpp +++ b/src/src/slrmanager.cpp @@ -16,6 +16,13 @@ const char* BASE_URL = "https://repo.steampowered.com/steamrt4/images/latest const char* ARCHIVE_NAME = "SteamLinuxRuntime_4.tar.xz"; const char* EXTRACTED_DIR = "SteamLinuxRuntime_4"; +// steamrt4 (Debian bookworm-based) ships without xrandr, which Proton-GE +// and some protonfixes require at launch. We inject it from the Debian +// x11-xserver-utils package. +const char* XRANDR_DEB_URL = + "http://ftp.debian.org/debian/pool/main/x/x11-xserver-utils/" + "x11-xserver-utils_7.7+11_amd64.deb"; + QString slrInstallDir() { return QDir::homePath() + "/.local/share/fluorine/steamrt"; @@ -173,7 +180,59 @@ QString downloadSlr(const std::function<void(float)>& progressCb, if (!QFileInfo::exists(slrRunScriptPath())) return QStringLiteral("Extraction succeeded but run script not found"); - // 4. Save BUILD_ID. + // 4. Inject xrandr into the container (steamrt4 ships without it, but + // Proton-GE and several protonfixes invoke xrandr during launch). + { + status(QStringLiteral("Injecting xrandr into runtime...")); + const QString debPath = installDir + "/x11-xserver-utils.deb"; + httpGet(QString::fromLatin1(XRANDR_DEB_URL), cancelFlag, nullptr, debPath); + if (!QFileInfo::exists(debPath)) { + MOBase::log::warn("Failed to download xrandr .deb — runtime will lack xrandr"); + } else { + const QString tmpExtract = installDir + "/xrandr_tmp"; + QDir(tmpExtract).removeRecursively(); + QDir().mkpath(tmpExtract); + + QProcess ar; + ar.setWorkingDirectory(tmpExtract); + ar.start(QStringLiteral("ar"), {QStringLiteral("x"), debPath, QStringLiteral("data.tar.xz")}); + ar.waitForFinished(30000); + + QProcess untar; + untar.setWorkingDirectory(tmpExtract); + untar.start(QStringLiteral("tar"), + {QStringLiteral("xf"), QStringLiteral("data.tar.xz"), + QStringLiteral("./usr/bin/xrandr")}); + untar.waitForFinished(30000); + + const QString xrandrSrc = tmpExtract + "/usr/bin/xrandr"; + if (QFileInfo::exists(xrandrSrc)) { + // Place xrandr in a dedicated dir that Fluorine's installer never + // touches (the fluorine/bin dir gets overwritten on every install). + // Launchers expose this dir via --filesystem and prepend it to PATH. + const QString xrandrDir = installDir + "/xrandr-bin"; + QDir().mkpath(xrandrDir); + const QString dst = xrandrDir + "/xrandr"; + QFile::remove(dst); + if (QFile::copy(xrandrSrc, dst)) { + QFile::setPermissions(dst, QFileDevice::ReadOwner | QFileDevice::WriteOwner | + QFileDevice::ExeOwner | QFileDevice::ReadGroup | + QFileDevice::ExeGroup | QFileDevice::ReadOther | + QFileDevice::ExeOther); + MOBase::log::info("Installed xrandr to {}", dst.toStdString()); + } else { + MOBase::log::warn("Failed to copy xrandr into fluorine bin dir"); + } + } else { + MOBase::log::warn("xrandr .deb extracted but binary not found"); + } + + QDir(tmpExtract).removeRecursively(); + QFile::remove(debPath); + } + } + + // 5. Save BUILD_ID. { QFile f(localBuildIdPath()); if (f.open(QIODevice::WriteOnly)) diff --git a/src/src/steamappinfo.cpp b/src/src/steamappinfo.cpp new file mode 100644 index 0000000..b335358 --- /dev/null +++ b/src/src/steamappinfo.cpp @@ -0,0 +1,64 @@ +#include "steamappinfo.h" + +#include <QFileInfo> +#include <steam_appinfo_ffi.h> +#include <uibase/log.h> + +namespace { + +QHash<quint32, SteamAppInfo> g_cache; +bool g_loaded = false; + +extern "C" void appinfoCallback(void* user, uint32_t appid, + const char* type_, const char* name) +{ + auto* map = static_cast<QHash<quint32, SteamAppInfo>*>(user); + SteamAppInfo info; + info.appid = appid; + info.type = QString::fromUtf8(type_ ? type_ : ""); + info.name = QString::fromUtf8(name ? name : ""); + map->insert(appid, info); +} + +} // namespace + +const QHash<quint32, SteamAppInfo>& loadSteamAppInfo(const QString& steamPath) +{ + if (g_loaded) + return g_cache; + g_loaded = true; + + const QString appinfoPath = steamPath + "/appcache/appinfo.vdf"; + if (!QFileInfo::exists(appinfoPath)) { + MOBase::log::debug("steamappinfo: {} not found", appinfoPath.toStdString()); + return g_cache; + } + + const QByteArray pathUtf8 = appinfoPath.toUtf8(); + const int rc = steam_appinfo_parse(pathUtf8.constData(), &g_cache, + appinfoCallback); + if (rc != 0) { + MOBase::log::warn("steamappinfo: parse failed (rc={})", rc); + g_cache.clear(); + return g_cache; + } + + MOBase::log::info("steamappinfo: loaded {} entries", g_cache.size()); + return g_cache; +} + +int steamAppTypeRank(const QString& type) +{ + const QString t = type.toLower(); + if (t == "game") + return 0; + if (t == "application") + return 1; + if (t.isEmpty()) + return 2; // unknown — better to try than skip + if (t == "demo") + return 3; + if (t == "tool" || t == "config" || t == "driver") + return 4; + return 2; +} diff --git a/src/src/steamappinfo.h b/src/src/steamappinfo.h new file mode 100644 index 0000000..d67ebf5 --- /dev/null +++ b/src/src/steamappinfo.h @@ -0,0 +1,26 @@ +#ifndef STEAMAPPINFO_H +#define STEAMAPPINFO_H + +#include <QHash> +#include <QString> + +struct SteamAppInfo { + quint32 appid = 0; + QString type; // "Game", "Tool", "Application", "Demo", "Music", "Config", ... + QString name; +}; + +/// Parse ~/.steam/steam/appcache/appinfo.vdf (binary v29) and return a map of +/// appid -> SteamAppInfo with just the fields we need (common.type, common.name). +/// Parses lazily on first call and caches in-memory for the process lifetime. +/// Returns an empty hash on any parse/IO failure — callers should treat missing +/// entries as "unknown type". +const QHash<quint32, SteamAppInfo>& loadSteamAppInfo(const QString& steamPath); + +/// Rank for sorting candidate prefixes when multiple games have the same +/// My Games subfolder: managed-game's own appid wins, then Games, then +/// Applications, then unknown, then Tools/Demos/Config last. +/// Lower = higher priority. +int steamAppTypeRank(const QString& type); + +#endif // STEAMAPPINFO_H diff --git a/src/src/steamdetection.cpp b/src/src/steamdetection.cpp index 0d97ec0..f4622b2 100644 --- a/src/src/steamdetection.cpp +++ b/src/src/steamdetection.cpp @@ -152,9 +152,19 @@ QVector<SteamProtonInfo> findSteamProtons() QVector<SteamProtonInfo> builtin; scanProtonDir(steamPath + "/steamapps/common", true, builtin); // Keep only entries whose folder name starts with "Proton". + // Proton 11 is blacklisted: its current Wine tree deadlocks during + // wineboot -u on modern kernels (ntsync / futex_wait_multiple races), + // causing prefix init to hang indefinitely. Users should fall back to + // Proton 10 / Proton-Experimental / GE-Proton until Proton 11 stabilizes. for (auto& p : builtin) { - if (p.name.startsWith(QStringLiteral("Proton"))) - protons.append(std::move(p)); + if (!p.name.startsWith(QStringLiteral("Proton"))) + continue; + if (p.name.startsWith(QStringLiteral("Proton 11"))) { + MOBase::log::warn("Skipping '{}' — known-broken on Linux, use Proton 10 or GE-Proton", + p.name.toStdString()); + continue; + } + protons.append(std::move(p)); } } diff --git a/src/src/wineprefix.cpp b/src/src/wineprefix.cpp index 5003639..c320807 100644 --- a/src/src/wineprefix.cpp +++ b/src/src/wineprefix.cpp @@ -334,60 +334,84 @@ bool WinePrefix::deployProfileIni(const QString& sourceIniPath, bool WinePrefix::deployProfileSaves(const QString& profileSaveDir, const QString& absoluteSaveDir, - bool clearDestination) const + bool /*clearDestination*/) const { if (!isValid()) { MOBase::log::error("deployProfileSaves: prefix '{}' is not valid", m_prefixPath); return false; } - MOBase::log::debug("deployProfileSaves: profileSaveDir='{}', absoluteSaveDir='{}', " - "clearDestination={}", - profileSaveDir, absoluteSaveDir, clearDestination); + MOBase::log::debug("deployProfileSaves: profileSaveDir='{}', absoluteSaveDir='{}'", + profileSaveDir, absoluteSaveDir); + + // Ensure the profile saves dir exists — the game will write into it + // directly via the symlink. + if (!QDir().mkpath(profileSaveDir)) { + MOBase::log::error("deployProfileSaves: cannot create profile saves dir '{}'", + profileSaveDir); + return false; + } const QFileInfo saveDirInfo(absoluteSaveDir); const QString parentDir = saveDirInfo.dir().absolutePath(); const QString leafName = saveDirInfo.fileName(); - const QString backupUpper = - QDir(parentDir).filePath(".mo2linux_backup_" + leafName); - const QString backupLower = - QDir(parentDir).filePath(".mo2linux_backup_" + leafName.toLower()); const QString lowerSaveDir = QDir(parentDir).filePath(leafName.toLower()); - if (clearDestination) { - // Recover from any stale backup left by an interrupted run. - if ((QDir(backupUpper).exists() || QDir(backupLower).exists()) && - !restoreBackedUpSaves(absoluteSaveDir, lowerSaveDir, - backupUpper, backupLower)) { - MOBase::log::warn("deployProfileSaves: failed to restore stale backup"); - return false; - } + if (!QDir().mkpath(parentDir)) { + return false; + } - if (QDir(absoluteSaveDir).exists() && - !QDir().rename(absoluteSaveDir, backupUpper)) { - MOBase::log::warn("deployProfileSaves: failed to backup '{}' -> '{}'", - absoluteSaveDir, backupUpper); - return false; + // Symlink both the proper-case and lowercase save dirs straight to the + // profile saves dir. Writes land in the profile immediately — no copy-in + // / copy-out dance, crash-safe, and profile switches only swap the link. + auto relink = [&profileSaveDir](const QString& linkPath) -> bool { + QFileInfo fi(linkPath); + if (fi.isSymLink()) { + QFile::remove(linkPath); + } else if (QDir(linkPath).exists()) { + // Existing real directory from a pre-symlink install. Preserve any + // contents by copying into the profile, then remove so we can link. + copyTreeContents(linkPath, profileSaveDir); + QDir(linkPath).removeRecursively(); + } else if (fi.exists()) { + QFile::remove(linkPath); } - if (absoluteSaveDir != lowerSaveDir && - QDir(lowerSaveDir).exists() && - !QDir().rename(lowerSaveDir, backupLower)) { - MOBase::log::warn("deployProfileSaves: failed to backup '{}' -> '{}'", - lowerSaveDir, backupLower); + if (!QFile::link(profileSaveDir, linkPath)) { + MOBase::log::warn("deployProfileSaves: failed to symlink '{}' -> '{}'", + linkPath, profileSaveDir); return false; } - } + return true; + }; - if (!QDir().mkpath(absoluteSaveDir)) { + if (!relink(absoluteSaveDir)) + return false; + if (absoluteSaveDir != lowerSaveDir && !relink(lowerSaveDir)) return false; - } - if (!QDir(profileSaveDir).exists()) { - return true; - } + return true; +} - return copyTreeContents(profileSaveDir, absoluteSaveDir); +void WinePrefix::undeployProfileSaves(const QString& absoluteSaveDir) const +{ + if (!isValid()) + return; + + const QFileInfo saveDirInfo(absoluteSaveDir); + const QString parentDir = saveDirInfo.dir().absolutePath(); + const QString leafName = saveDirInfo.fileName(); + const QString lowerSaveDir = + QDir(parentDir).filePath(leafName.toLower()); + + auto unlinkIfSymlink = [](const QString& path) { + if (QFileInfo(path).isSymLink()) { + QFile::remove(path); + } + }; + unlinkIfSymlink(absoluteSaveDir); + if (absoluteSaveDir != lowerSaveDir) + unlinkIfSymlink(lowerSaveDir); } bool WinePrefix::syncSavesBack(const QString& profileSaveDir, @@ -401,6 +425,13 @@ bool WinePrefix::syncSavesBack(const QString& profileSaveDir, MOBase::log::debug("syncSavesBack: profileSaveDir='{}', absoluteSaveDir='{}'", profileSaveDir, absoluteSaveDir); + // With the symlink-based deploy, writes already land in the profile — no + // sync needed. Fall through to the legacy copy path only for pre-existing + // installs where the save dir is still a real directory. + if (QFileInfo(absoluteSaveDir).isSymLink()) { + return true; + } + const QFileInfo saveDirInfo(absoluteSaveDir); const QString parentDir = saveDirInfo.dir().absolutePath(); const QString leafName = saveDirInfo.fileName(); diff --git a/src/src/wineprefix.h b/src/src/wineprefix.h index c817f2e..fd88043 100644 --- a/src/src/wineprefix.h +++ b/src/src/wineprefix.h @@ -42,6 +42,13 @@ public: // propagate. bool syncSavesBack(const QString& profileSaveDir, const QString& absoluteSaveDir) const; + + /// Remove the __MO_Saves symlink(s) deployed by deployProfileSaves so a + /// vanilla game launch outside MO2 uses the prefix's default Saves dir. + /// Only removes entries that are actually symlinks — real directories + /// from a pre-symlink install are left alone. + void undeployProfileSaves(const QString& absoluteSaveDir) const; + bool syncProfileInisBack( const QList<QPair<QString, QString>>& iniMappings) const; |
