aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/src/CMakeLists.txt3
-rw-r--r--src/src/fluorineconfig.cpp59
-rw-r--r--src/src/instancemanagerdialog.cpp3
-rw-r--r--src/src/mainwindow.cpp3
-rw-r--r--src/src/organizercore.cpp14
-rw-r--r--src/src/prefixsetupdialog.cpp4
-rw-r--r--src/src/prefixsetuprunner.cpp96
-rw-r--r--src/src/prefixsetuprunner.h4
-rw-r--r--src/src/prefixsymlinks.cpp87
-rw-r--r--src/src/protonlauncher.cpp29
-rw-r--r--src/src/settingsdialogproton.cpp3
-rw-r--r--src/src/slrmanager.cpp61
-rw-r--r--src/src/steamappinfo.cpp64
-rw-r--r--src/src/steamappinfo.h26
-rw-r--r--src/src/steamdetection.cpp14
-rw-r--r--src/src/wineprefix.cpp97
-rw-r--r--src/src/wineprefix.h7
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;