diff options
Diffstat (limited to 'libs/game_bethesda/src/gamebryo/gamegamebryo.cpp')
| -rw-r--r-- | libs/game_bethesda/src/gamebryo/gamegamebryo.cpp | 823 |
1 files changed, 823 insertions, 0 deletions
diff --git a/libs/game_bethesda/src/gamebryo/gamegamebryo.cpp b/libs/game_bethesda/src/gamebryo/gamegamebryo.cpp new file mode 100644 index 0000000..ac9fe22 --- /dev/null +++ b/libs/game_bethesda/src/gamebryo/gamegamebryo.cpp @@ -0,0 +1,823 @@ +#include "gamegamebryo.h" + +#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 <QDir> +#include <QDirIterator> +#include <QFile> +#include <QFileInfo> +#include <QIcon> +#include <QJsonDocument> +#include <QJsonValue> +#include <QSettings> +#include <QStandardPaths> +#include <QTextStream> + +#include <QtDebug> +#include <QtGlobal> + +#ifdef _WIN32 +#include <Knownfolders.h> +#include <Shlobj.h> +#include <Windows.h> +#include <winreg.h> +#include <winver.h> +#endif + +#ifdef HAS_NAK_FFI +#include <nak_ffi.h> +#endif + +#include <optional> +#include <string> +#include <vector> + +// Case-insensitive file path resolution for Linux. +// On Windows (case-insensitive FS), just returns the input path. +// On Linux, if the exact path doesn't exist, searches the parent directory +// for a file matching the name case-insensitively. +static QString resolveFileCaseInsensitive(const QString& filePath) +{ +#ifdef _WIN32 + return filePath; +#else + if (QFileInfo::exists(filePath)) { + return filePath; + } + + QFileInfo info(filePath); + QDir dir(info.path()); + if (!dir.exists()) { + return filePath; + } + + const QString target = info.fileName(); + const QStringList entries = + dir.entryList(QDir::Files | QDir::Hidden | QDir::System); + for (const QString& entry : entries) { + if (entry.compare(target, Qt::CaseInsensitive) == 0) { + return dir.absoluteFilePath(entry); + } + } + + return filePath; +#endif +} + +GameGamebryo::GameGamebryo() {} + +void GameGamebryo::detectGame() +{ + m_GamePath = identifyGamePath(); + m_MyGamesPath = determineMyGamesPath(gameName()); +} + +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<std::shared_ptr<const MOBase::ISaveGame>> +GameGamebryo::listSaves(QDir folder) const +{ + QStringList filters; + filters << QString("*.") + savegameExtension(); + + std::vector<std::shared_ptr<const MOBase::ISaveGame>> 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 <prog>.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 +{ +#ifdef HAS_NAK_FFI + // Use NaK to detect game installations on Linux + NakGameList gameList = nak_detect_all_games(); + ON_BLOCK_EXIT([&]() { + nak_game_list_free(gameList); + }); + + 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 (size_t i = 0; i < gameList.count; ++i) { + const NakGame& game = gameList.games[i]; + QString detectedName = QString::fromUtf8(game.name); + QString detectedPath = QString::fromUtf8(game.install_path); + if (detectedName.compare(fullName, Qt::CaseInsensitive) == 0 || + detectedName.compare(shortName, Qt::CaseInsensitive) == 0 || + detectedName.contains(fullName, Qt::CaseInsensitive) || + detectedName.contains(shortName, Qt::CaseInsensitive) || + tokensMatch(detectedName, fullName) || tokensMatch(detectedName, shortName)) { + if (looksValid(QDir(detectedPath))) { + return detectedPath; + } + } + } + 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; +} + +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 + // 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 symlink to the real file. + if (!caseMismatchPath.isEmpty()) { + QFileInfo altInfo(caseMismatchPath); + if (altInfo.exists() && altInfo.size() > 200) { + if (targetInfo.exists()) { + QFile::remove(targetPath); + } + QFile::link(altInfo.fileName(), targetPath); + MOBase::log::info("Created case-matching symlink '{}' -> '{}'", + iniFile, altInfo.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(); // "fallout" + 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); + } + if (QFile::copy(defaultIniPath, 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 '{}' -> '{}'", + defaultIniPath, targetPath); + } + } + } +} + +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 common locations + QString flatpakPath = QDir::homePath() + "/.var/app/io.github.loot.loot/data/LOOT/Loot"; + if (QFileInfo(flatpakPath).exists()) + return flatpakPath; + // Try system PATH + QString systemLoot = QStandardPaths::findExecutable("loot"); + if (!systemLoot.isEmpty()) + return systemLoot; + return "loot"; +#endif +} + +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 + 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) +{ + QString filePath = 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(filePath).open(QIODevice::WriteOnly); + } + } +} + +MappingType GameGamebryo::mappings() const +{ + MappingType result; + + for (const QString& profileFile : {"plugins.txt", "loadorder.txt"}) { + result.push_back({m_Organizer->profilePath() + "/" + profileFile, + localAppFolder() + "/" + gameShortName() + "/" + profileFile, + false}); + } + + return result; +} + +#ifdef _WIN32 +std::unique_ptr<BYTE[]> 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<BYTE[]>(); + } + res = ::RegGetValueW(subKey, L"", value, flags, type, nullptr, &size); + if (res == ERROR_FILE_NOT_FOUND || res == ERROR_UNSUPPORTED_TYPE) { + return std::unique_ptr<BYTE[]>(); + } + 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<BYTE[]> 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<BYTE[]> buffer = + getRegValue(baseKey, path, value, RRF_RT_REG_SZ | RRF_NOEXPAND); + + return QString::fromUtf16(reinterpret_cast<const char16_t*>(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) +{ + const QString pattern = "%1/My Games/" + gameName; + + auto tryDir = [&](const QString& dir) -> std::optional<QString> { + 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 <game> 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 MO2 settings (most reliable). + const QString configuredPrefix = + QSettings().value("fluorine/prefix_path").toString().trimmed(); + 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; + } + } + + // If no existing directory was found, try to create it in the configured prefix + // so that the game launcher can populate it on first run. + if (!configuredPrefix.isEmpty()) { + const QString configuredDocs = + QDir(configuredPrefix).filePath("drive_c/users/steamuser/Documents"); + const QString newPath = pattern.arg(configuredDocs); + if (QDir().mkpath(newPath)) { + MOBase::log::info("determineMyGamesPath: created '{}' for game '{}'", newPath, + gameName); + return newPath; + } + } + + MOBase::log::warn("determineMyGamesPath: could not find My Games path for '{}'", + 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) +{ +#ifdef HAS_NAK_FFI + // Use NaK to find Steam path + char* steamPathC = nak_find_steam_path(); + QString steamLocation; + if (steamPathC) { + steamLocation = QString::fromUtf8(steamPathC); + nak_string_free(steamPathC); + } +#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_<appid>.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<MOBase::GameFeature> 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); +} |
