#include "gamegamebryo.h" #include #include "bsainvalidation.h" #include "dataarchives.h" #include "gamebryomoddatacontent.h" #include "gamebryosavegame.h" #include "gameplugins.h" #include "iprofile.h" #include "log.h" #include "registry.h" #include "savegameinfo.h" #include "scopeguard.h" #include "scriptextender.h" #include "utility.h" #include "vdf_parser.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef _WIN32 #include #include #include #include #include #endif #ifndef _WIN32 #include "gamedetection.h" #include "steamdetection.h" #endif #include #include #include // Local resolveFileCaseInsensitive moved to MOBase::resolveFileCaseInsensitive using MOBase::resolveFileCaseInsensitive; GameGamebryo::GameGamebryo() {} void GameGamebryo::detectGame() { m_GamePath = identifyGamePath(); m_MyGamesPath = determineMyGamesPath(gameName(), !m_GamePath.isEmpty()); } bool GameGamebryo::init(MOBase::IOrganizer* moInfo) { m_Organizer = moInfo; m_Organizer->onAboutToRun([this](const auto& binary) { return prepareIni(binary); }); return true; } bool GameGamebryo::isInstalled() const { return !m_GamePath.isEmpty(); } QIcon GameGamebryo::gameIcon() const { return MOBase::iconForExecutable(gameDirectory().absoluteFilePath(binaryName())); } QDir GameGamebryo::gameDirectory() const { return QDir(m_GamePath); } QDir GameGamebryo::dataDirectory() const { return gameDirectory().absoluteFilePath("Data"); } void GameGamebryo::setGamePath(const QString& path) { m_GamePath = path; } QDir GameGamebryo::documentsDirectory() const { return m_MyGamesPath; } QDir GameGamebryo::savesDirectory() const { return QDir(myGamesPath() + "/Saves"); } std::vector> GameGamebryo::listSaves(QDir folder) const { QStringList filters; filters << QString("*.") + savegameExtension(); std::vector> saves; for (auto info : folder.entryInfoList(filters, QDir::Files)) { try { saves.push_back(makeSaveGame(info.filePath())); } catch (std::exception& e) { MOBase::log::error("{}", e.what()); continue; } } return saves; } void GameGamebryo::setGameVariant(const QString& variant) { m_GameVariant = variant; } QString GameGamebryo::binaryName() const { return gameShortName() + ".exe"; } MOBase::IPluginGame::LoadOrderMechanism GameGamebryo::loadOrderMechanism() const { return LoadOrderMechanism::FileTime; } MOBase::IPluginGame::SortMechanism GameGamebryo::sortMechanism() const { return SortMechanism::LOOT; } bool GameGamebryo::looksValid(QDir const& path) const { // Check for .exe for now. return path.exists(binaryName()); } QString GameGamebryo::gameVersion() const { // We try the file version, but if it looks invalid (starts with the fallback // version), we look the product version instead. If the product version is // not empty, we use it. QString binaryAbsPath = gameDirectory().absoluteFilePath(binaryName()); QString version = MOBase::getFileVersion(binaryAbsPath); if (version.startsWith(FALLBACK_GAME_VERSION)) { QString pversion = MOBase::getProductVersion(binaryAbsPath); if (!pversion.isEmpty()) { version = pversion; } } return version; } QString GameGamebryo::getLauncherName() const { return gameShortName() + "Launcher.exe"; } WORD GameGamebryo::getArch(QString const& program) const { #ifdef _WIN32 WORD arch = 0; // This *really* needs to be factored out std::wstring app_name = L"\\\\?\\" + QDir::toNativeSeparators(this->gameDirectory().absoluteFilePath(program)) .toStdWString(); WIN32_FIND_DATAW FindFileData; HANDLE hFind = ::FindFirstFileW(app_name.c_str(), &FindFileData); // exit if the binary was not found if (hFind == INVALID_HANDLE_VALUE) return arch; HANDLE hFile = INVALID_HANDLE_VALUE; HANDLE hMapping = INVALID_HANDLE_VALUE; LPVOID addrHeader = nullptr; PIMAGE_NT_HEADERS peHdr = nullptr; hFile = CreateFileW(app_name.c_str(), GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_READONLY, NULL); if (hFile == INVALID_HANDLE_VALUE) goto cleanup; hMapping = CreateFileMappingW(hFile, NULL, PAGE_READONLY | SEC_IMAGE, 0, 0, program.toStdWString().c_str()); if (hMapping == INVALID_HANDLE_VALUE) goto cleanup; addrHeader = MapViewOfFile(hMapping, FILE_MAP_READ, 0, 0, 0); if (addrHeader == NULL) goto cleanup; // couldn't memory map the file peHdr = ImageNtHeader(addrHeader); if (peHdr == NULL) goto cleanup; // couldn't read the header arch = peHdr->FileHeader.Machine; cleanup: // release all of our handles FindClose(hFind); if (hFile != INVALID_HANDLE_VALUE) CloseHandle(hFile); if (hMapping != INVALID_HANDLE_VALUE) CloseHandle(hMapping); return arch; #else Q_UNUSED(program); return 0; #endif } QFileInfo GameGamebryo::findInGameFolder(const QString& relativePath) const { return QFileInfo(m_GamePath + "/" + relativePath); } QString GameGamebryo::identifyGamePath() const { #ifndef _WIN32 // Detect game installations on Linux. const GameScanResult scanResult = detectAllGames(); QString shortName = gameShortName(); QString fullName = gameName(); auto tokensMatch = [](const QString& detected, const QString& candidate) { const QString detectedLower = detected.toLower(); const QStringList tokens = candidate.toLower().split(QRegularExpression("[^a-z0-9]+"), Qt::SkipEmptyParts); bool anyToken = false; for (const QString& token : tokens) { if (token.size() < 3) { continue; } anyToken = true; if (!detectedLower.contains(token)) { return false; } } return anyToken; }; for (const DetectedGame& game : scanResult.games) { if (game.name.compare(fullName, Qt::CaseInsensitive) == 0 || game.name.compare(shortName, Qt::CaseInsensitive) == 0 || game.name.contains(fullName, Qt::CaseInsensitive) || game.name.contains(shortName, Qt::CaseInsensitive) || tokensMatch(game.name, fullName) || tokensMatch(game.name, shortName)) { if (looksValid(QDir(game.install_path))) { return game.install_path; } } } return {}; #elif defined(_WIN32) QString path = "Software\\Bethesda Softworks\\" + gameShortName(); return findInRegistry(HKEY_LOCAL_MACHINE, path.toStdWString().c_str(), L"Installed Path"); #else return {}; #endif } bool GameGamebryo::prepareIni(const QString&) { const auto profile = m_Organizer->profile(); QString basePath = profile->localSettingsEnabled() ? profile->absolutePath() : documentsDirectory().absolutePath(); // Ensure all INI files exist with adequate content before writing settings. // On Linux, the game launcher often doesn't work properly and can't create // the INI files. If an INI is missing or is a stub (< 200 bytes, meaning // only MO2-written keys exist), seed it from the game's default INI template. ensureIniFilesExist(basePath); if (!iniFiles().isEmpty()) { // Resolve case-insensitively (e.g., fallout.ini vs Fallout.ini on Linux) QString profileIni = resolveFileCaseInsensitive(basePath + "/" + iniFiles()[0]); QString setting = readIniValue(profileIni, "Launcher", "bEnableFileSelection", "0"); if (setting.toLong() != 1) { MOBase::WriteRegistryValue("Launcher", "bEnableFileSelection", "1", profileIni); } } return true; } #ifndef _WIN32 // Ensure both the canonical (proper-case) and lowercase versions of an INI file // exist in a directory. One will be the real file, the other a symlink. // This prevents case-sensitivity issues on Linux where different code paths or // the game itself may reference either casing. static void ensureCaseAliases(const QDir& dir, const QString& canonicalName, const QString& actualFileName) { QString lowerName = canonicalName.toLower(); auto ensureAlias = [&](const QString& aliasName) { if (aliasName != actualFileName) { QString aliasPath = dir.absoluteFilePath(aliasName); if (!QFileInfo::exists(aliasPath)) { QFile::link(actualFileName, aliasPath); } } }; // Ensure the canonical (proper-case) name exists ensureAlias(canonicalName); // Ensure the lowercase name exists if (lowerName != canonicalName) { ensureAlias(lowerName); } } #endif void GameGamebryo::ensureIniFilesExist(const QString& basePath) { // Make sure the target directory exists QDir baseDir(basePath); if (!baseDir.exists()) { baseDir.mkpath("."); } for (const QString& iniFile : iniFiles()) { QString targetPath = basePath + "/" + iniFile; QFileInfo targetInfo(targetPath); // Check if the target file already has adequate content // (> 200 bytes = more than just MO2's BSA invalidation stub) if (targetInfo.exists() && targetInfo.size() > 200) { continue; } #ifndef _WIN32 // Remove broken symlinks — QFileInfo::exists() follows symlinks and // returns false for dangling ones, but QFile::copy() fails with // "File exists" because the symlink inode is still there. if (targetInfo.isSymLink() && !targetInfo.exists()) { QFile::remove(targetPath); } #endif #ifndef _WIN32 // On Linux, search for a differently-cased version of this INI file // (e.g., FalloutPrefs.ini when we expect falloutprefs.ini). // We can't use resolveFileCaseInsensitive here because the exact-case // file might exist as an empty stub while the real one has different case. QString caseMismatchPath; { const QString target = targetInfo.fileName(); const QStringList entries = baseDir.entryList(QDir::Files | QDir::Hidden | QDir::System); for (const QString& entry : entries) { if (entry != target && entry.compare(target, Qt::CaseInsensitive) == 0) { caseMismatchPath = baseDir.absoluteFilePath(entry); break; } } } // If a differently-cased version exists with adequate content, // replace the stub (if any) with a copy of the real file. if (!caseMismatchPath.isEmpty()) { QFileInfo altInfo(caseMismatchPath); if (altInfo.exists() && altInfo.size() > 200) { if (targetInfo.exists()) { QFile::remove(targetPath); } if (QFile::copy(altInfo.absoluteFilePath(), targetPath)) { QFile::setPermissions( targetPath, QFile::permissions(targetPath) | QFile::WriteUser | QFile::WriteOwner); MOBase::log::info("Copied case-mismatched INI '{}' -> '{}'", altInfo.fileName(), iniFile); } else { MOBase::log::warn("Failed to copy case-mismatched INI '{}' -> '{}'", altInfo.fileName(), iniFile); } // Ensure both proper-case and lowercase aliases exist ensureCaseAliases(baseDir, iniFile, targetInfo.fileName()); continue; } } #endif // The INI doesn't exist or is a stub, and no adequate differently-cased // version was found. Try to seed from the game's default INI template // (e.g., Fallout_default.ini, Oblivion_default.ini). QString baseName = QFileInfo(iniFile).completeBaseName(); QString defaultIniName = baseName + "_default.ini"; QString defaultIniPath = resolveFileCaseInsensitive(gameDirectory().absoluteFilePath(defaultIniName)); if (QFileInfo::exists(defaultIniPath)) { // Remove the stub file if it exists so we can replace it if (targetInfo.exists()) { QFile::remove(targetPath); } QFile srcFile(defaultIniPath); if (srcFile.copy(targetPath)) { // Make the copy writable QFile::setPermissions( targetPath, QFile::permissions(targetPath) | QFile::WriteUser | QFile::WriteOwner); MOBase::log::info("Seeded '{}' from default INI '{}'", iniFile, defaultIniPath); } else { MOBase::log::warn("Failed to copy default INI '{}' -> '{}': {} " "(srcExists={}, srcSize={}, targetExists={}, dirExists={})", defaultIniPath, targetPath, srcFile.errorString(), QFileInfo::exists(defaultIniPath), QFileInfo(defaultIniPath).size(), QFileInfo::exists(targetPath), QFileInfo(targetPath).dir().exists()); } } #ifndef _WIN32 // After creating/finding the INI, ensure both proper-case and lowercase // versions exist so that any code path or the game itself can find it // regardless of which casing it uses. QFileInfo finalInfo(targetPath); if (finalInfo.exists() || finalInfo.isSymLink()) { ensureCaseAliases(baseDir, iniFile, finalInfo.fileName()); } #endif } } QString GameGamebryo::readIniValue(const QString& iniFile, const QString& section, const QString& key, const QString& defaultValue) { // Read INI values directly without QSettings to avoid QSettings // misinterpreting backslashes as line continuations. QFile file(iniFile); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { return defaultValue; } QString sectionHeader = "[" + section + "]"; QTextStream in(&file); bool inSection = false; while (!in.atEnd()) { QString line = in.readLine().trimmed(); if (line.startsWith('[') && line.endsWith(']')) { if (inSection) { break; // Left the target section without finding key } if (line.compare(sectionHeader, Qt::CaseInsensitive) == 0) { inSection = true; } continue; } if (inSection && !line.isEmpty() && !line.startsWith(';') && !line.startsWith('#')) { int eqPos = line.indexOf('='); if (eqPos > 0) { QString existingKey = line.left(eqPos).trimmed(); if (existingKey.compare(key, Qt::CaseInsensitive) == 0) { return line.mid(eqPos + 1).trimmed(); } } } } return defaultValue; } QString GameGamebryo::selectedVariant() const { return m_GameVariant; } QString GameGamebryo::myGamesPath() const { return m_MyGamesPath; } /*static*/ QString GameGamebryo::getLootPath() { #ifdef _WIN32 return findInRegistry(HKEY_LOCAL_MACHINE, L"Software\\LOOT", L"Installed Path") + "/Loot.exe"; #else // On Linux, look for loot in PATH QString systemLoot = QStandardPaths::findExecutable("loot"); if (!systemLoot.isEmpty()) return systemLoot; return "loot"; #endif } static QString readFluorinePrefixPath() { QString configRoot = QStandardPaths::writableLocation(QStandardPaths::ConfigLocation); if (configRoot.isEmpty()) configRoot = QDir::homePath() + "/.config"; QString configPath = QDir(configRoot).filePath("fluorine/config.json"); QFile f(configPath); if (!f.open(QIODevice::ReadOnly)) return {}; auto json = QJsonDocument::fromJson(f.readAll()); if (!json.isObject()) return {}; return json.object().value("prefix_path").toString().trimmed(); } QString GameGamebryo::localAppFolder() { #ifdef _WIN32 QString result = getKnownFolderPath(FOLDERID_LocalAppData, false); if (result.isEmpty()) { // fallback: try the registry result = getSpecialPath("Local AppData"); } return result; #else // On Linux, AppData/Local lives inside the Wine prefix. const QString configuredPrefix = readFluorinePrefixPath(); if (!configuredPrefix.isEmpty()) { const QString appDataLocal = QDir(configuredPrefix).filePath("drive_c/users/steamuser/AppData/Local"); if (QDir(appDataLocal).exists() || QDir().mkpath(appDataLocal)) { return appDataLocal; } } // Fallback: search Steam Proton prefixes const QString steamRoot = QDir::homePath() + "/.steam/steam/steamapps/compatdata"; QDir compatDir(steamRoot); if (compatDir.exists()) { for (const QString& appId : compatDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) { const QString appDataLocal = steamRoot + "/" + appId + "/pfx/drive_c/users/steamuser/AppData/Local"; if (QDir(appDataLocal).exists()) { return appDataLocal; } } } // Last resort: GenericDataLocation (won't work for Wine games but // prevents crashes) MOBase::log::warn("localAppFolder: could not find Wine prefix " "AppData/Local, falling back to XDG data location"); return QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation); #endif } void GameGamebryo::copyToProfile(QString const& sourcePath, QDir const& destinationDirectory, QString const& sourceFileName) { copyToProfile(sourcePath, destinationDirectory, sourceFileName, sourceFileName); } void GameGamebryo::copyToProfile(QString const& sourcePath, QDir const& destinationDirectory, QString const& sourceFileName, QString const& destinationFileName) { // Use case-insensitive check so we don't create duplicates on Linux // (e.g. a profile with "SkyrimPrefs.ini" from Windows is not overwritten // by a new "skyrimprefs.ini" copy). QString filePath = resolveFileCaseInsensitive( destinationDirectory.absoluteFilePath(destinationFileName)); if (!QFileInfo(filePath).exists()) { QString sourceFile = sourcePath + "/" + sourceFileName; // On Linux, try case-insensitive match if the exact source doesn't exist sourceFile = resolveFileCaseInsensitive(sourceFile); if (!MOBase::shellCopy(sourceFile, filePath)) { // if copy file fails, create the file empty QFile outputFile(filePath); if (!outputFile.open(QIODevice::WriteOnly)) { MOBase::log::warn("Failed to create fallback file '{}': {}", filePath, outputFile.errorString()); } } } #ifndef _WIN32 // Ensure both proper-case and lowercase versions exist (one as symlink) QFileInfo actualFile(filePath); if (actualFile.exists() || actualFile.isSymLink()) { ensureCaseAliases(destinationDirectory, destinationFileName, actualFile.fileName()); } #endif } QString GameGamebryo::localAppName() const { // Default: derive from the My Games path. If myGamesPath() is // e.g. ".../Documents/My Games/Skyrim Special Edition", we return // "Skyrim Special Edition". This matches the AppData/Local subfolder // for the vast majority of Bethesda games. const QString mgp = myGamesPath(); if (!mgp.isEmpty()) { const QString leaf = QDir(mgp).dirName(); if (!leaf.isEmpty() && leaf != QStringLiteral(".")) { return leaf; } } // Fallback: gameShortName is used by the base mappings() return gameShortName(); } MappingType GameGamebryo::mappings() const { MappingType result; for (const QString& profileFile : {"plugins.txt", "loadorder.txt"}) { result.push_back({m_Organizer->profilePath() + "/" + profileFile, localAppFolder() + "/" + localAppName() + "/" + profileFile, false}); } return result; } #ifdef _WIN32 std::unique_ptr GameGamebryo::getRegValue(HKEY key, LPCWSTR path, LPCWSTR value, DWORD flags, LPDWORD type) { DWORD size = 0; HKEY subKey; LONG res = ::RegOpenKeyExW(key, path, 0, KEY_QUERY_VALUE | KEY_WOW64_32KEY, &subKey); if (res != ERROR_SUCCESS) { res = ::RegOpenKeyExW(key, path, 0, KEY_QUERY_VALUE | KEY_WOW64_64KEY, &subKey); if (res != ERROR_SUCCESS) return std::unique_ptr(); } res = ::RegGetValueW(subKey, L"", value, flags, type, nullptr, &size); if (res == ERROR_FILE_NOT_FOUND || res == ERROR_UNSUPPORTED_TYPE) { return std::unique_ptr(); } if (res != ERROR_SUCCESS && res != ERROR_MORE_DATA) { throw MOBase::MyException( QObject::tr("failed to query registry path (preflight): %1").arg(res, 0, 16)); } std::unique_ptr result(new BYTE[size]); res = ::RegGetValueW(subKey, L"", value, flags, type, result.get(), &size); if (res != ERROR_SUCCESS) { throw MOBase::MyException( QObject::tr("failed to query registry path (read): %1").arg(res, 0, 16)); } return result; } QString GameGamebryo::findInRegistry(HKEY baseKey, LPCWSTR path, LPCWSTR value) { std::unique_ptr buffer = getRegValue(baseKey, path, value, RRF_RT_REG_SZ | RRF_NOEXPAND); return QString::fromUtf16(reinterpret_cast(buffer.get())); } QString GameGamebryo::getKnownFolderPath(REFKNOWNFOLDERID folderId, bool useDefault) { PWSTR path = nullptr; ON_BLOCK_EXIT([&]() { if (path != nullptr) ::CoTaskMemFree(path); }); if (::SHGetKnownFolderPath(folderId, useDefault ? KF_FLAG_DEFAULT_PATH : 0, NULL, &path) == S_OK) { return QDir::fromNativeSeparators(QString::fromWCharArray(path)); } else { return QString(); } } QString GameGamebryo::getSpecialPath(const QString& name) { QString base = findInRegistry( HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders", name.toStdWString().c_str()); WCHAR temp[MAX_PATH]; if (::ExpandEnvironmentStringsW(base.toStdWString().c_str(), temp, MAX_PATH) != 0) { return QString::fromWCharArray(temp); } else { return base; } } #endif // _WIN32 QString GameGamebryo::determineMyGamesPath(const QString& gameName, bool createIfMissing) { const QString pattern = "%1/My Games/" + gameName; auto tryDir = [&](const QString& dir) -> std::optional { if (dir.isEmpty()) { return {}; } const auto path = pattern.arg(dir); if (!QFileInfo(path).exists()) { return {}; } return path; }; #ifdef _WIN32 // a) this is the way it should work. get the configured My Documents directory if (auto d = tryDir(getKnownFolderPath(FOLDERID_Documents, false))) { return *d; } // b) if there is no directory there, look in the default directory if (auto d = tryDir(getKnownFolderPath(FOLDERID_Documents, true))) { return *d; } // c) finally, look in the registry. This is discouraged if (auto d = tryDir(getSpecialPath("Personal"))) { return *d; } #else // On Linux, My Games is inside the Wine prefix's Documents folder. // Check common Wine prefix locations for steamuser Documents. QStringList prefixDocPaths; // First check the configured prefix from fluorine config (most reliable). const QString configuredPrefix = readFluorinePrefixPath(); if (!configuredPrefix.isEmpty()) { const QString configuredDocs = QDir(configuredPrefix).filePath("drive_c/users/steamuser/Documents"); prefixDocPaths.append(configuredDocs); } // Standard Steam Proton prefix paths QString steamRoot = QDir::homePath() + "/.steam/steam/steamapps/compatdata"; QDir compatDir(steamRoot); if (compatDir.exists()) { QStringList appIds = compatDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot); for (const QString& appId : appIds) { prefixDocPaths.append(steamRoot + "/" + appId + "/pfx/drive_c/users/steamuser/Documents"); } } // Also check XDG Documents (for native games or manual setups) prefixDocPaths.append( QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation)); for (const QString& docPath : prefixDocPaths) { if (auto d = tryDir(docPath)) { MOBase::log::debug("determineMyGamesPath: found '{}' for game '{}'", *d, gameName); return *d; } } // No existing directory found. By default we return the expected path // (under the configured prefix) WITHOUT creating it — every Bethesda // plugin constructs itself at startup, and pre-creating `My Games/` // for every possible title (Fallout4, Oblivion, Morrowind, …) clutters // the user's prefix with empty folders for games they don't have. // See issue #55. // // Callers that actually need the directory (profile initialization, // save writes, ini deployment) should mkpath on demand or pass // createIfMissing=true explicitly. if (!configuredPrefix.isEmpty()) { const QString configuredDocs = QDir(configuredPrefix).filePath("drive_c/users/steamuser/Documents"); const QString newPath = pattern.arg(configuredDocs); if (createIfMissing) { if (QDir().mkpath(newPath)) { MOBase::log::info("determineMyGamesPath: created '{}' for game '{}'", newPath, gameName); return newPath; } } else { // Return the expected path for reference; callers may check for // existence before writing. return newPath; } } MOBase::log::debug( "determineMyGamesPath: no existing My Games path for '{}' (create=false)", gameName); #endif return {}; } QString GameGamebryo::parseEpicGamesLocation(const QStringList& manifests) { #ifdef _WIN32 // Use the registry entry to find the EGL Data dir first, just in case something // changes QString manifestDir = findInRegistry( HKEY_LOCAL_MACHINE, L"Software\\Epic Games\\EpicGamesLauncher", L"AppDataPath"); if (manifestDir.isEmpty()) manifestDir = getKnownFolderPath(FOLDERID_ProgramData, false) + "\\Epic\\EpicGamesLauncher\\Data\\"; manifestDir += "Manifests"; #else // Epic Games on Linux (via Heroic or Legendary) QString manifestDir = QDir::homePath() + "/.config/heroic/store_cache/egl_manifests"; if (!QDir(manifestDir).exists()) { // Try Legendary manifestDir = QDir::homePath() + "/.config/legendary/installed.json"; // Legendary uses a different format - skip for now return ""; } #endif QDir epicManifests(manifestDir, "*.item", QDir::SortFlags(QDir::Name | QDir::IgnoreCase), QDir::Files); if (epicManifests.exists()) { QDirIterator it(epicManifests); while (it.hasNext()) { QString manifestFile = it.next(); QFile manifest(manifestFile); if (!manifest.open(QIODevice::ReadOnly)) { qWarning("Couldn't open Epic Games manifest file."); continue; } QByteArray manifestData = manifest.readAll(); QJsonDocument manifestJson(QJsonDocument::fromJson(manifestData)); if (manifests.contains(manifestJson["AppName"].toString())) { return manifestJson["InstallLocation"].toString(); } } } return ""; } QString GameGamebryo::parseSteamLocation(const QString& appid, const QString& directoryName) { #ifndef _WIN32 QString steamLocation = findSteamPath(); #elif defined(_WIN32) QString path = "Software\\Valve\\Steam"; QString steamLocation = findInRegistry(HKEY_CURRENT_USER, path.toStdWString().c_str(), L"SteamPath"); #else // Fallback: common Steam locations on Linux QString steamLocation = QDir::homePath() + "/.steam/steam"; if (!QDir(steamLocation).exists()) { steamLocation = QDir::homePath() + "/.local/share/Steam"; } #endif if (!steamLocation.isEmpty()) { QString steamLibraryLocation; QString steamLibraries(steamLocation + "/" + "config" + "/" + "libraryfolders.vdf"); if (QFile(steamLibraries).exists()) { std::ifstream file(steamLibraries.toStdString()); auto root = tyti::vdf::read(file); for (auto child : root.childs) { tyti::vdf::object* library = child.second.get(); auto apps = library->childs["apps"]; if (apps->attribs.contains(appid.toStdString())) { steamLibraryLocation = QString::fromStdString(library->attribs["path"]); break; } } } if (!steamLibraryLocation.isEmpty()) { if (!directoryName.isEmpty()) { QString gameLocation = steamLibraryLocation + "/" + "steamapps" + "/" + "common" + "/" + directoryName; if (QDir(gameLocation).exists()) { return gameLocation; } } // Fallback: resolve install dir from appmanifest_.acf. QString manifestPath = steamLibraryLocation + "/steamapps/appmanifest_" + appid + ".acf"; if (QFile::exists(manifestPath)) { std::ifstream manifestFile(manifestPath.toStdString()); auto manifest = tyti::vdf::read(manifestFile); auto appStateIt = manifest.childs.find("AppState"); if (appStateIt != manifest.childs.end()) { const auto& attrs = appStateIt->second->attribs; auto installdirIt = attrs.find("installdir"); if (installdirIt != attrs.end()) { QString installdir = QString::fromStdString(installdirIt->second); QString gameLocation = steamLibraryLocation + "/steamapps/common/" + installdir; if (QDir(gameLocation).exists()) { return gameLocation; } } } } } } return ""; } void GameGamebryo::registerFeature(std::shared_ptr feature) { // priority does not matter, this is a game plugin so will get lowest priority in MO2 m_Organizer->gameFeatures()->registerFeature(this, feature, 0, true); }