diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-08 03:30:04 -0500 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-08 03:30:04 -0500 |
| commit | 3de4056df4ea5d0afbcea388ad8d681daea1aac3 (patch) | |
| tree | fc5978451f9f51b84268f45bd2fa6f5d4a17baaf /src | |
| parent | 5ba23a6d7a40052d9e1d4bd0cf018cfe7814d03a (diff) | |
Remove NaK/Rust dependency, port remaining functionality to native C++
Replace NaK Rust crate and nak_ffi with native C++ implementations:
- Game detection (Steam, Heroic, Bottles), VDF parser, icon extraction
- Prefix symlinks, SLR manager, Steam path detection
- Known games database
Add FUSE VFS optimizations: zero-copy reads via fuse_reply_data,
default_permissions mount option, FUSE_CAP_ASYNC_DIO, FUSE_CAP_EXPIRE_ONLY,
lookup cache parent index, cached mode bits, increased I/O buffer sizes.
Update build system, prefix setup, and various fixes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/src/CMakeLists.txt | 11 | ||||
| -rw-r--r-- | src/src/fuseconnector.cpp | 5 | ||||
| -rw-r--r-- | src/src/gamedetection.cpp | 530 | ||||
| -rw-r--r-- | src/src/gamedetection.h | 33 | ||||
| -rw-r--r-- | src/src/iconextractor.cpp | 276 | ||||
| -rw-r--r-- | src/src/iconextractor.h | 11 | ||||
| -rw-r--r-- | src/src/instancemanagerdialog.cpp | 26 | ||||
| -rw-r--r-- | src/src/knowngames.h | 194 | ||||
| -rw-r--r-- | src/src/main.cpp | 18 | ||||
| -rw-r--r-- | src/src/mainwindow.cpp | 22 | ||||
| -rw-r--r-- | src/src/organizercore.cpp | 1 | ||||
| -rw-r--r-- | src/src/prefixsetuprunner.cpp | 47 | ||||
| -rw-r--r-- | src/src/prefixsymlinks.cpp | 143 | ||||
| -rw-r--r-- | src/src/prefixsymlinks.h | 12 | ||||
| -rw-r--r-- | src/src/protonlauncher.cpp | 23 | ||||
| -rw-r--r-- | src/src/selfupdater.cpp | 18 | ||||
| -rw-r--r-- | src/src/settingsdialogproton.cpp | 214 | ||||
| -rw-r--r-- | src/src/slrmanager.cpp | 186 | ||||
| -rw-r--r-- | src/src/slrmanager.h | 21 | ||||
| -rw-r--r-- | src/src/steamdetection.cpp | 195 | ||||
| -rw-r--r-- | src/src/steamdetection.h | 32 | ||||
| -rw-r--r-- | src/src/vdfparser.cpp | 146 | ||||
| -rw-r--r-- | src/src/vdfparser.h | 69 | ||||
| -rw-r--r-- | src/src/vfs/mo2filesystem.cpp | 107 | ||||
| -rw-r--r-- | src/src/vfs/mo2filesystem.h | 4 | ||||
| -rw-r--r-- | src/src/vfs/vfstree.h | 1 |
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 |
