aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/src/CMakeLists.txt11
-rw-r--r--src/src/fuseconnector.cpp5
-rw-r--r--src/src/gamedetection.cpp530
-rw-r--r--src/src/gamedetection.h33
-rw-r--r--src/src/iconextractor.cpp276
-rw-r--r--src/src/iconextractor.h11
-rw-r--r--src/src/instancemanagerdialog.cpp26
-rw-r--r--src/src/knowngames.h194
-rw-r--r--src/src/main.cpp18
-rw-r--r--src/src/mainwindow.cpp22
-rw-r--r--src/src/organizercore.cpp1
-rw-r--r--src/src/prefixsetuprunner.cpp47
-rw-r--r--src/src/prefixsymlinks.cpp143
-rw-r--r--src/src/prefixsymlinks.h12
-rw-r--r--src/src/protonlauncher.cpp23
-rw-r--r--src/src/selfupdater.cpp18
-rw-r--r--src/src/settingsdialogproton.cpp214
-rw-r--r--src/src/slrmanager.cpp186
-rw-r--r--src/src/slrmanager.h21
-rw-r--r--src/src/steamdetection.cpp195
-rw-r--r--src/src/steamdetection.h32
-rw-r--r--src/src/vdfparser.cpp146
-rw-r--r--src/src/vdfparser.h69
-rw-r--r--src/src/vfs/mo2filesystem.cpp107
-rw-r--r--src/src/vfs/mo2filesystem.h4
-rw-r--r--src/src/vfs/vfstree.h1
26 files changed, 2146 insertions, 199 deletions
diff --git a/src/src/CMakeLists.txt b/src/src/CMakeLists.txt
index ffa4902..d618af7 100644
--- a/src/src/CMakeLists.txt
+++ b/src/src/CMakeLists.txt
@@ -19,6 +19,15 @@ file(GLOB ORGANIZER_SOURCES
${CMAKE_CURRENT_SOURCE_DIR}/*.cpp
${CMAKE_CURRENT_SOURCE_DIR}/shared/*.cpp
${CMAKE_CURRENT_SOURCE_DIR}/vfs/*.cpp)
+
+# These files are compiled into libuibase.so (shared with plugins) — exclude from organizer.
+list(REMOVE_ITEM ORGANIZER_SOURCES
+ ${CMAKE_CURRENT_SOURCE_DIR}/gamedetection.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/steamdetection.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/vdfparser.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/iconextractor.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/prefixsymlinks.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/slrmanager.cpp)
file(GLOB ORGANIZER_HEADERS
${CMAKE_CURRENT_SOURCE_DIR}/*.h
${CMAKE_CURRENT_SOURCE_DIR}/shared/*.h
@@ -101,8 +110,6 @@ target_link_libraries(organizer PRIVATE
mo2::bsatk
mo2::esptk
mo2::lootcli-header
- # NaK FFI for game detection + Proton
- mo2::nak_ffi
# Qt6
Qt6::Widgets
Qt6::Concurrent
diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp
index 4d84164..d890613 100644
--- a/src/src/fuseconnector.cpp
+++ b/src/src/fuseconnector.cpp
@@ -176,7 +176,8 @@ void setupFuseOps(struct fuse_lowlevel_ops* ops)
ops->mkdir = mo2_mkdir;
ops->release = mo2_release;
ops->releasedir = mo2_releasedir;
- ops->access = mo2_access;
+ // access handler removed: default_permissions mount option lets the kernel
+ // handle permission checks in-kernel, eliminating access() round-trips.
}
} // namespace
@@ -311,7 +312,7 @@ bool FuseConnector::mount(
// separately to fuse_session_mount(). Including it here causes
// "fuse: unknown option(s)" error.
std::vector<std::string> argvStorage = {
- "mo2fuse", "-o", "fsname=mo2linux", "-o", "noatime"};
+ "mo2fuse", "-o", "fsname=mo2linux", "-o", "noatime", "-o", "default_permissions"};
std::vector<char*> argv;
argv.reserve(argvStorage.size());
diff --git a/src/src/gamedetection.cpp b/src/src/gamedetection.cpp
new file mode 100644
index 0000000..60c06dc
--- /dev/null
+++ b/src/src/gamedetection.cpp
@@ -0,0 +1,530 @@
+#include "gamedetection.h"
+#include "knowngames.h"
+#include "vdfparser.h"
+
+#include <QDir>
+#include <QFile>
+#include <QFileInfo>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QJsonArray>
+#include <QSet>
+#include <uibase/log.h>
+#include <optional>
+
+// ============================================================================
+// Wine registry helpers
+// ============================================================================
+
+namespace {
+
+/// Parse a quoted registry value, handling Wine escape sequences.
+QString parseQuotedRegValue(const QString& s)
+{
+ if (!s.startsWith('"'))
+ return {};
+
+ QString result;
+ bool esc = false;
+ for (int i = 1; i < s.size(); ++i) {
+ QChar c = s[i];
+ if (esc) {
+ if (c == 'n') result += '\n';
+ else if (c == 'r') result += '\r';
+ else if (c == 't') result += '\t';
+ else if (c == '\\') result += '\\';
+ else if (c == '"') result += '"';
+ else { result += '\\'; result += c; }
+ esc = false;
+ } else if (c == '\\') {
+ esc = true;
+ } else if (c == '"') {
+ break;
+ } else {
+ result += c;
+ }
+ }
+ return result;
+}
+
+/// Parse a registry value line like "ValueName"="value".
+std::pair<QString, QString> parseRegValueLine(const QString& line)
+{
+ int eq = line.indexOf('=');
+ if (eq < 0)
+ return {};
+
+ QString namePart = line.left(eq).trimmed();
+ QString valPart = line.mid(eq + 1);
+
+ QString name = (namePart == QStringLiteral("@"))
+ ? QStringLiteral("@")
+ : namePart.mid(1, namePart.size() - 2); // strip quotes
+
+ QString value;
+ if (valPart.startsWith('"')) {
+ value = parseQuotedRegValue(valPart);
+ } else if (valPart.startsWith(QStringLiteral("dword:"))) {
+ bool ok;
+ uint v = valPart.mid(6).toUInt(&ok, 16);
+ value = ok ? QString::number(v) : valPart;
+ } else {
+ value = valPart;
+ }
+ return {name, value};
+}
+
+/// Find a value inside registry content for a given key section.
+QString findValueInContent(const QString& content, const QString& key,
+ const QString& valueName)
+{
+ const QString keyLower = key.toLower();
+ const QString valLower = valueName.toLower();
+ bool inTarget = false;
+
+ for (const auto& line : content.split('\n')) {
+ const QString trimmed = line.trimmed();
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
+ inTarget = (trimmed.toLower() == keyLower);
+ continue;
+ }
+ if (!inTarget) continue;
+ if (trimmed.isEmpty()) continue;
+ if (trimmed.startsWith('[')) break;
+
+ auto [name, value] = parseRegValueLine(trimmed);
+ if (name.toLower() == valLower)
+ return value;
+ }
+ return {};
+}
+
+/// Read a registry value from a Wine prefix.
+QString readRegistryValue(const QString& prefixPath, const QString& keyPath,
+ const QString& valueName)
+{
+ // Wine key format: lowercase, double-escaped backslashes.
+ auto wineKey = [](const QString& kp) {
+ return QStringLiteral("[%1]").arg(
+ kp.toLower().replace('\\', QStringLiteral("\\\\")));
+ };
+
+ // Wow6432Node variant.
+ auto wow64Key = [](const QString& kp) {
+ QString stripped = kp;
+ if (stripped.startsWith(QStringLiteral("Software\\"), Qt::CaseInsensitive))
+ stripped = stripped.mid(9);
+ return QStringLiteral("[software\\\\wow6432node\\\\%1]").arg(
+ stripped.toLower().replace('\\', QStringLiteral("\\\\")));
+ };
+
+ for (const char* regFile : {"system.reg", "user.reg"}) {
+ QFile f(QDir(prefixPath).filePath(QString::fromLatin1(regFile)));
+ if (!f.open(QIODevice::ReadOnly | QIODevice::Text))
+ continue;
+ const QString content = QString::fromUtf8(f.readAll());
+
+ QString val = findValueInContent(content, wineKey(keyPath), valueName);
+ if (!val.isEmpty()) return val;
+
+ val = findValueInContent(content, wow64Key(keyPath), valueName);
+ if (!val.isEmpty()) return val;
+ }
+ return {};
+}
+
+/// Convert a Wine Z: path to a Linux path.
+QString winePathToLinux(const QString& winePath)
+{
+ if (winePath.startsWith(QStringLiteral("Z:"), Qt::CaseInsensitive) ||
+ winePath.startsWith(QStringLiteral("z:"), Qt::CaseInsensitive))
+ return QString(winePath).mid(2).replace('\\', '/');
+ return {};
+}
+
+// ============================================================================
+// Steam game detection
+// ============================================================================
+
+struct SteamInstallation {
+ QString path;
+ bool is_flatpak = false;
+ bool is_snap = false;
+};
+
+QVector<SteamInstallation> findSteamInstallations()
+{
+ QVector<SteamInstallation> installs;
+ 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",
+ };
+
+ QSet<QString> seen;
+ for (const char* rel : PATHS) {
+ const QString full = QDir(home).filePath(QString::fromLatin1(rel));
+ if (!QFileInfo::exists(full + "/steamapps") &&
+ !QFileInfo::exists(full + "/steam.pid"))
+ continue;
+
+ const QString canonical = QFileInfo(full).canonicalFilePath();
+ if (seen.contains(canonical))
+ continue;
+ seen.insert(canonical);
+
+ SteamInstallation si;
+ si.path = full;
+ si.is_flatpak = QString::fromLatin1(rel).contains(
+ QStringLiteral(".var/app/com.valvesoftware.Steam"));
+ si.is_snap = QString::fromLatin1(rel).contains(QStringLiteral("snap/steam"));
+ installs.append(si);
+ }
+ return installs;
+}
+
+QStringList getLibraryFolders(const QString& steamPath)
+{
+ QStringList folders;
+ folders.append(steamPath);
+
+ for (const char* sub : {"steamapps/libraryfolders.vdf",
+ "config/libraryfolders.vdf"}) {
+ QFile f(QDir(steamPath).filePath(QString::fromLatin1(sub)));
+ if (!f.open(QIODevice::ReadOnly | QIODevice::Text))
+ continue;
+ for (const QString& p : parseLibraryFolders(QString::fromUtf8(f.readAll()))) {
+ if (QFileInfo::exists(p) && !folders.contains(p))
+ folders.append(p);
+ }
+ }
+ return folders;
+}
+
+QVector<DetectedGame> detectSteamGames()
+{
+ QVector<DetectedGame> games;
+
+ for (const auto& si : findSteamInstallations()) {
+ const QStringList libraries = getLibraryFolders(si.path);
+
+ // Collect all steamapps paths for cross-library compatdata search.
+ QStringList allSteamapps;
+ for (const QString& lib : libraries) {
+ const QString sa = lib + "/steamapps";
+ if (QFileInfo::exists(sa))
+ allSteamapps.append(sa);
+ }
+
+ for (const QString& steamapps : allSteamapps) {
+ QDir dir(steamapps);
+ const QStringList acfs =
+ dir.entryList({QStringLiteral("appmanifest_*.acf")}, QDir::Files);
+
+ for (const QString& acf : acfs) {
+ QFile f(dir.filePath(acf));
+ if (!f.open(QIODevice::ReadOnly | QIODevice::Text))
+ continue;
+
+ AppManifest m = AppManifest::fromVdf(QString::fromUtf8(f.readAll()));
+ if (m.app_id.isEmpty() || !m.isInstalled())
+ continue;
+
+ const QString installPath = steamapps + "/common/" + m.install_dir;
+ if (!QFileInfo::exists(installPath))
+ continue;
+
+ // Search all library folders for compatdata.
+ QString prefixPath;
+ for (const QString& sa : allSteamapps) {
+ const QString pfx = sa + "/compatdata/" + m.app_id + "/pfx";
+ if (QFileInfo::exists(pfx)) {
+ prefixPath = pfx;
+ break;
+ }
+ }
+
+ const KnownGame* kg = findKnownGameBySteamId(m.app_id);
+
+ DetectedGame g;
+ g.name = m.name;
+ g.app_id = m.app_id;
+ g.install_path = installPath;
+ g.prefix_path = prefixPath;
+ g.launcher = si.is_flatpak ? QStringLiteral("Steam (Flatpak)")
+ : si.is_snap ? QStringLiteral("Steam (Snap)")
+ : QStringLiteral("Steam");
+ if (kg) {
+ g.my_games_folder = kg->my_games_folder ? QString::fromLatin1(kg->my_games_folder) : QString();
+ g.appdata_local_folder = kg->appdata_local_folder ? QString::fromLatin1(kg->appdata_local_folder) : QString();
+ g.appdata_roaming_folder = kg->appdata_roaming_folder ? QString::fromLatin1(kg->appdata_roaming_folder) : QString();
+ g.registry_path = QString::fromLatin1(kg->registry_path);
+ g.registry_value = QString::fromLatin1(kg->registry_value);
+ }
+ games.append(g);
+ }
+ }
+ }
+
+ MOBase::log::info("Steam: Found {} installed games", games.size());
+ return games;
+}
+
+// ============================================================================
+// Heroic game detection (GOG + Epic)
+// ============================================================================
+
+/// Get the Wine prefix for a Heroic game from its GamesConfig JSON.
+QString getHeroicGamePrefix(const QString& heroicPath, const QString& appName)
+{
+ QFile f(heroicPath + "/GamesConfig/" + appName + ".json");
+ if (!f.open(QIODevice::ReadOnly))
+ return {};
+
+ QJsonDocument doc = QJsonDocument::fromJson(f.readAll());
+ QJsonObject root = doc.object();
+
+ // Try direct or nested under appName.
+ QString prefix;
+ if (root.contains(QStringLiteral("winePrefix"))) {
+ prefix = root[QStringLiteral("winePrefix")].toString();
+ } else if (root.contains(appName)) {
+ prefix = root[appName].toObject()[QStringLiteral("winePrefix")].toString();
+ }
+ return (!prefix.isEmpty() && QFileInfo::exists(prefix)) ? prefix : QString();
+}
+
+QVector<DetectedGame> detectHeroicGames()
+{
+ QVector<DetectedGame> games;
+ const QString home = QDir::homePath();
+
+ static const char* HEROIC_PATHS[] = {
+ ".config/heroic",
+ ".var/app/com.heroicgameslauncher.hgl/config/heroic",
+ };
+
+ for (const char* rel : HEROIC_PATHS) {
+ const QString heroicPath = QDir(home).filePath(QString::fromLatin1(rel));
+ if (!QFileInfo::exists(heroicPath))
+ continue;
+
+ // --- GOG ---
+ {
+ QFile f(heroicPath + "/gog_store/installed.json");
+ if (f.open(QIODevice::ReadOnly)) {
+ QJsonDocument doc = QJsonDocument::fromJson(f.readAll());
+
+ QJsonArray installed;
+ if (doc.isObject() && doc.object().contains(QStringLiteral("installed")))
+ installed = doc.object()[QStringLiteral("installed")].toArray();
+ else if (doc.isArray())
+ installed = doc.array();
+
+ for (const QJsonValue& v : installed) {
+ QJsonObject obj = v.toObject();
+ if (obj[QStringLiteral("platform")].toString() != QStringLiteral("windows"))
+ continue;
+
+ const QString appName = obj[QStringLiteral("appName")].toString();
+ const QString installPath = obj[QStringLiteral("install_path")].toString();
+ if (appName.isEmpty() || installPath.isEmpty() || !QFileInfo::exists(installPath))
+ continue;
+
+ const KnownGame* kg = findKnownGameByGogId(appName);
+
+ DetectedGame g;
+ g.name = obj[QStringLiteral("title")].toString(appName);
+ g.app_id = appName;
+ g.install_path = installPath;
+ g.prefix_path = getHeroicGamePrefix(heroicPath, appName);
+ g.launcher = QStringLiteral("Heroic (GOG)");
+ if (kg) {
+ g.my_games_folder = kg->my_games_folder ? QString::fromLatin1(kg->my_games_folder) : QString();
+ g.appdata_local_folder = kg->appdata_local_folder ? QString::fromLatin1(kg->appdata_local_folder) : QString();
+ g.appdata_roaming_folder = kg->appdata_roaming_folder ? QString::fromLatin1(kg->appdata_roaming_folder) : QString();
+ g.registry_path = QString::fromLatin1(kg->registry_path);
+ g.registry_value = QString::fromLatin1(kg->registry_value);
+ }
+ games.append(g);
+ }
+ }
+ }
+
+ // --- Epic ---
+ {
+ QString epicPath = heroicPath + "/store_cache/legendary_library.json";
+ if (!QFileInfo::exists(epicPath))
+ epicPath = heroicPath + "/legendaryConfig/legendary/installed.json";
+
+ QFile f(epicPath);
+ if (f.open(QIODevice::ReadOnly)) {
+ QJsonDocument doc = QJsonDocument::fromJson(f.readAll());
+ if (doc.isObject()) {
+ const QJsonObject obj = doc.object();
+ for (auto it = obj.begin(); it != obj.end(); ++it) {
+ const QString appName = it.key();
+ const QJsonObject gd = it.value().toObject();
+
+ if (!gd[QStringLiteral("is_installed")].toBool())
+ continue;
+
+ const QString platform = gd[QStringLiteral("platform")].toString();
+ if (platform.toLower() != QStringLiteral("windows"))
+ continue;
+
+ const QString installPath = gd[QStringLiteral("install_path")].toString();
+ if (installPath.isEmpty() || !QFileInfo::exists(installPath))
+ continue;
+
+ const QString title = gd[QStringLiteral("title")].toString(appName);
+ const KnownGame* kg =
+ findKnownGameByEpicId(appName);
+ if (!kg)
+ kg = findKnownGameByTitle(title);
+
+ DetectedGame g;
+ g.name = title;
+ g.app_id = appName;
+ g.install_path = installPath;
+ g.prefix_path = getHeroicGamePrefix(heroicPath, appName);
+ g.launcher = QStringLiteral("Heroic (Epic)");
+ if (kg) {
+ g.my_games_folder = kg->my_games_folder ? QString::fromLatin1(kg->my_games_folder) : QString();
+ g.appdata_local_folder = kg->appdata_local_folder ? QString::fromLatin1(kg->appdata_local_folder) : QString();
+ g.appdata_roaming_folder = kg->appdata_roaming_folder ? QString::fromLatin1(kg->appdata_roaming_folder) : QString();
+ g.registry_path = QString::fromLatin1(kg->registry_path);
+ g.registry_value = QString::fromLatin1(kg->registry_value);
+ }
+ games.append(g);
+ }
+ }
+ }
+ }
+ }
+
+ MOBase::log::info("Heroic: Found {} installed games", games.size());
+ return games;
+}
+
+// ============================================================================
+// Bottles game detection
+// ============================================================================
+
+QVector<DetectedGame> detectBottlesGames()
+{
+ QVector<DetectedGame> games;
+ const QString home = QDir::homePath();
+
+ static const char* BOTTLES_PATHS[] = {
+ ".local/share/bottles/bottles",
+ ".var/app/com.usebottles.bottles/data/bottles/bottles",
+ };
+
+ for (const char* rel : BOTTLES_PATHS) {
+ const QString bottlesPath = QDir(home).filePath(QString::fromLatin1(rel));
+ if (!QFileInfo::exists(bottlesPath))
+ continue;
+
+ QDir dir(bottlesPath);
+ for (const QString& bottleName :
+ dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) {
+ const QString bottlePath = dir.absoluteFilePath(bottleName);
+ const QString driveC = bottlePath + "/drive_c";
+ if (!QFileInfo::exists(driveC))
+ continue;
+
+ for (int i = 0; i < KNOWN_GAMES_COUNT; ++i) {
+ const KnownGame& kg = KNOWN_GAMES[i];
+ const QString val = readRegistryValue(
+ bottlePath, QString::fromLatin1(kg.registry_path),
+ QString::fromLatin1(kg.registry_value));
+ if (val.isEmpty())
+ continue;
+
+ QString installPath = winePathToLinux(val);
+ if (installPath.isEmpty()) {
+ // Fallback for C: paths.
+ if (val.startsWith(QStringLiteral("C:"), Qt::CaseInsensitive) ||
+ val.startsWith(QStringLiteral("c:"), Qt::CaseInsensitive)) {
+ QString relative = val.mid(2).replace('\\', '/');
+ if (relative.startsWith('/'))
+ relative = relative.mid(1);
+ installPath = driveC + "/" + relative;
+ } else {
+ continue;
+ }
+ }
+
+ if (!QFileInfo::exists(installPath))
+ continue;
+
+ DetectedGame g;
+ g.name = QString::fromLatin1(kg.name);
+ g.app_id = QStringLiteral("bottles-%1").arg(kg.steam_app_id);
+ g.install_path = installPath;
+ g.prefix_path = bottlePath;
+ g.launcher = QStringLiteral("Bottles");
+ g.my_games_folder = kg.my_games_folder ? QString::fromLatin1(kg.my_games_folder) : QString();
+ g.appdata_local_folder = kg.appdata_local_folder ? QString::fromLatin1(kg.appdata_local_folder) : QString();
+ g.appdata_roaming_folder = kg.appdata_roaming_folder ? QString::fromLatin1(kg.appdata_roaming_folder) : QString();
+ g.registry_path = QString::fromLatin1(kg.registry_path);
+ g.registry_value = QString::fromLatin1(kg.registry_value);
+ games.append(g);
+ }
+ }
+ }
+
+ MOBase::log::info("Bottles: Found {} installed games", games.size());
+ return games;
+}
+
+} // namespace
+
+// ============================================================================
+// Public API
+// ============================================================================
+
+GameScanResult detectAllGames()
+{
+ // Cache the result — game plugins call this once each during identifyGamePath().
+ static std::optional<GameScanResult> cache;
+ if (cache.has_value())
+ return *cache;
+
+ GameScanResult result;
+
+ auto steam = detectSteamGames();
+ result.steam_count = steam.size();
+ result.games.append(steam);
+
+ auto heroic = detectHeroicGames();
+ result.heroic_count = heroic.size();
+ result.games.append(heroic);
+
+ auto bottles = detectBottlesGames();
+ result.bottles_count = bottles.size();
+ result.games.append(bottles);
+
+ // Deduplicate by registry_path (keep first = highest priority).
+ QSet<QString> seen;
+ result.games.erase(
+ std::remove_if(result.games.begin(), result.games.end(),
+ [&seen](const DetectedGame& g) {
+ if (g.registry_path.isEmpty())
+ return false;
+ if (seen.contains(g.registry_path))
+ return true;
+ seen.insert(g.registry_path);
+ return false;
+ }),
+ result.games.end());
+
+ cache = result;
+ return result;
+}
diff --git a/src/src/gamedetection.h b/src/src/gamedetection.h
new file mode 100644
index 0000000..102b7b1
--- /dev/null
+++ b/src/src/gamedetection.h
@@ -0,0 +1,33 @@
+#ifndef GAMEDETECTION_H
+#define GAMEDETECTION_H
+
+#include <QString>
+#include <QStringList>
+#include <QVector>
+
+/// A detected game installation.
+struct DetectedGame {
+ QString name;
+ QString app_id;
+ QString install_path;
+ QString prefix_path; // empty if no prefix
+ QString launcher; // display string: "Steam", "Heroic (GOG)", etc.
+ QString my_games_folder; // empty if n/a
+ QString appdata_local_folder; // empty if n/a
+ QString appdata_roaming_folder; // empty if n/a
+ QString registry_path; // empty if n/a
+ QString registry_value; // empty if n/a
+};
+
+/// Results of a full game scan.
+struct GameScanResult {
+ QVector<DetectedGame> games;
+ int steam_count = 0;
+ int heroic_count = 0;
+ int bottles_count = 0;
+};
+
+/// Detect all installed games across Steam, Heroic (GOG + Epic), and Bottles.
+__attribute__((visibility("default"))) GameScanResult detectAllGames();
+
+#endif // GAMEDETECTION_H
diff --git a/src/src/iconextractor.cpp b/src/src/iconextractor.cpp
new file mode 100644
index 0000000..fb29742
--- /dev/null
+++ b/src/src/iconextractor.cpp
@@ -0,0 +1,276 @@
+#include "iconextractor.h"
+
+#include <QFile>
+#include <cstring>
+
+// Minimal PE resource parser to extract icons without external dependencies.
+// Supports both PE32 (32-bit) and PE32+ (64-bit) executables.
+
+namespace {
+
+// Little-endian reads.
+inline uint16_t r16(const char* p) { uint16_t v; memcpy(&v, p, 2); return v; }
+inline uint32_t r32(const char* p) { uint32_t v; memcpy(&v, p, 4); return v; }
+
+// PE resource types.
+constexpr uint32_t RT_ICON = 3;
+constexpr uint32_t RT_GROUP_ICON = 14;
+
+/// Resolve an RVA to a file offset using the section table.
+int64_t rvaToOffset(uint32_t rva, const char* sections, int numSections)
+{
+ for (int i = 0; i < numSections; ++i) {
+ const char* sec = sections + i * 40;
+ uint32_t va = r32(sec + 12);
+ uint32_t rawSz = r32(sec + 16);
+ uint32_t rawOff = r32(sec + 20);
+ if (rva >= va && rva < va + rawSz)
+ return rawOff + (rva - va);
+ }
+ return -1;
+}
+
+/// Represents one entry in the resource directory.
+struct ResDirEntry {
+ uint32_t nameOrId;
+ uint32_t offsetOrData;
+ bool isDir;
+ bool isNamedEntry;
+};
+
+/// Parse resource directory entries.
+QVector<ResDirEntry> parseResDir(const char* base, uint32_t dirOffset)
+{
+ QVector<ResDirEntry> entries;
+ const char* dir = base + dirOffset;
+ uint16_t numNamed = r16(dir + 12);
+ uint16_t numId = r16(dir + 14);
+ int count = numNamed + numId;
+
+ for (int i = 0; i < count; ++i) {
+ const char* e = dir + 16 + i * 8;
+ ResDirEntry re;
+ re.nameOrId = r32(e);
+ re.offsetOrData = r32(e + 4);
+ re.isDir = (re.offsetOrData & 0x80000000u) != 0;
+ re.isNamedEntry = (re.nameOrId & 0x80000000u) != 0;
+ re.offsetOrData &= 0x7FFFFFFFu;
+ entries.append(re);
+ }
+ return entries;
+}
+
+/// Get the data entry (leaf node) bytes.
+QByteArray getResourceData(const char* resBase, uint32_t dataEntryOffset,
+ const char* fileData, int64_t fileSize,
+ const char* sections, int numSections)
+{
+ const char* de = resBase + dataEntryOffset;
+ uint32_t dataRva = r32(de);
+ uint32_t dataSize = r32(de + 4);
+
+ int64_t off = rvaToOffset(dataRva, sections, numSections);
+ if (off < 0 || off + dataSize > fileSize)
+ return {};
+ return QByteArray(fileData + off, static_cast<int>(dataSize));
+}
+
+/// Build an ICO file from a GRPICONDIR and RT_ICON resources.
+QByteArray buildIco(const QByteArray& grpData,
+ const QVector<ResDirEntry>& iconTypeEntries,
+ const char* resBase, const char* fileData,
+ int64_t fileSize, const char* sections, int numSections)
+{
+ if (grpData.size() < 6)
+ return {};
+
+ const int count = r16(grpData.constData() + 4);
+ if (grpData.size() < 6 + count * 14)
+ return {};
+
+ struct Entry { QByteArray header; QByteArray data; };
+ QVector<Entry> entries;
+
+ for (int i = 0; i < count; ++i) {
+ int grpOff = 6 + i * 14;
+ uint16_t iconId = r16(grpData.constData() + grpOff + 12);
+
+ // Find RT_ICON with this ID.
+ for (const auto& ie : iconTypeEntries) {
+ if (ie.nameOrId != iconId || !ie.isDir)
+ continue;
+
+ auto langEntries = parseResDir(resBase, ie.offsetOrData);
+ if (langEntries.isEmpty())
+ continue;
+
+ const auto& langEntry = langEntries.first();
+ if (langEntry.isDir)
+ continue;
+
+ QByteArray imgData = getResourceData(
+ resBase, langEntry.offsetOrData, fileData, fileSize, sections, numSections);
+ if (imgData.isEmpty())
+ continue;
+
+ QByteArray hdr(grpData.constData() + grpOff, 8);
+ entries.append({hdr, imgData});
+ break;
+ }
+ }
+
+ if (entries.isEmpty())
+ return {};
+
+ // Build ICO file.
+ QByteArray ico;
+ uint16_t entryCount = static_cast<uint16_t>(entries.size());
+ int headerSize = 6 + entryCount * 16;
+ ico.reserve(headerSize + count * 4096);
+
+ // ICONDIR header.
+ uint16_t zero = 0, one = 1;
+ ico.append(reinterpret_cast<const char*>(&zero), 2);
+ ico.append(reinterpret_cast<const char*>(&one), 2);
+ ico.append(reinterpret_cast<const char*>(&entryCount), 2);
+
+ // ICONDIRENTRY records.
+ uint32_t dataOffset = static_cast<uint32_t>(headerSize);
+ for (const auto& e : entries) {
+ ico.append(e.header); // 8 bytes
+ uint32_t sz = static_cast<uint32_t>(e.data.size());
+ ico.append(reinterpret_cast<const char*>(&sz), 4);
+ ico.append(reinterpret_cast<const char*>(&dataOffset), 4);
+ dataOffset += sz;
+ }
+
+ // Image data.
+ for (const auto& e : entries)
+ ico.append(e.data);
+
+ return ico;
+}
+
+/// Try to extract icons from a PE file (works for both PE32 and PE32+).
+QByteArray tryExtractIcons(const QByteArray& fileData)
+{
+ const int64_t sz = fileData.size();
+ const char* d = fileData.constData();
+
+ if (sz < 64 || d[0] != 'M' || d[1] != 'Z')
+ return {};
+
+ int32_t peOffset = r32(d + 60);
+ if (peOffset + 24 > sz)
+ return {};
+ if (memcmp(d + peOffset, "PE\0\0", 4) != 0)
+ return {};
+
+ const char* coff = d + peOffset + 4;
+ uint16_t machine = r16(coff);
+ uint16_t numSections = r16(coff + 2);
+ uint16_t optHdrSize = r16(coff + 16);
+
+ const char* optHdr = coff + 20;
+ uint16_t magic = r16(optHdr);
+
+ // Determine resource directory RVA.
+ uint32_t resRva = 0, resSize = 0;
+ if (magic == 0x10b) {
+ // PE32: data directories start at offset 96 in optional header.
+ // Resource table is the 3rd entry (index 2), each entry is 8 bytes.
+ if (optHdrSize >= 96 + 3 * 8) {
+ resRva = r32(optHdr + 96 + 2 * 8);
+ resSize = r32(optHdr + 96 + 2 * 8 + 4);
+ }
+ } else if (magic == 0x20b) {
+ // PE32+: data directories start at offset 112.
+ if (optHdrSize >= 112 + 3 * 8) {
+ resRva = r32(optHdr + 112 + 2 * 8);
+ resSize = r32(optHdr + 112 + 2 * 8 + 4);
+ }
+ } else {
+ return {};
+ }
+
+ if (resRva == 0 || resSize == 0)
+ return {};
+
+ const char* sections = optHdr + optHdrSize;
+ int64_t resFileOff = rvaToOffset(resRva, sections, numSections);
+ if (resFileOff < 0 || resFileOff + resSize > sz)
+ return {};
+
+ const char* resBase = d + resFileOff;
+
+ // Parse root directory — find RT_GROUP_ICON (14) and RT_ICON (3).
+ auto rootEntries = parseResDir(resBase, 0);
+
+ const ResDirEntry* groupIconType = nullptr;
+ const ResDirEntry* iconType = nullptr;
+ for (const auto& e : rootEntries) {
+ if (e.nameOrId == RT_GROUP_ICON && e.isDir)
+ groupIconType = &e;
+ if (e.nameOrId == RT_ICON && e.isDir)
+ iconType = &e;
+ }
+
+ if (!groupIconType || !iconType)
+ return {};
+
+ auto groupEntries = parseResDir(resBase, groupIconType->offsetOrData);
+ auto iconEntries = parseResDir(resBase, iconType->offsetOrData);
+
+ // Iterate groups, pick the one with the largest total area.
+ QByteArray bestIco;
+ int bestArea = -1;
+
+ for (const auto& ge : groupEntries) {
+ if (!ge.isDir)
+ continue;
+
+ auto langEntries = parseResDir(resBase, ge.offsetOrData);
+ if (langEntries.isEmpty())
+ continue;
+
+ const auto& langEntry = langEntries.first();
+ if (langEntry.isDir)
+ continue;
+
+ QByteArray grpData = getResourceData(
+ resBase, langEntry.offsetOrData, d, sz, sections, numSections);
+ if (grpData.size() < 6)
+ continue;
+
+ int count = r16(grpData.constData() + 4);
+ if (grpData.size() < 6 + count * 14)
+ continue;
+
+ int totalArea = 0;
+ for (int i = 0; i < count; ++i) {
+ int off = 6 + i * 14;
+ int w = (uint8_t)grpData[off] == 0 ? 256 : (uint8_t)grpData[off];
+ int h = (uint8_t)grpData[off+1] == 0 ? 256 : (uint8_t)grpData[off+1];
+ totalArea += w * h;
+ }
+
+ QByteArray ico = buildIco(grpData, iconEntries, resBase, d, sz, sections, numSections);
+ if (!ico.isEmpty() && totalArea > bestArea) {
+ bestArea = totalArea;
+ bestIco = ico;
+ }
+ }
+
+ return bestIco;
+}
+
+} // namespace
+
+QByteArray extractExeIcon(const QString& exePath)
+{
+ QFile f(exePath);
+ if (!f.open(QIODevice::ReadOnly))
+ return {};
+ QByteArray data = f.readAll();
+ return tryExtractIcons(data);
+}
diff --git a/src/src/iconextractor.h b/src/src/iconextractor.h
new file mode 100644
index 0000000..7348d60
--- /dev/null
+++ b/src/src/iconextractor.h
@@ -0,0 +1,11 @@
+#ifndef ICONEXTRACTOR_H
+#define ICONEXTRACTOR_H
+
+#include <QByteArray>
+#include <QString>
+
+/// Extract the best (largest) icon from a Windows PE executable as raw ICO bytes.
+/// Returns an empty QByteArray if extraction fails.
+__attribute__((visibility("default"))) QByteArray extractExeIcon(const QString& exePath);
+
+#endif // ICONEXTRACTOR_H
diff --git a/src/src/instancemanagerdialog.cpp b/src/src/instancemanagerdialog.cpp
index d3b9f77..ba0c900 100644
--- a/src/src/instancemanagerdialog.cpp
+++ b/src/src/instancemanagerdialog.cpp
@@ -20,7 +20,7 @@
#include <QtConcurrent/QtConcurrent>
#include <iplugingame.h>
#include <log.h>
-#include <nak_ffi.h>
+#include "slrmanager.h"
#include <report.h>
#include <utility.h>
@@ -943,13 +943,10 @@ void InstanceManagerDialog::openExistingPortable()
void InstanceManagerDialog::downloadSLRIfNeeded()
{
- if (nak_slr_is_installed()) {
+ if (isSlrInstalled()) {
return;
}
- // Indeterminate progress dialog — SLR download logs internally via nak_init_logging.
- // We can't pass capturing C++ lambdas as C function pointers, so we let the
- // Rust side handle progress logging and just show a spinner here.
auto* progress = new QProgressDialog(
tr("Downloading Steam Linux Runtime (~180 MB)...\n"
"This only happens once. Check the MO2 log for details."),
@@ -964,23 +961,20 @@ void InstanceManagerDialog::downloadSLRIfNeeded()
*cancelFlag = 1;
});
- using Result = char*;
- auto* watcher = new QFutureWatcher<Result>(this);
+ auto* watcher = new QFutureWatcher<QString>(this);
- connect(watcher, &QFutureWatcher<Result>::finished, this,
+ connect(watcher, &QFutureWatcher<QString>::finished, this,
[this, watcher, progress, cancelFlag] {
progress->close();
watcher->deleteLater();
progress->deleteLater();
- char* err = watcher->result();
- if (err) {
- const QString msg = QString::fromUtf8(err);
- nak_string_free(err);
- MOBase::log::error("[SLR] Download failed: {}", msg);
+ const QString err = watcher->result();
+ if (!err.isEmpty()) {
+ MOBase::log::error("[SLR] Download failed: {}", err);
QMessageBox::warning(this, tr("Steam Linux Runtime"),
tr("Download failed:\n%1\n\nSLR has been disabled for this instance.")
- .arg(msg));
+ .arg(err));
ui->steamLinuxRuntimeCheckBox->blockSignals(true);
ui->steamLinuxRuntimeCheckBox->setChecked(false);
ui->steamLinuxRuntimeCheckBox->blockSignals(false);
@@ -1000,8 +994,8 @@ void InstanceManagerDialog::downloadSLRIfNeeded()
});
int* cancelPtr = cancelFlag;
- watcher->setFuture(QtConcurrent::run([cancelPtr]() -> Result {
- return nak_download_slr(nullptr, nullptr, cancelPtr);
+ watcher->setFuture(QtConcurrent::run([cancelPtr]() -> QString {
+ return downloadSlr(nullptr, nullptr, cancelPtr);
}));
progress->show();
diff --git a/src/src/knowngames.h b/src/src/knowngames.h
new file mode 100644
index 0000000..a29e5dc
--- /dev/null
+++ b/src/src/knowngames.h
@@ -0,0 +1,194 @@
+#ifndef KNOWNGAMES_H
+#define KNOWNGAMES_H
+
+#include <QString>
+#include <QVector>
+#include <optional>
+
+struct KnownGame {
+ const char* name;
+ const char* steam_app_id;
+ const char* gog_app_id; // nullptr if not on GOG
+ const char* epic_app_id; // nullptr if not on Epic
+ const char* my_games_folder; // nullptr if not applicable
+ const char* appdata_local_folder;// nullptr if not applicable
+ const char* appdata_roaming_folder; // nullptr if not applicable
+ const char* registry_path; // under HKLM\Software
+ const char* registry_value; // value name for install path
+ const char* steam_folder; // expected folder in steamapps/common/
+};
+
+// Static list of all known games.
+inline constexpr KnownGame KNOWN_GAMES[] = {
+ // Bethesda Games
+ {"Enderal", "933480", "1708684988", nullptr,
+ "Enderal", nullptr, nullptr,
+ R"(Software\SureAI\Enderal)", "Install_Path", "Enderal"},
+ {"Enderal Special Edition", "976620", nullptr, nullptr,
+ "Enderal Special Edition", nullptr, nullptr,
+ R"(Software\SureAI\Enderal SE)", "installed path", "Enderal Special Edition"},
+ {"Fallout 3", "22300", "1454315831", "adeae8bbfc94427db57c7dfecce3f1d4",
+ "Fallout3", "Fallout3", nullptr,
+ R"(Software\Bethesda Softworks\Fallout3)", "Installed Path", "Fallout 3"},
+ {"Fallout 4", "377160", "1998527297", "61d52ce4d09d41e48800c22784d13ae8",
+ "Fallout4", "Fallout4", nullptr,
+ R"(Software\Bethesda Softworks\Fallout4)", "Installed Path", "Fallout 4"},
+ {"Fallout 4 VR", "611660", nullptr, nullptr,
+ "Fallout4VR", nullptr, nullptr,
+ R"(Software\Bethesda Softworks\Fallout 4 VR)", "Installed Path", "Fallout 4 VR"},
+ {"Fallout New Vegas", "22380", "1454587428", "5daeb974a22a435988892319b3a4f476",
+ "FalloutNV", "FalloutNV", nullptr,
+ R"(Software\Bethesda Softworks\FalloutNV)", "Installed Path", "Fallout New Vegas"},
+ {"Morrowind", "22320", "1440163901", nullptr,
+ "Morrowind", nullptr, nullptr,
+ R"(Software\Bethesda Softworks\Morrowind)", "Installed Path", "Morrowind"},
+ {"Oblivion", "22330", "1458058109", nullptr,
+ "Oblivion", "Oblivion", nullptr,
+ R"(Software\Bethesda Softworks\Oblivion)", "Installed Path", "Oblivion"},
+ {"Skyrim", "72850", nullptr, nullptr,
+ "Skyrim", "Skyrim", nullptr,
+ R"(Software\Bethesda Softworks\Skyrim)", "Installed Path", "Skyrim"},
+ {"Skyrim Special Edition", "489830", "1711230643", "ac82db5035584c7f8a2c548d98c86b2c",
+ "Skyrim Special Edition", "Skyrim Special Edition", nullptr,
+ R"(Software\Bethesda Softworks\Skyrim Special Edition)", "Installed Path",
+ "Skyrim Special Edition"},
+ {"Skyrim VR", "611670", nullptr, nullptr,
+ "Skyrim VR", nullptr, nullptr,
+ R"(Software\Bethesda Softworks\Skyrim VR)", "Installed Path", "Skyrim VR"},
+ {"Starfield", "1716740", nullptr, nullptr,
+ "Starfield", nullptr, nullptr,
+ R"(Software\Bethesda Softworks\Starfield)", "Installed Path", "Starfield"},
+ // CD Projekt RED Games
+ {"The Witcher 3", "292030", "1495134320", nullptr,
+ "The Witcher 3", nullptr, nullptr,
+ R"(Software\CD Projekt Red\The Witcher 3)", "InstallFolder",
+ "The Witcher 3 Wild Hunt"},
+ {"Cyberpunk 2077", "1091500", "1423049311", nullptr,
+ nullptr, "CD Projekt Red/Cyberpunk 2077", nullptr,
+ R"(Software\CD Projekt Red\Cyberpunk 2077)", "InstallFolder", "Cyberpunk 2077"},
+ // Other popular moddable games
+ {"Baldur's Gate 3", "1086940", "1456460669", nullptr,
+ nullptr, "Larian Studios/Baldur's Gate 3", nullptr,
+ R"(Software\Larian Studios\Baldur's Gate 3)", "InstallDir", "Baldurs Gate 3"},
+};
+
+inline constexpr int KNOWN_GAMES_COUNT =
+ static_cast<int>(sizeof(KNOWN_GAMES) / sizeof(KNOWN_GAMES[0]));
+
+// GOG ID aliases: (alias_id, primary_id)
+inline constexpr std::pair<const char*, const char*> GOG_ID_ALIASES[] = {
+ {"1435828767", "1440163901"}, // Morrowind alternate GOG ID
+ {"1801825368", "1711230643"}, // Skyrim Anniversary Edition → Skyrim SE
+};
+
+inline constexpr int GOG_ID_ALIASES_COUNT =
+ static_cast<int>(sizeof(GOG_ID_ALIASES) / sizeof(GOG_ID_ALIASES[0]));
+
+// Normalize Steam App ID (e.g. Fallout 3 GOTY 22370 → 22300).
+inline QString normalizeSteamId(const QString& appId)
+{
+ if (appId == QLatin1String("22370"))
+ return QStringLiteral("22300");
+ return appId;
+}
+
+inline const KnownGame* findKnownGameBySteamId(const QString& appId)
+{
+ const QString normalized = normalizeSteamId(appId);
+ for (int i = 0; i < KNOWN_GAMES_COUNT; ++i) {
+ if (normalized == QLatin1String(KNOWN_GAMES[i].steam_app_id))
+ return &KNOWN_GAMES[i];
+ }
+ return nullptr;
+}
+
+inline const KnownGame* findKnownGameByGogId(const QString& appId)
+{
+ // Direct match.
+ for (int i = 0; i < KNOWN_GAMES_COUNT; ++i) {
+ if (KNOWN_GAMES[i].gog_app_id && appId == QLatin1String(KNOWN_GAMES[i].gog_app_id))
+ return &KNOWN_GAMES[i];
+ }
+ // Check aliases.
+ for (int i = 0; i < GOG_ID_ALIASES_COUNT; ++i) {
+ if (appId == QLatin1String(GOG_ID_ALIASES[i].first)) {
+ const char* primary = GOG_ID_ALIASES[i].second;
+ for (int j = 0; j < KNOWN_GAMES_COUNT; ++j) {
+ if (KNOWN_GAMES[j].gog_app_id &&
+ QLatin1String(KNOWN_GAMES[j].gog_app_id) == QLatin1String(primary))
+ return &KNOWN_GAMES[j];
+ }
+ }
+ }
+ return nullptr;
+}
+
+inline const KnownGame* findKnownGameByEpicId(const QString& appId)
+{
+ for (int i = 0; i < KNOWN_GAMES_COUNT; ++i) {
+ if (KNOWN_GAMES[i].epic_app_id && appId == QLatin1String(KNOWN_GAMES[i].epic_app_id))
+ return &KNOWN_GAMES[i];
+ }
+ return nullptr;
+}
+
+inline const KnownGame* findKnownGameByName(const QString& name)
+{
+ const QString lower = name.toLower();
+ for (int i = 0; i < KNOWN_GAMES_COUNT; ++i) {
+ if (lower == QString::fromLatin1(KNOWN_GAMES[i].name).toLower())
+ return &KNOWN_GAMES[i];
+ }
+ return nullptr;
+}
+
+/// Fuzzy title matching — strips punctuation, tries containment, colon-split.
+inline const KnownGame* findKnownGameByTitle(const QString& title)
+{
+ const QString titleLower = title.toLower();
+
+ // Exact match first.
+ if (auto* g = findKnownGameByName(title))
+ return g;
+
+ // Sort by name length descending so "Skyrim Special Edition" beats "Skyrim".
+ QVector<const KnownGame*> sorted;
+ sorted.reserve(KNOWN_GAMES_COUNT);
+ for (int i = 0; i < KNOWN_GAMES_COUNT; ++i)
+ sorted.append(&KNOWN_GAMES[i]);
+ std::sort(sorted.begin(), sorted.end(), [](const KnownGame* a, const KnownGame* b) {
+ return qstrlen(a->name) > qstrlen(b->name);
+ });
+
+ // Helper: strip non-alphanumeric, collapse whitespace.
+ auto normalize = [](const QString& s) -> QString {
+ QString out;
+ for (QChar c : s) {
+ out += (c.isLetterOrNumber() || c == ' ') ? c : QChar(' ');
+ }
+ return out.simplified();
+ };
+
+ const QString titleNorm = normalize(titleLower);
+
+ for (const KnownGame* g : sorted) {
+ const QString gameLower = QString::fromLatin1(g->name).toLower();
+ const QString gameNorm = normalize(gameLower);
+
+ if (titleLower.contains(gameLower))
+ return g;
+ if (titleNorm.contains(gameNorm))
+ return g;
+
+ // After-colon match: "The Elder Scrolls III: Morrowind" → "Morrowind"
+ const int colon = titleLower.indexOf(':');
+ if (colon >= 0) {
+ const QString afterColon = titleLower.mid(colon + 1).trimmed();
+ if (afterColon.startsWith(gameLower))
+ return g;
+ }
+ }
+ return nullptr;
+}
+
+#endif // KNOWNGAMES_H
diff --git a/src/src/main.cpp b/src/src/main.cpp
index eed977e..b3e431c 100644
--- a/src/src/main.cpp
+++ b/src/src/main.cpp
@@ -10,7 +10,6 @@
#include "shared/util.h"
#include "thread_utils.h"
#include <log.h>
-#include <nak_ffi.h>
#include <report.h>
#include <QString>
@@ -87,23 +86,6 @@ int run(int argc, char* argv[])
initLogging();
- // Route NaK (Rust) log messages through MOBase::log
- nak_init_logging([](const char* level, const char* message) {
- if (!message || !*message) return;
- if (!level || !*level) {
- log::info("[nak] {}", message);
- return;
- }
- // Map NaK levels to MOBase log levels
- if (std::strcmp(level, "error") == 0) {
- log::error("[nak] {}", message);
- } else if (std::strcmp(level, "warning") == 0) {
- log::warn("[nak] {}", message);
- } else {
- log::info("[nak] {}", message);
- }
- });
-
// must be after logging
TimeThis tt("main() multiprocess");
diff --git a/src/src/mainwindow.cpp b/src/src/mainwindow.cpp
index f7cb4cd..f2ce67b 100644
--- a/src/src/mainwindow.cpp
+++ b/src/src/mainwindow.cpp
@@ -92,7 +92,7 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>.
#include "shared/fileentry.h"
#include "shared/filesorigin.h"
-#include <nak_ffi.h>
+#include "slrmanager.h"
#include <QAbstractItemDelegate>
#include <QAction>
#include <QApplication>
@@ -2381,7 +2381,7 @@ void MainWindow::on_startButton_clicked()
QSettings instanceIni(s->filename(), QSettings::IniFormat);
useSLR = instanceIni.value("fluorine/use_slr", true).toBool();
}
- if (useSLR && !nak_slr_is_installed()) {
+ if (useSLR && !isSlrInstalled()) {
auto* progress = new QProgressDialog(
tr("Downloading Steam Linux Runtime (~180 MB)...\n"
"This is required to launch games. Check the MO2 log for details."),
@@ -2396,28 +2396,26 @@ void MainWindow::on_startButton_clicked()
});
// Run download synchronously using an event loop so the dialog stays responsive.
- QFutureWatcher<char*> watcher;
+ QFutureWatcher<QString> watcher;
QEventLoop loop;
- connect(&watcher, &QFutureWatcher<char*>::finished, &loop, &QEventLoop::quit);
- watcher.setFuture(QtConcurrent::run([&cancelFlag]() -> char* {
- return nak_download_slr(nullptr, nullptr, &cancelFlag);
+ connect(&watcher, &QFutureWatcher<QString>::finished, &loop, &QEventLoop::quit);
+ watcher.setFuture(QtConcurrent::run([&cancelFlag]() -> QString {
+ return downloadSlr(nullptr, nullptr, &cancelFlag);
}));
progress->show();
loop.exec();
progress->close();
progress->deleteLater();
- char* err = watcher.result();
+ const QString err = watcher.result();
if (cancelFlag) {
return; // user cancelled, don't launch
}
- if (err) {
- const QString msg = QString::fromUtf8(err);
- nak_string_free(err);
- log::error("[SLR] Download failed: {}", msg);
+ if (!err.isEmpty()) {
+ log::error("[SLR] Download failed: {}", err);
QMessageBox::warning(this, tr("Steam Linux Runtime"),
tr("Steam Linux Runtime download failed:\n%1\n\n"
- "You can disable SLR in the Instance Manager and try again.").arg(msg));
+ "You can disable SLR in the Instance Manager and try again.").arg(err));
return;
}
}
diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp
index 373ec06..330eeae 100644
--- a/src/src/organizercore.cpp
+++ b/src/src/organizercore.cpp
@@ -44,7 +44,6 @@
#else
#include "fluorineconfig.h"
#include "wineprefix.h"
-#include <nak_ffi.h>
#endif
#include <chrono>
diff --git a/src/src/prefixsetuprunner.cpp b/src/src/prefixsetuprunner.cpp
index ed836e4..81824e1 100644
--- a/src/src/prefixsetuprunner.cpp
+++ b/src/src/prefixsetuprunner.cpp
@@ -2,7 +2,9 @@
#include "fluorinepaths.h"
-#include <nak_ffi.h>
+#include "gamedetection.h"
+#include "steamdetection.h"
+#include "prefixsymlinks.h"
#include <QCoreApplication>
#include <QDir>
@@ -1284,9 +1286,8 @@ bool PrefixSetupRunner::stepGameDetection()
{
emit logMessage("Auto-detecting installed games...");
- NakGameList gameList = nak_detect_all_games();
- if (gameList.count == 0) {
- nak_game_list_free(gameList);
+ GameScanResult scanResult = detectAllGames();
+ if (scanResult.games.isEmpty()) {
emit logMessage("No games detected");
return true;
}
@@ -1295,15 +1296,14 @@ bool PrefixSetupRunner::stepGameDetection()
QString regContent = QStringLiteral("Windows Registry Editor Version 5.00\n\n");
int gameCount = 0;
- for (size_t i = 0; i < gameList.count; ++i) {
- const NakGame& game = gameList.games[i];
- if (!game.registry_path || !game.registry_value)
+ for (const DetectedGame& game : scanResult.games) {
+ if (game.registry_path.isEmpty() || game.registry_value.isEmpty())
continue;
- const QString gameName = QString::fromUtf8(game.name);
- const QString installPath = QString::fromUtf8(game.install_path);
- const QString rPath = QString::fromUtf8(game.registry_path);
- const QString rVal = QString::fromUtf8(game.registry_value);
+ const QString& gameName = game.name;
+ const QString& installPath = game.install_path;
+ const QString& rPath = game.registry_path;
+ const QString& rVal = game.registry_value;
// Convert Linux path to Wine Z: drive path with escaped backslashes.
QString winePath = "Z:" + QString(installPath).replace('/', "\\\\");
@@ -1319,8 +1319,6 @@ bool PrefixSetupRunner::stepGameDetection()
++gameCount;
}
- nak_game_list_free(gameList);
-
if (gameCount == 0) {
emit logMessage("No games with valid registry paths");
return true;
@@ -1420,18 +1418,10 @@ bool PrefixSetupRunner::stepPostSetup()
emit logMessage("Running post-setup tasks...");
// Ensure AppData temp directory exists.
- const QByteArray prefixUtf8 = m_prefixPath.toUtf8();
- nak_ensure_temp_directory(prefixUtf8.constData());
+ ensureTempDirectory(m_prefixPath);
// Create game symlinks.
- nak_create_game_symlinks_auto(prefixUtf8.constData());
-
- // Ensure DXVK config.
- if (char* err = nak_ensure_dxvk_conf(); err != nullptr) {
- emit logMessage(QStringLiteral("Warning: dxvk.conf: %1").arg(QString::fromUtf8(err)));
- nak_string_free(err);
- // Non-fatal.
- }
+ createGameSymlinksAuto(m_prefixPath);
emit logMessage("Post-setup complete");
return true;
@@ -1640,13 +1630,10 @@ QString PrefixSetupRunner::findProtonScript() const
QString PrefixSetupRunner::detectSteamPath() const
{
- // Use NaK FFI first.
- if (char* path = nak_find_steam_path(); path != nullptr) {
- QString result = QString::fromUtf8(path);
- nak_string_free(path);
- if (!result.isEmpty())
- return result;
- }
+ // Use native Steam detection first.
+ const QString steamPath = findSteamPath();
+ if (!steamPath.isEmpty())
+ return steamPath;
// Fallback.
const QString home = QDir::homePath();
diff --git a/src/src/prefixsymlinks.cpp b/src/src/prefixsymlinks.cpp
new file mode 100644
index 0000000..2195e3e
--- /dev/null
+++ b/src/src/prefixsymlinks.cpp
@@ -0,0 +1,143 @@
+#include "prefixsymlinks.h"
+#include "gamedetection.h"
+
+#include <QDir>
+#include <QFile>
+#include <QFileInfo>
+#include <uibase/log.h>
+#include <unistd.h>
+
+namespace {
+
+static const char* SKIP_DIRS[] = {
+ "Temp", "Microsoft", "wine", "Public", "root",
+ "Application Data", "Cookies", "Local Settings",
+ "NetHood", "PrintHood", "Recent", "SendTo",
+ "Start Menu", "Templates", "My Documents", "My Music",
+ "My Pictures", "My Videos", "Desktop", "Downloads",
+ "Favorites", "Links", "Searches",
+ "Contacts", "3D Objects",
+};
+
+bool shouldSkip(const QString& name)
+{
+ for (const char* s : SKIP_DIRS) {
+ if (name.compare(QLatin1String(s), Qt::CaseInsensitive) == 0)
+ return true;
+ }
+ return false;
+}
+
+/// Find the username directory inside drive_c/users/.
+QString findPrefixUsername(const QString& usersDir)
+{
+ QDir dir(usersDir);
+ for (const QString& entry : dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) {
+ if (entry != QStringLiteral("Public") && entry != QStringLiteral("root"))
+ return entry;
+ }
+ 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)
+{
+ QFileInfo fi(linkPath);
+ if (fi.exists() || fi.isSymLink()) {
+ if (fi.isSymLink() && fi.symLinkTarget() == target)
+ return true;
+ return false; // something else exists
+ }
+
+ QDir().mkpath(QFileInfo(linkPath).absolutePath());
+ if (symlink(target.toUtf8().constData(), linkPath.toUtf8().constData()) != 0) {
+ MOBase::log::warn("Failed to create symlink {} -> {}", linkPath, target);
+ return false;
+ }
+ return true;
+}
+
+/// Scan all subdirectories in gameBase and symlink them into nakBase.
+int scanAndLinkAll(const QString& nakBase, const QString& gameBase,
+ const QString& label, const QString& gameName,
+ bool skipMyGames = false)
+{
+ QDir dir(gameBase);
+ if (!dir.exists())
+ return 0;
+
+ int count = 0;
+ for (const QString& folder : dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) {
+ if (shouldSkip(folder))
+ continue;
+ if (skipMyGames && folder == QStringLiteral("My Games"))
+ continue;
+
+ const QString linkPath = nakBase + "/" + folder;
+ const QString target = gameBase + "/" + folder;
+
+ if (createSymlinkIfNeeded(linkPath, target)) {
+ MOBase::log::info("Linked {}/{} -> {} ({})", label, folder, target, gameName);
+ ++count;
+ }
+ }
+ return count;
+}
+
+} // namespace
+
+void ensureTempDirectory(const QString& prefixPath)
+{
+ const QString usersDir = prefixPath + "/drive_c/users";
+ const QString username = findPrefixUsername(usersDir);
+ const QString tempDir = usersDir + "/" + username + "/AppData/Local/Temp";
+
+ if (QDir().mkpath(tempDir))
+ MOBase::log::info("Ensured AppData/Local/Temp directory exists");
+ else
+ MOBase::log::warn("Failed to create Temp directory at {}", tempDir);
+}
+
+void createGameSymlinksAuto(const QString& prefixPath)
+{
+ GameScanResult result = detectAllGames();
+
+ const QString usersDir = prefixPath + "/drive_c/users";
+ const QString username = findPrefixUsername(usersDir);
+ const QString userDir = usersDir + "/" + username;
+ const QString documents = userDir + "/Documents";
+ const QString myGames = documents + "/My Games";
+ const QString appdataLocal = userDir + "/AppData/Local";
+ const QString appdataRoaming = userDir + "/AppData/Roaming";
+
+ QDir().mkpath(myGames);
+ QDir().mkpath(appdataLocal);
+ QDir().mkpath(appdataRoaming);
+
+ int linked = 0;
+ for (const DetectedGame& game : result.games) {
+ if (game.prefix_path.isEmpty())
+ continue;
+
+ const QString gameUsersDir = game.prefix_path + "/drive_c/users";
+ const QString gameUsername = findPrefixUsername(gameUsersDir);
+ const QString gameUserDir = gameUsersDir + "/" + gameUsername;
+
+ linked += scanAndLinkAll(myGames, gameUserDir + "/Documents/My Games",
+ QStringLiteral("Documents/My Games"), game.name);
+ linked += scanAndLinkAll(documents, gameUserDir + "/Documents",
+ QStringLiteral("Documents"), game.name, true);
+ linked += scanAndLinkAll(appdataLocal, gameUserDir + "/AppData/Local",
+ QStringLiteral("AppData/Local"), game.name);
+ linked += scanAndLinkAll(appdataRoaming, gameUserDir + "/AppData/Roaming",
+ QStringLiteral("AppData/Roaming"), game.name);
+ }
+
+ if (linked > 0)
+ MOBase::log::info("Created {} symlinks to game prefixes", linked);
+
+ // "My Documents" compat symlink.
+ const QString myDocs = userDir + "/My Documents";
+ if (!QFileInfo::exists(myDocs) && !QFileInfo(myDocs).isSymLink())
+ symlink("Documents", myDocs.toUtf8().constData());
+}
diff --git a/src/src/prefixsymlinks.h b/src/src/prefixsymlinks.h
new file mode 100644
index 0000000..db8230a
--- /dev/null
+++ b/src/src/prefixsymlinks.h
@@ -0,0 +1,12 @@
+#ifndef PREFIXSYMLINKS_H
+#define PREFIXSYMLINKS_H
+
+#include <QString>
+
+/// Ensure AppData/Local/Temp exists in the Wine prefix.
+__attribute__((visibility("default"))) void ensureTempDirectory(const QString& prefixPath);
+
+/// Detect all games and create symlinks from the given prefix to game prefixes.
+__attribute__((visibility("default"))) void createGameSymlinksAuto(const QString& prefixPath);
+
+#endif // PREFIXSYMLINKS_H
diff --git a/src/src/protonlauncher.cpp b/src/src/protonlauncher.cpp
index c6fc545..89338f4 100644
--- a/src/src/protonlauncher.cpp
+++ b/src/src/protonlauncher.cpp
@@ -1,6 +1,7 @@
#include "protonlauncher.h"
-#include <nak_ffi.h>
+#include "steamdetection.h"
+#include "slrmanager.h"
#include <cstdio>
#include <QDir>
#include <QFile>
@@ -114,10 +115,8 @@ QString compatDataPathFromPrefix(const QString& prefixPath)
QString detectSteamPath()
{
- if (char* steamPathRaw = nak_find_steam_path(); steamPathRaw != nullptr) {
- const QString steamPath = QString::fromUtf8(steamPathRaw).trimmed();
- nak_string_free(steamPathRaw);
-
+ {
+ const QString steamPath = findSteamPath().trimmed();
if (!steamPath.isEmpty()) {
return steamPath;
}
@@ -456,9 +455,8 @@ bool ProtonLauncher::launchWithProton(qint64& pid) const
QString program;
QStringList arguments;
if (m_useSLR) {
- if (char* slrScript = nak_slr_get_run_script(); slrScript != nullptr) {
- const QString runScript = QString::fromUtf8(slrScript);
- nak_string_free(slrScript);
+ const QString runScript = getSlrRunScript();
+ if (!runScript.isEmpty()) {
MOBase::log::info("SLR: wrapping launch with {}", runScript);
// Build: [wrappers] run_script [--filesystem=...] -- proton_script protonArgs
QStringList slrArgs;
@@ -535,15 +533,6 @@ bool ProtonLauncher::launchWithProton(qint64& pid) const
env.insert(it.key(), it.value());
}
- // Set DXVK config if available
- if (char* dxvkPath = nak_get_dxvk_conf_path(); dxvkPath != nullptr) {
- const QString dxvkConf = QString::fromUtf8(dxvkPath);
- nak_string_free(dxvkPath);
- if (QFileInfo::exists(dxvkConf)) {
- env.insert("DXVK_CONFIG_FILE", dxvkConf);
- }
- }
-
MOBase::log::info("Proton launch: '{}' run '{}'", protonScript, m_binary);
MOBase::log::info("Final command: '{}' {}", program,
arguments.join(" ").toStdString());
diff --git a/src/src/selfupdater.cpp b/src/src/selfupdater.cpp
index 4438f60..556e40e 100644
--- a/src/src/selfupdater.cpp
+++ b/src/src/selfupdater.cpp
@@ -89,6 +89,10 @@ void SelfUpdater::setPluginContainer(PluginContainer* pluginContainer)
void SelfUpdater::testForUpdate(const Settings& settings)
{
+ // Fluorine Manager has its own update mechanism — skip the MO2 GitHub check.
+ (void)settings;
+ return;
+
if (settings.network().offlineMode()) {
log::debug("not checking for updates, in offline mode");
return;
@@ -235,13 +239,13 @@ void SelfUpdater::openOutputFile(const QString& fileName)
QString outputPath =
QDir::fromNativeSeparators(qApp->property("dataPath").toString()) + "/" +
fileName;
- log::debug("downloading to {}", outputPath);
- m_UpdateFile.setFileName(outputPath);
- if (!m_UpdateFile.open(QIODevice::WriteOnly)) {
- log::error("failed to open update output file '{}': {}", outputPath,
- m_UpdateFile.errorString());
- }
-}
+ log::debug("downloading to {}", outputPath);
+ m_UpdateFile.setFileName(outputPath);
+ if (!m_UpdateFile.open(QIODevice::WriteOnly)) {
+ log::error("failed to open update output file '{}': {}", outputPath,
+ m_UpdateFile.errorString());
+ }
+}
void SelfUpdater::download(const QString& downloadLink)
{
diff --git a/src/src/settingsdialogproton.cpp b/src/src/settingsdialogproton.cpp
index 1875f31..2117a09 100644
--- a/src/src/settingsdialogproton.cpp
+++ b/src/src/settingsdialogproton.cpp
@@ -8,7 +8,10 @@
#include <QtConcurrent/QtConcurrentRun>
#include <log.h>
#include <uibase/utility.h>
-#include <nak_ffi.h>
+#include "knowngames.h"
+#include "steamdetection.h"
+#include "gamedetection.h"
+#include "slrmanager.h"
#include <atomic>
#include <QCheckBox>
#include <QComboBox>
@@ -37,6 +40,128 @@
namespace
{
std::atomic<ProtonSettingsTab*> g_activeInstallTab = nullptr;
+
+/// Find the wine binary inside a Proton installation directory.
+static QString findWineBinary(const QString& protonPath)
+{
+ for (const char* subdir : {"files/bin", "dist/bin"}) {
+ const QString candidate =
+ QDir(protonPath).filePath(QString::fromLatin1(subdir) + "/wine");
+ if (QFileInfo::exists(candidate))
+ return candidate;
+ }
+ return {};
+}
+
+/// Apply a single game's registry entry via wine regedit.
+///
+/// Looks up the game by name in NaK's KNOWN_GAMES, builds a .reg file that
+/// maps the install path to the game's registry key, and imports it with
+/// wine regedit (wrapped in SLR if available).
+static QString applyGameRegistryNative(const QString& prefixPath,
+ const QString& protonPath,
+ const QString& gameName,
+ const QString& installPath,
+ void (*logCb)(const char*))
+{
+ // Find wine binary.
+ const QString wineBin = findWineBinary(protonPath);
+ if (wineBin.isEmpty())
+ return QStringLiteral("Wine binary not found in Proton at %1").arg(protonPath);
+
+ // Look up registry path/value from known games.
+ const KnownGame* foundGame = nullptr;
+ for (int i = 0; i < KNOWN_GAMES_COUNT; ++i) {
+ if (gameName == QString::fromLatin1(KNOWN_GAMES[i].name)) {
+ foundGame = &KNOWN_GAMES[i];
+ break;
+ }
+ }
+
+ if (!foundGame || !foundGame->registry_path || !foundGame->registry_value)
+ return QStringLiteral("Unknown game: %1").arg(gameName);
+
+ const QString rPath = QString::fromLatin1(foundGame->registry_path);
+ const QString rVal = QString::fromLatin1(foundGame->registry_value);
+
+ // Convert Linux path → Wine Z: drive path with escaped backslashes for .reg.
+ const QString winePath = "Z:" + QString(installPath).replace('/', "\\\\");
+
+ // Wow6432Node key: strip the leading "Software\" prefix.
+ const int firstBackslash = rPath.indexOf('\\');
+ const QString wow64Key =
+ (firstBackslash >= 0) ? rPath.mid(firstBackslash + 1) : rPath;
+
+ const QString regContent = QStringLiteral(
+ "Windows Registry Editor Version 5.00\n\n"
+ "[HKEY_LOCAL_MACHINE\\%1]\n\"%2\"=\"%3\"\n\n"
+ "[HKEY_LOCAL_MACHINE\\SOFTWARE\\Wow6432Node\\%4]\n\"%5\"=\"%6\"\n\n")
+ .arg(rPath, rVal, winePath, wow64Key, rVal, winePath);
+
+ // Write temp .reg file.
+ const QString tmpDir = fluorineDataDir() + "/tmp";
+ QDir().mkpath(tmpDir);
+ const QString regFile = tmpDir + "/game_reg_apply.reg";
+
+ {
+ QFile f(regFile);
+ if (!f.open(QIODevice::WriteOnly | QIODevice::Text))
+ return QStringLiteral("Failed to write registry file: %1").arg(regFile);
+ f.write(regContent.toUtf8());
+ }
+
+ if (logCb) {
+ const QByteArray msg =
+ QStringLiteral("Applying registry for %1...").arg(gameName).toUtf8();
+ logCb(msg.constData());
+ }
+
+ // Build the command — wrap in SLR if available.
+ QProcess proc;
+ QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
+ env.insert("WINEPREFIX", prefixPath);
+ env.insert("WINEDLLOVERRIDES", "mshtml=d");
+ env.insert("PROTON_USE_XALIA", "0");
+ proc.setProcessEnvironment(env);
+
+ const QString slr = getSlrRunScript();
+ if (!slr.isEmpty()) {
+
+ QStringList args;
+ // Expose directories to the container.
+ const QString wineDir = QFileInfo(wineBin).absolutePath();
+ if (!wineDir.isEmpty())
+ args << QStringLiteral("--filesystem=%1").arg(wineDir);
+ if (!prefixPath.isEmpty())
+ args << QStringLiteral("--filesystem=%1").arg(prefixPath);
+ if (!protonPath.isEmpty())
+ args << QStringLiteral("--filesystem=%1").arg(protonPath);
+ if (QDir(tmpDir).exists())
+ args << QStringLiteral("--filesystem=%1").arg(tmpDir);
+ args << "--" << wineBin << "regedit" << regFile;
+
+ proc.setProgram(slr);
+ proc.setArguments(args);
+ } else {
+ proc.setProgram(wineBin);
+ proc.setArguments({"regedit", regFile});
+ }
+
+ proc.setProcessChannelMode(QProcess::MergedChannels);
+ proc.start();
+ proc.waitForFinished(60000);
+ QFile::remove(regFile);
+
+ if (proc.exitStatus() != QProcess::NormalExit || proc.exitCode() != 0)
+ return QStringLiteral("wine regedit failed (exit code %1)").arg(proc.exitCode());
+
+ if (logCb) {
+ const QByteArray msg =
+ QStringLiteral("Registry applied for %1").arg(gameName).toUtf8();
+ logCb(msg.constData());
+ }
+ return {}; // success
+}
}
ProtonSettingsTab::ProtonSettingsTab(Settings& s, SettingsDialog& d)
@@ -126,25 +251,20 @@ void ProtonSettingsTab::populateProtons()
{
ui->protonVersionCombo->clear();
- const NakProtonList protonList = nak_find_steam_protons();
-
- for (size_t i = 0; i < protonList.count; ++i) {
- const NakSteamProton& proton = protonList.protons[i];
+ const auto protonList = findSteamProtons();
- const QString protonName = QString::fromUtf8(proton.name ? proton.name : "");
- const QString protonPath = QString::fromUtf8(proton.path ? proton.path : "");
+ for (int i = 0; i < protonList.size(); ++i) {
+ const SteamProtonInfo& proton = protonList[i];
- if (protonName.isEmpty() || protonPath.isEmpty()) {
+ if (proton.name.isEmpty() || proton.path.isEmpty()) {
continue;
}
- ui->protonVersionCombo->addItem(protonName);
- ui->protonVersionCombo->setItemData(ui->protonVersionCombo->count() - 1, protonPath,
+ ui->protonVersionCombo->addItem(proton.name);
+ ui->protonVersionCombo->setItemData(ui->protonVersionCombo->count() - 1, proton.path,
Qt::UserRole + 1);
}
- nak_proton_list_free(protonList);
-
if (auto cfg = FluorineConfig::load(); cfg.has_value()) {
const int idx = ui->protonVersionCombo->findText(cfg->proton_name);
if (idx >= 0) {
@@ -293,7 +413,7 @@ void ProtonSettingsTab::onOpenPrefixFolder()
void ProtonSettingsTab::onDownloadSLR()
{
- if (nak_slr_is_installed()) {
+ if (isSlrInstalled()) {
QMessageBox::information(parentWidget(), tr("Steam Linux Runtime"),
tr("Steam Linux Runtime is already installed."));
return;
@@ -313,21 +433,19 @@ void ProtonSettingsTab::onDownloadSLR()
*cancelFlag = 1;
});
- auto* watcher = new QFutureWatcher<char*>(this);
- connect(watcher, &QFutureWatcher<char*>::finished, this,
+ auto* watcher = new QFutureWatcher<QString>(this);
+ connect(watcher, &QFutureWatcher<QString>::finished, this,
[this, watcher, progress, cancelFlag] {
progress->close();
watcher->deleteLater();
progress->deleteLater();
ui->downloadSLRButton->setEnabled(true);
- char* err = watcher->result();
- if (err) {
- const QString msg = QString::fromUtf8(err);
- nak_string_free(err);
- MOBase::log::error("[SLR] Download failed: {}", msg);
+ const QString err = watcher->result();
+ if (!err.isEmpty()) {
+ MOBase::log::error("[SLR] Download failed: {}", err);
QMessageBox::warning(parentWidget(), tr("Steam Linux Runtime"),
- tr("Download failed:\n%1").arg(msg));
+ tr("Download failed:\n%1").arg(err));
} else {
MOBase::log::info("[SLR] Steam Linux Runtime installed successfully");
QMessageBox::information(parentWidget(), tr("Steam Linux Runtime"),
@@ -337,8 +455,8 @@ void ProtonSettingsTab::onDownloadSLR()
});
int* cancelPtr = cancelFlag;
- watcher->setFuture(QtConcurrent::run([cancelPtr]() -> char* {
- return nak_download_slr(nullptr, nullptr, cancelPtr);
+ watcher->setFuture(QtConcurrent::run([cancelPtr]() -> QString {
+ return downloadSlr(nullptr, nullptr, cancelPtr);
}));
progress->show();
}
@@ -534,12 +652,8 @@ void ProtonSettingsTab::showGameRegistryDialog()
auto* gameCombo = new QComboBox(&dialog);
- size_t knownCount = 0;
- const NakKnownGame* knownGames = nak_get_known_games(&knownCount);
-
- for (size_t i = 0; i < knownCount; ++i) {
- const QString name =
- QString::fromUtf8(knownGames[i].name ? knownGames[i].name : "");
+ for (int i = 0; i < KNOWN_GAMES_COUNT; ++i) {
+ const QString name = QString::fromLatin1(KNOWN_GAMES[i].name);
if (!name.isEmpty()) {
gameCombo->addItem(name);
}
@@ -572,22 +686,18 @@ void ProtonSettingsTab::showGameRegistryDialog()
const QString gameName = gameCombo->currentText();
if (gameName.isEmpty()) return;
- // Use nak_detect_all_games to find the install path
- NakGameList gameList = nak_detect_all_games();
- for (size_t i = 0; i < gameList.count; ++i) {
- const QString detected =
- QString::fromUtf8(gameList.games[i].name ? gameList.games[i].name : "");
- if (detected.contains(gameName, Qt::CaseInsensitive) ||
- gameName.contains(detected, Qt::CaseInsensitive)) {
- const QString path = QString::fromUtf8(
- gameList.games[i].install_path ? gameList.games[i].install_path : "");
- if (!path.isEmpty()) {
- pathEdit->setText(path);
+ // Use detectAllGames to find the install path
+ const GameScanResult scanResult = detectAllGames();
+ for (int i = 0; i < scanResult.games.size(); ++i) {
+ const DetectedGame& detected = scanResult.games[i];
+ if (detected.name.contains(gameName, Qt::CaseInsensitive) ||
+ gameName.contains(detected.name, Qt::CaseInsensitive)) {
+ if (!detected.install_path.isEmpty()) {
+ pathEdit->setText(detected.install_path);
break;
}
}
}
- nak_game_list_free(gameList);
};
QObject::connect(gameCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
@@ -631,24 +741,15 @@ void ProtonSettingsTab::showGameRegistryDialog()
m_pendingProtonPath = protonPath;
m_installWatcher.setFuture(
- QtConcurrent::run([prefixPath, protonName, protonPath,
+ QtConcurrent::run([prefixPath, protonPath,
selectedGame, gamePath]() -> InstallResult {
- const QByteArray prefixUtf8 = prefixPath.toUtf8();
- const QByteArray protonNmUtf8 = protonName.toUtf8();
- const QByteArray protonPthUtf8 = protonPath.toUtf8();
- const QByteArray gameUtf8 = selectedGame.toUtf8();
- const QByteArray pathUtf8 = gamePath.toUtf8();
-
- char* error = nak_apply_registry_for_game_path(
- prefixUtf8.constData(), protonNmUtf8.constData(),
- protonPthUtf8.constData(), gameUtf8.constData(),
- pathUtf8.constData(), &ProtonSettingsTab::logCallback);
+ const QString err = applyGameRegistryNative(
+ prefixPath, protonPath, selectedGame, gamePath,
+ &ProtonSettingsTab::logCallback);
InstallResult r;
- if (error != nullptr) {
- r.error = QString::fromUtf8(error);
- nak_string_free(error);
- }
+ if (!err.isEmpty())
+ r.error = err;
return r;
}));
@@ -720,3 +821,4 @@ void ProtonSettingsTab::onInstallFinished()
ui->protonStatusLabel->setText(tr("Done"));
refreshState();
}
+
diff --git a/src/src/slrmanager.cpp b/src/src/slrmanager.cpp
new file mode 100644
index 0000000..4e7b898
--- /dev/null
+++ b/src/src/slrmanager.cpp
@@ -0,0 +1,186 @@
+#include "slrmanager.h"
+
+#include <QDir>
+#include <QFile>
+#include <QFileInfo>
+#include <QNetworkAccessManager>
+#include <QNetworkReply>
+#include <QProcess>
+#include <QTimer>
+#include <QEventLoop>
+#include <uibase/log.h>
+
+namespace {
+
+const char* BASE_URL = "https://repo.steampowered.com/steamrt3/images/latest-public-beta";
+const char* ARCHIVE_NAME = "SteamLinuxRuntime_sniper.tar.xz";
+const char* EXTRACTED_DIR = "SteamLinuxRuntime_sniper";
+
+QString slrInstallDir()
+{
+ return QDir::homePath() + "/.local/share/fluorine/steamrt";
+}
+
+QString slrRunScriptPath()
+{
+ return slrInstallDir() + "/" + EXTRACTED_DIR + "/run";
+}
+
+QString localBuildIdPath()
+{
+ return slrInstallDir() + "/BUILD_ID.txt";
+}
+
+/// Blocking HTTP GET that returns the response body as QByteArray.
+QByteArray httpGet(const QString& url, const int* cancelFlag,
+ const std::function<void(float)>& progressCb = nullptr,
+ const QString& destFile = {})
+{
+ QNetworkAccessManager mgr;
+ QNetworkReply* reply = mgr.get(QNetworkRequest(QUrl(url)));
+ QEventLoop loop;
+
+ QFile outFile;
+ if (!destFile.isEmpty()) {
+ outFile.setFileName(destFile);
+ if (!outFile.open(QIODevice::WriteOnly))
+ return {};
+ }
+
+ qint64 totalBytes = -1;
+ qint64 received = 0;
+
+ QObject::connect(reply, &QNetworkReply::readyRead, [&]() {
+ if (totalBytes < 0)
+ totalBytes = reply->header(QNetworkRequest::ContentLengthHeader).toLongLong();
+ QByteArray chunk = reply->readAll();
+ received += chunk.size();
+ if (outFile.isOpen())
+ outFile.write(chunk);
+ if (progressCb && totalBytes > 0)
+ progressCb(static_cast<float>(received) / static_cast<float>(totalBytes));
+ });
+
+ QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
+
+ // Poll cancel flag via a timer.
+ QTimer cancelTimer;
+ if (cancelFlag) {
+ QObject::connect(&cancelTimer, &QTimer::timeout, [&]() {
+ if (*cancelFlag != 0) {
+ reply->abort();
+ loop.quit();
+ }
+ });
+ cancelTimer.start(200);
+ }
+
+ loop.exec();
+
+ if (outFile.isOpen())
+ outFile.close();
+
+ if (reply->error() != QNetworkReply::NoError) {
+ reply->deleteLater();
+ if (!destFile.isEmpty())
+ QFile::remove(destFile);
+ return {};
+ }
+
+ QByteArray body;
+ if (destFile.isEmpty())
+ body = reply->readAll(); // small responses fully buffered
+ reply->deleteLater();
+ return body;
+}
+
+} // namespace
+
+bool isSlrInstalled()
+{
+ const QString script = slrRunScriptPath();
+ QFileInfo fi(script);
+ return fi.exists() && fi.isExecutable();
+}
+
+QString getSlrRunScript()
+{
+ return isSlrInstalled() ? slrRunScriptPath() : QString();
+}
+
+QString downloadSlr(const std::function<void(float)>& progressCb,
+ const std::function<void(const QString&)>& statusCb,
+ const int* cancelFlag)
+{
+ auto status = [&](const QString& msg) { if (statusCb) statusCb(msg); };
+ auto progress = [&](float p) { if (progressCb) progressCb(p); };
+
+ // 1. Check for updates.
+ status(QStringLiteral("Checking Steam Linux Runtime version..."));
+
+ const QByteArray remoteBuildIdRaw = httpGet(
+ QStringLiteral("%1/BUILD_ID.txt").arg(QLatin1String(BASE_URL)), cancelFlag);
+ if (remoteBuildIdRaw.isEmpty())
+ return QStringLiteral("Failed to fetch SLR BUILD_ID");
+
+ const QString remoteBuildId = QString::fromUtf8(remoteBuildIdRaw).trimmed();
+
+ // Read local BUILD_ID.
+ QString localBuildId;
+ {
+ QFile f(localBuildIdPath());
+ if (f.open(QIODevice::ReadOnly))
+ localBuildId = QString::fromUtf8(f.readAll()).trimmed();
+ }
+
+ if (localBuildId == remoteBuildId && isSlrInstalled()) {
+ MOBase::log::info("Steam Linux Runtime is already up to date");
+ status(QStringLiteral("Steam Linux Runtime is already up to date"));
+ progress(1.0f);
+ return {};
+ }
+
+ MOBase::log::info("Downloading Steam Linux Runtime (BUILD_ID: {})", remoteBuildId);
+
+ const QString installDir = slrInstallDir();
+ QDir().mkpath(installDir);
+ const QString archivePath = installDir + "/" + ARCHIVE_NAME;
+
+ // 2. Download.
+ status(QStringLiteral("Downloading Steam Linux Runtime (sniper, ~180 MB)..."));
+ httpGet(QStringLiteral("%1/%2").arg(QLatin1String(BASE_URL), QLatin1String(ARCHIVE_NAME)),
+ cancelFlag, progress, archivePath);
+ progress(1.0f);
+
+ if (!QFileInfo::exists(archivePath))
+ return QStringLiteral("Download failed or was cancelled");
+
+ // 3. Extract.
+ status(QStringLiteral("Extracting Steam Linux Runtime..."));
+ const QString extractedDir = installDir + "/" + EXTRACTED_DIR;
+ if (QFileInfo::exists(extractedDir))
+ QDir(extractedDir).removeRecursively();
+
+ QProcess tar;
+ tar.setWorkingDirectory(installDir);
+ tar.start(QStringLiteral("tar"), {QStringLiteral("xJf"), archivePath});
+ tar.waitForFinished(600000);
+ QFile::remove(archivePath);
+
+ if (tar.exitStatus() != QProcess::NormalExit || tar.exitCode() != 0)
+ return QStringLiteral("tar extraction failed (exit code %1)").arg(tar.exitCode());
+
+ if (!QFileInfo::exists(slrRunScriptPath()))
+ return QStringLiteral("Extraction succeeded but run script not found");
+
+ // 4. Save BUILD_ID.
+ {
+ QFile f(localBuildIdPath());
+ if (f.open(QIODevice::WriteOnly))
+ f.write(remoteBuildId.toUtf8());
+ }
+
+ MOBase::log::info("Steam Linux Runtime installed successfully");
+ status(QStringLiteral("Steam Linux Runtime ready"));
+ return {};
+}
diff --git a/src/src/slrmanager.h b/src/src/slrmanager.h
new file mode 100644
index 0000000..c800c2e
--- /dev/null
+++ b/src/src/slrmanager.h
@@ -0,0 +1,21 @@
+#ifndef SLRMANAGER_H
+#define SLRMANAGER_H
+
+#include <QString>
+#include <functional>
+
+/// Returns true if the SLR `run` script is present and executable.
+__attribute__((visibility("default"))) bool isSlrInstalled();
+
+/// Returns the path to the SLR `run` script, or empty if not installed.
+__attribute__((visibility("default"))) QString getSlrRunScript();
+
+/// Download and install SteamLinuxRuntime_sniper (~180 MB).
+/// Skips if already at the latest version (BUILD_ID check).
+/// Returns empty string on success, or an error message.
+__attribute__((visibility("default")))
+QString downloadSlr(const std::function<void(float)>& progressCb,
+ const std::function<void(const QString&)>& statusCb,
+ const int* cancelFlag);
+
+#endif // SLRMANAGER_H
diff --git a/src/src/steamdetection.cpp b/src/src/steamdetection.cpp
new file mode 100644
index 0000000..0d97ec0
--- /dev/null
+++ b/src/src/steamdetection.cpp
@@ -0,0 +1,195 @@
+#include "steamdetection.h"
+
+#include <QDir>
+#include <QDirIterator>
+#include <QFileInfo>
+#include <QStandardPaths>
+#include <uibase/log.h>
+
+// ============================================================================
+// SteamProtonInfo
+// ============================================================================
+
+QString SteamProtonInfo::wineBinary() const
+{
+ for (const char* sub : {"files/bin/wine", "dist/bin/wine"}) {
+ const QString p = QDir(path).filePath(QString::fromLatin1(sub));
+ if (QFileInfo::exists(p))
+ return p;
+ }
+ return {};
+}
+
+QString SteamProtonInfo::wineserverBinary() const
+{
+ for (const char* sub : {"files/bin/wineserver", "dist/bin/wineserver"}) {
+ const QString p = QDir(path).filePath(QString::fromLatin1(sub));
+ if (QFileInfo::exists(p))
+ return p;
+ }
+ return {};
+}
+
+QString SteamProtonInfo::binDir() const
+{
+ const QString wine = wineBinary();
+ return wine.isEmpty() ? QString() : QFileInfo(wine).absolutePath();
+}
+
+// ============================================================================
+// Steam Path Detection
+// ============================================================================
+
+QString findSteamPath()
+{
+ const QString home = QDir::homePath();
+ const QStringList candidates = {
+ home + "/.steam/steam",
+ home + "/.local/share/Steam",
+ home + "/.var/app/com.valvesoftware.Steam/.steam/steam",
+ home + "/snap/steam/common/.steam/steam",
+ };
+ for (const QString& p : candidates) {
+ if (QFileInfo::exists(p))
+ return p;
+ }
+ return {};
+}
+
+// ============================================================================
+// Proton Detection
+// ============================================================================
+
+namespace {
+
+/// Check if a Proton version is 10 or newer.
+bool isProton10OrNewer(const SteamProtonInfo& p)
+{
+ if (p.is_experimental || p.name.contains(QStringLiteral("Experimental")))
+ return true;
+
+ if (p.name.contains(QStringLiteral("CachyOS")))
+ return true;
+
+ if (p.name == QStringLiteral("LegacyRuntime") ||
+ p.name.contains(QStringLiteral("Runtime")))
+ return false;
+
+ auto extractMajor = [](const QString& s, QChar sep) -> int {
+ bool ok = false;
+ int v = s.section(sep, 0, 0).toInt(&ok);
+ return ok ? v : -1;
+ };
+
+ if (p.name.startsWith(QStringLiteral("GE-Proton"))) {
+ int v = extractMajor(p.name.mid(9), '-');
+ return v < 0 || v >= 10;
+ }
+ if (p.name.startsWith(QStringLiteral("Proton "))) {
+ int v = extractMajor(p.name.mid(7), '.');
+ return v < 0 || v >= 10;
+ }
+ if (p.name.startsWith(QStringLiteral("EM-"))) {
+ int v = extractMajor(p.name.mid(3), '.');
+ return v < 0 || v >= 10;
+ }
+
+ return true; // unknown format — allow
+}
+
+/// Scan a directory for Proton installations.
+void scanProtonDir(const QString& dir, bool isSteamBuiltin,
+ QVector<SteamProtonInfo>& out)
+{
+ QDir d(dir);
+ if (!d.exists())
+ return;
+
+ const QStringList entries = d.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
+ for (const QString& name : entries) {
+ const QString path = d.absoluteFilePath(name);
+
+ bool hasProton = QFileInfo::exists(path + "/proton");
+ bool hasVdf = QFileInfo::exists(path + "/compatibilitytool.vdf");
+
+ if (!hasProton && !hasVdf)
+ continue;
+
+ SteamProtonInfo info;
+ info.name = name;
+ info.path = path;
+ info.is_steam_proton = isSteamBuiltin;
+ info.is_experimental = name.contains(QStringLiteral("Experimental"));
+
+ if (isSteamBuiltin) {
+ if (info.is_experimental) {
+ info.config_name = QStringLiteral("proton_experimental");
+ } else {
+ const QString version = QString(name).remove(QStringLiteral("Proton "));
+ const QString major = version.section('.', 0, 0);
+ info.config_name = QStringLiteral("proton_%1").arg(major);
+ }
+ } else {
+ info.config_name = name;
+ }
+
+ out.append(std::move(info));
+ }
+}
+
+} // namespace
+
+QVector<SteamProtonInfo> findSteamProtons()
+{
+ QVector<SteamProtonInfo> protons;
+
+ const QString steamPath = findSteamPath();
+ if (steamPath.isEmpty())
+ return protons;
+
+ // 1. Steam's built-in Protons (only entries starting with "Proton").
+ {
+ QVector<SteamProtonInfo> builtin;
+ scanProtonDir(steamPath + "/steamapps/common", true, builtin);
+ // Keep only entries whose folder name starts with "Proton".
+ for (auto& p : builtin) {
+ if (p.name.startsWith(QStringLiteral("Proton")))
+ protons.append(std::move(p));
+ }
+ }
+
+ // 2. Custom Protons in user's compatibilitytools.d.
+ scanProtonDir(steamPath + "/compatibilitytools.d", false, protons);
+
+ // 3. System-level Protons.
+ scanProtonDir(QStringLiteral("/usr/share/steam/compatibilitytools.d"), false, protons);
+
+ // Filter to Proton 10+.
+ protons.erase(
+ std::remove_if(protons.begin(), protons.end(),
+ [](const SteamProtonInfo& p) { return !isProton10OrNewer(p); }),
+ protons.end());
+
+ // Filter to Protons with valid wine binaries.
+ protons.erase(
+ std::remove_if(protons.begin(), protons.end(),
+ [](const SteamProtonInfo& p) {
+ bool ok = !p.wineBinary().isEmpty();
+ if (!ok) {
+ MOBase::log::warn("Skipping Proton '{}': wine binary not found",
+ p.name);
+ }
+ return !ok;
+ }),
+ protons.end());
+
+ // Sort: Experimental first, then by name descending (newest first).
+ std::sort(protons.begin(), protons.end(),
+ [](const SteamProtonInfo& a, const SteamProtonInfo& b) {
+ if (a.is_experimental != b.is_experimental)
+ return a.is_experimental > b.is_experimental;
+ return a.name > b.name;
+ });
+
+ return protons;
+}
diff --git a/src/src/steamdetection.h b/src/src/steamdetection.h
new file mode 100644
index 0000000..fd8dc3f
--- /dev/null
+++ b/src/src/steamdetection.h
@@ -0,0 +1,32 @@
+#ifndef STEAMDETECTION_H
+#define STEAMDETECTION_H
+
+#include <QString>
+#include <QStringList>
+#include <QVector>
+
+/// Information about an installed Proton version.
+struct SteamProtonInfo {
+ QString name;
+ QString config_name;
+ QString path;
+ bool is_steam_proton = false;
+ bool is_experimental = false;
+
+ /// Get the path to the wine binary (files/bin/wine or dist/bin/wine).
+ QString wineBinary() const;
+
+ /// Get the path to the wineserver binary.
+ QString wineserverBinary() const;
+
+ /// Get the bin directory containing wine executables.
+ QString binDir() const;
+};
+
+/// Find the Steam installation path (returns empty string if not found).
+__attribute__((visibility("default"))) QString findSteamPath();
+
+/// Find all installed Proton versions (Proton 10+ only, sorted newest first).
+__attribute__((visibility("default"))) QVector<SteamProtonInfo> findSteamProtons();
+
+#endif // STEAMDETECTION_H
diff --git a/src/src/vdfparser.cpp b/src/src/vdfparser.cpp
new file mode 100644
index 0000000..cc6bd46
--- /dev/null
+++ b/src/src/vdfparser.cpp
@@ -0,0 +1,146 @@
+#include "vdfparser.h"
+
+namespace {
+
+class VdfParser
+{
+public:
+ explicit VdfParser(const QString& text) : m_text(text), m_pos(0) {}
+
+ VdfValue parse() { return parseObject(); }
+
+private:
+ const QString& m_text;
+ int m_pos;
+
+ QChar peek() const
+ {
+ return (m_pos < m_text.size()) ? m_text[m_pos] : QChar(0);
+ }
+
+ QChar advance()
+ {
+ return (m_pos < m_text.size()) ? m_text[m_pos++] : QChar(0);
+ }
+
+ bool atEnd() const { return m_pos >= m_text.size(); }
+
+ void skipWhitespaceAndComments()
+ {
+ for (;;) {
+ while (!atEnd() && peek().isSpace())
+ advance();
+
+ if (peek() == '/') {
+ advance();
+ if (peek() == '/') {
+ advance();
+ while (!atEnd() && peek() != '\n')
+ advance();
+ continue;
+ }
+ }
+ break;
+ }
+ }
+
+ QString parseQuotedString()
+ {
+ if (advance() != '"')
+ return {};
+
+ QString result;
+ for (;;) {
+ if (atEnd()) return {};
+ QChar c = advance();
+ if (c == '"') break;
+ if (c == '\\') {
+ if (atEnd()) return {};
+ QChar esc = advance();
+ if (esc == 'n') result += '\n';
+ else if (esc == 't') result += '\t';
+ else if (esc == '\\') result += '\\';
+ else if (esc == '"') result += '"';
+ else { result += '\\'; result += esc; }
+ } else {
+ result += c;
+ }
+ }
+ return result;
+ }
+
+ VdfValue parseObject()
+ {
+ QMap<QString, VdfValue> map;
+
+ for (;;) {
+ skipWhitespaceAndComments();
+ if (atEnd()) break;
+
+ if (peek() == '}') {
+ advance();
+ break;
+ }
+
+ if (peek() == '"') {
+ QString key = parseQuotedString();
+ if (key.isNull()) break;
+
+ skipWhitespaceAndComments();
+
+ if (peek() == '"') {
+ QString value = parseQuotedString();
+ map.insert(key, VdfValue(value));
+ } else if (peek() == '{') {
+ advance();
+ map.insert(key, parseObject());
+ } else {
+ break;
+ }
+ } else {
+ advance();
+ }
+ }
+
+ return VdfValue(std::move(map));
+ }
+};
+
+} // namespace
+
+VdfValue parseVdf(const QString& content)
+{
+ VdfParser parser(content);
+ return parser.parse();
+}
+
+AppManifest AppManifest::fromVdf(const QString& content)
+{
+ VdfValue root = parseVdf(content);
+ const VdfValue* appState = root.get(QStringLiteral("AppState"));
+ if (!appState)
+ return {};
+
+ AppManifest m;
+ m.app_id = appState->getString(QStringLiteral("appid"));
+ m.name = appState->getString(QStringLiteral("name"));
+ m.install_dir = appState->getString(QStringLiteral("installdir"));
+ m.state_flags = appState->getString(QStringLiteral("StateFlags")).toUInt();
+ return m;
+}
+
+QStringList parseLibraryFolders(const QString& content)
+{
+ QStringList paths;
+ VdfValue root = parseVdf(content);
+ const VdfValue* folders = root.get(QStringLiteral("libraryfolders"));
+ if (!folders || !folders->isObject())
+ return paths;
+
+ for (const VdfValue& entry : folders->asObject()) {
+ const QString path = entry.getString(QStringLiteral("path"));
+ if (!path.isEmpty())
+ paths.append(path);
+ }
+ return paths;
+}
diff --git a/src/src/vdfparser.h b/src/src/vdfparser.h
new file mode 100644
index 0000000..faafdb6
--- /dev/null
+++ b/src/src/vdfparser.h
@@ -0,0 +1,69 @@
+#ifndef VDFPARSER_H
+#define VDFPARSER_H
+
+#include <QMap>
+#include <QString>
+#include <QStringList>
+#include <QVariant>
+#include <memory>
+
+/// A VDF value — either a string or a nested object (QMap).
+class VdfValue
+{
+public:
+ enum Type { String, Object };
+
+ VdfValue() : m_type(String) {}
+ explicit VdfValue(const QString& s) : m_type(String), m_string(s) {}
+ explicit VdfValue(QMap<QString, VdfValue>&& obj)
+ : m_type(Object), m_object(std::move(obj)) {}
+
+ Type type() const { return m_type; }
+
+ bool isString() const { return m_type == String; }
+ bool isObject() const { return m_type == Object; }
+
+ const QString& asString() const { return m_string; }
+ const QMap<QString, VdfValue>& asObject() const { return m_object; }
+
+ /// Get a nested value by key (returns nullptr if not an object or key missing).
+ const VdfValue* get(const QString& key) const
+ {
+ if (m_type != Object) return nullptr;
+ auto it = m_object.find(key);
+ return (it != m_object.end()) ? &it.value() : nullptr;
+ }
+
+ /// Get a string value by key.
+ QString getString(const QString& key) const
+ {
+ if (auto* v = get(key); v && v->isString())
+ return v->asString();
+ return {};
+ }
+
+private:
+ Type m_type;
+ QString m_string;
+ QMap<QString, VdfValue> m_object;
+};
+
+/// Parsed appmanifest_*.acf file.
+struct AppManifest {
+ QString app_id;
+ QString name;
+ QString install_dir;
+ uint32_t state_flags = 0;
+
+ bool isInstalled() const { return state_flags == 4; }
+
+ static AppManifest fromVdf(const QString& content);
+};
+
+/// Parse a VDF file content into a root VdfValue (Object).
+VdfValue parseVdf(const QString& content);
+
+/// Parse libraryfolders.vdf and return the list of library paths.
+QStringList parseLibraryFolders(const QString& content);
+
+#endif // VDFPARSER_H
diff --git a/src/src/vfs/mo2filesystem.cpp b/src/src/vfs/mo2filesystem.cpp
index 7453631..5fe6be5 100644
--- a/src/src/vfs/mo2filesystem.cpp
+++ b/src/src/vfs/mo2filesystem.cpp
@@ -29,7 +29,8 @@ void fillStatForDir(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid);
void fillStatForFile(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid,
uint64_t size,
const std::chrono::system_clock::time_point& mtime,
- const std::string& real_path = {});
+ const std::string& real_path = {},
+ mode_t cached_mode = 0);
void invalidateLookupCache(Mo2FsContext* ctx, const std::string& dirPath);
void maybeLogCounters(Mo2FsContext* ctx)
@@ -143,12 +144,12 @@ void invalidateLookupCache(Mo2FsContext* ctx, const std::string& dirPath)
}
std::scoped_lock lock(ctx->lookup_cache_mutex);
- for (auto it = ctx->lookup_cache.begin(); it != ctx->lookup_cache.end();) {
- if (it->first.first == parentIno) {
- it = ctx->lookup_cache.erase(it);
- } else {
- ++it;
+ auto idxIt = ctx->lookup_cache_by_parent.find(parentIno);
+ if (idxIt != ctx->lookup_cache_by_parent.end()) {
+ for (const auto& childName : idxIt->second) {
+ ctx->lookup_cache.erase({parentIno, childName});
}
+ ctx->lookup_cache_by_parent.erase(idxIt);
}
}
@@ -385,6 +386,7 @@ struct ChildSnapshot
uint64_t size = 0;
std::chrono::system_clock::time_point mtime{};
std::string real_path;
+ mode_t cached_mode = 0; // permission bits from stat() or VfsNode cache
};
std::vector<ChildSnapshot> listChildrenSnapshot(
@@ -408,6 +410,24 @@ std::vector<ChildSnapshot> listChildrenSnapshot(
snap.size = child->file_info.size;
snap.mtime = child->file_info.mtime;
snap.real_path = child->file_info.real_path;
+
+ // Use cached mode bits if available, otherwise stat() once and cache.
+ if (child->file_info.cached_mode != 0) {
+ snap.cached_mode = child->file_info.cached_mode;
+ } else if (!snap.real_path.empty()) {
+ struct stat real_st;
+ if (::stat(snap.real_path.c_str(), &real_st) == 0) {
+ snap.cached_mode = real_st.st_mode & 0777;
+ // Cache in the tree node for future readdir calls (safe under shared lock
+ // because mode_t is atomic-width and this is a benign data race — worst
+ // case we stat() one extra time from another thread).
+ const_cast<VfsNode*>(child)->file_info.cached_mode = snap.cached_mode;
+ } else {
+ snap.cached_mode = 0644;
+ }
+ } else {
+ snap.cached_mode = 0644;
+ }
}
out.push_back(std::move(snap));
}
@@ -434,7 +454,7 @@ std::vector<Mo2FsContext::DirEntry> buildDirEntries(
entries.push_back(
Mo2FsContext::DirEntry{ctx->inodes->getOrCreate(childPath), child.name,
child.is_dir, child.size, child.mtime,
- child.real_path});
+ child.real_path, child.cached_mode});
}
return entries;
@@ -511,14 +531,8 @@ std::vector<char> buildReaddirBlob(
if (entry.is_dir) {
st.st_mode = S_IFDIR | 0755;
} else {
- // Preserve executable bits from the real file on disk.
- mode_t mode = 0644;
- if (!entry.real_path.empty()) {
- struct stat real_st;
- if (::stat(entry.real_path.c_str(), &real_st) == 0) {
- mode = real_st.st_mode & 0777;
- }
- }
+ // Use cached mode bits (populated during tree snapshot) — no stat() needed.
+ mode_t mode = entry.cached_mode != 0 ? entry.cached_mode : static_cast<mode_t>(0644);
st.st_mode = S_IFREG | mode;
}
@@ -635,7 +649,8 @@ void fillStatForDir(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid)
void fillStatForFile(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid,
uint64_t size,
const std::chrono::system_clock::time_point& mtime,
- const std::string& real_path)
+ const std::string& real_path,
+ mode_t cached_mode)
{
std::memset(st, 0, sizeof(struct stat));
st->st_ino = ino;
@@ -644,10 +659,11 @@ void fillStatForFile(struct stat* st, fuse_ino_t ino, uid_t uid, gid_t gid,
st->st_gid = gid;
st->st_size = static_cast<off_t>(size);
- // Preserve executable bits from the real file on disk so native Linux
- // executables added as mods can actually be launched through the VFS.
+ // Use cached mode bits if available, otherwise stat() the real file.
mode_t mode = 0644;
- if (!real_path.empty()) {
+ if (cached_mode != 0) {
+ mode = cached_mode;
+ } else if (!real_path.empty()) {
struct stat real_st;
if (::stat(real_path.c_str(), &real_st) == 0) {
mode = real_st.st_mode & 0777;
@@ -765,18 +781,32 @@ void mo2_init(void* userdata, struct fuse_conn_info* conn)
conn->want |= FUSE_CAP_SPLICE_READ;
}
- // More async readahead/writeback slots (default is 12, far too low).
- conn->max_background = 128;
- conn->congestion_threshold = 96;
+ // Allow concurrent submission of split direct I/O requests.
+ // Harmless when not triggered; helps if Wine opens files with O_DIRECT.
+ if (conn->capable & FUSE_CAP_ASYNC_DIO) {
+ conn->want |= FUSE_CAP_ASYNC_DIO;
+ }
+
+ // Softer dentry invalidation: mark entries as expired rather than
+ // forcefully removing them, reducing cascading cache evictions.
+ if (conn->capable & FUSE_CAP_EXPIRE_ONLY) {
+ conn->want |= FUSE_CAP_EXPIRE_ONLY;
+ }
+
+ // Maximize async I/O slots (default is 12). The kernel will still only
+ // dispatch as many as there are actual concurrent requests, so higher
+ // values just raise the ceiling without wasting memory.
+ conn->max_background = 32767;
+ conn->congestion_threshold = 24576;
- // Increase max read/write buffer to 1MB (kernel 4.20+ supports this).
- // Reduces FUSE round-trips for large file reads (textures, meshes, BSAs).
- constexpr unsigned int ONE_MB = 1048576;
- if (conn->max_readahead < ONE_MB) {
- conn->max_readahead = ONE_MB;
+ // Request large read/write buffers. The kernel caps these at its own
+ // compiled-in maximum, so overshooting is harmless.
+ constexpr unsigned int EIGHT_MB = 8 * 1048576;
+ if (conn->max_readahead < EIGHT_MB) {
+ conn->max_readahead = EIGHT_MB;
}
- if (conn->max_write < ONE_MB) {
- conn->max_write = ONE_MB;
+ if (conn->max_write < EIGHT_MB) {
+ conn->max_write = EIGHT_MB;
}
std::fprintf(stderr,
@@ -831,6 +861,7 @@ void mo2_lookup(fuse_req_t req, fuse_ino_t parent, const char* name)
lce.child_ino = 0;
lce.entry = e;
ctx->lookup_cache[cacheKey] = lce;
+ ctx->lookup_cache_by_parent[cacheKey.first].push_back(cacheKey.second);
}
fuse_reply_entry(req, &e);
@@ -889,6 +920,7 @@ void mo2_lookup(fuse_req_t req, fuse_ino_t parent, const char* name)
lce.child_ino = childIno;
lce.entry = e;
ctx->lookup_cache[cacheKey] = lce;
+ ctx->lookup_cache_by_parent[cacheKey.first].push_back(cacheKey.second);
}
fuse_reply_entry(req, &e);
@@ -1340,18 +1372,17 @@ void mo2_read(fuse_req_t req, fuse_ino_t /*ino*/, size_t size, off_t off,
openedTempFd = true;
}
- std::vector<char> out(size);
- const ssize_t n = pread(localFd, out.data(), size, off);
+ // Zero-copy read: splice data directly from backing fd to /dev/fuse,
+ // bypassing userspace entirely. The kernel transfers data kernel-to-kernel.
+ struct fuse_bufvec buf = FUSE_BUFVEC_INIT(size);
+ buf.buf[0].flags = static_cast<fuse_buf_flags>(FUSE_BUF_IS_FD | FUSE_BUF_FD_SEEK);
+ buf.buf[0].fd = localFd;
+ buf.buf[0].pos = off;
+ fuse_reply_data(req, &buf, FUSE_BUF_SPLICE_MOVE);
+
if (openedTempFd) {
close(localFd);
}
-
- if (n < 0) {
- fuse_reply_err(req, EIO);
- return;
- }
-
- fuse_reply_buf(req, out.data(), static_cast<size_t>(n));
}
void mo2_write(fuse_req_t req, fuse_ino_t /*ino*/, const char* buf, size_t size,
diff --git a/src/src/vfs/mo2filesystem.h b/src/src/vfs/mo2filesystem.h
index 4ebf2bd..e105c07 100644
--- a/src/src/vfs/mo2filesystem.h
+++ b/src/src/vfs/mo2filesystem.h
@@ -56,6 +56,7 @@ struct Mo2FsContext
uint64_t size = 0;
std::chrono::system_clock::time_point mtime{};
std::string real_path;
+ mode_t cached_mode = 0;
};
struct OpenDir
{
@@ -97,6 +98,9 @@ struct Mo2FsContext
}
};
std::unordered_map<std::pair<fuse_ino_t, std::string>, LookupCacheEntry, PairHash> lookup_cache;
+ // Reverse index: parent_ino → set of normalized child names in lookup_cache.
+ // Makes invalidation O(children) instead of O(total_cache_size).
+ std::unordered_map<fuse_ino_t, std::vector<std::string>> lookup_cache_by_parent;
mutable std::mutex lookup_cache_mutex;
std::atomic<uint64_t> next_dh{1};
diff --git a/src/src/vfs/vfstree.h b/src/src/vfs/vfstree.h
index 12baeea..2e33a9a 100644
--- a/src/src/vfs/vfstree.h
+++ b/src/src/vfs/vfstree.h
@@ -18,6 +18,7 @@ struct VfsFileInfo
std::chrono::system_clock::time_point mtime{};
std::string origin;
bool is_backing = false;
+ mode_t cached_mode = 0; // permission bits from stat() at tree-build time
};
struct CachedBaseFile