aboutsummaryrefslogtreecommitdiff
path: root/libs/lootcli/src
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-11 02:37:39 -0600
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-11 02:37:39 -0600
commit7ee008e150bc5bcf76082d726f719ee0fdfda982 (patch)
tree27fb39be241fdb5ac2734c574de678977d1856d0 /libs/lootcli/src
Fluorine Manager: full Linux port of Mod Organizer 2
Complete native Linux port with FUSE-based virtual filesystem, Proton/umu-run integration, and Flatpak packaging. Key features: - FUSE VFS replacing Windows USVFS (in-process + standalone helper for Flatpak) - Proton/GE-Proton/umu-run launcher with env var forwarding - Flatpak support (sandbox-aware VFS, NXM handler, umu-run) - Wine prefix management UI - Case-insensitive path resolution for Linux filesystems - QSettings-safe INI handling (avoids Bethesda INI corruption) - Portable instance support with auto-generated launcher scripts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'libs/lootcli/src')
-rw-r--r--libs/lootcli/src/CMakeLists.txt138
-rw-r--r--libs/lootcli/src/game_settings.cpp435
-rw-r--r--libs/lootcli/src/game_settings.h101
-rw-r--r--libs/lootcli/src/lootthread.cpp1197
-rw-r--r--libs/lootcli/src/lootthread.h107
-rw-r--r--libs/lootcli/src/main.cpp102
-rw-r--r--libs/lootcli/src/pch.cpp1
-rw-r--r--libs/lootcli/src/pch.h84
-rw-r--r--libs/lootcli/src/version.h4
-rw-r--r--libs/lootcli/src/version.rc35
10 files changed, 2204 insertions, 0 deletions
diff --git a/libs/lootcli/src/CMakeLists.txt b/libs/lootcli/src/CMakeLists.txt
new file mode 100644
index 0000000..fdc292e
--- /dev/null
+++ b/libs/lootcli/src/CMakeLists.txt
@@ -0,0 +1,138 @@
+cmake_minimum_required(VERSION 3.16)
+
+# Qt is not required, this allows us to skip building the executable and only create
+# the single-header interface when using this as a VCPKG dependency
+
+find_package(Qt6 CONFIG COMPONENTS Core)
+
+if (Qt6_FOUND)
+
+message(STATUS "Qt6 found, building lootcli executable")
+
+find_package(tomlplusplus CONFIG REQUIRED)
+find_package(Boost REQUIRED CONFIG COMPONENTS locale)
+
+# Try to find libloot via cmake config, then fall back to pkg-config
+find_package(libloot CONFIG QUIET)
+if(NOT libloot_FOUND)
+ # On Linux with AUR package, libloot installs a .pc file
+ find_package(PkgConfig)
+ if(PkgConfig_FOUND)
+ pkg_check_modules(LIBLOOT REQUIRED IMPORTED_TARGET libloot)
+ endif()
+endif()
+
+if(TARGET libloot::loot)
+ # avoid CMake error/warning
+ set_target_properties(libloot::loot PROPERTIES
+ MAP_IMPORTED_CONFIG_RELEASE RelWithDebInfo
+ MAP_IMPORTED_CONFIG_MINSIZEREL RelWithDebInfo
+ )
+endif()
+
+if(WIN32)
+ add_executable(lootcli WIN32)
+ set_target_properties(lootcli PROPERTIES
+ CXX_STANDARD 20
+ WIN32_EXECUTABLE TRUE)
+else()
+ add_executable(lootcli)
+ set_target_properties(lootcli PROPERTIES
+ CXX_STANDARD 20)
+endif()
+
+set(LOOTCLI_SOURCES
+ game_settings.cpp
+ game_settings.h
+ lootthread.cpp
+ lootthread.h
+ main.cpp
+ pch.h
+ version.h
+ ${CMAKE_CURRENT_SOURCE_DIR}/../include/lootcli/lootcli.h
+)
+
+if(WIN32)
+ list(APPEND LOOTCLI_SOURCES version.rc)
+endif()
+
+target_sources(lootcli PRIVATE ${LOOTCLI_SOURCES})
+
+if(WIN32)
+ target_compile_definitions(lootcli
+ PRIVATE
+ _UNICODE UNICODE
+ _SILENCE_CXX17_CODECVT_HEADER_DEPRECATION_WARNING)
+endif()
+
+target_include_directories(lootcli PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../include)
+target_precompile_headers(lootcli PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/pch.h)
+
+# Link libraries
+if(TARGET libloot::loot)
+ target_link_libraries(lootcli PRIVATE libloot::loot)
+elseif(TARGET PkgConfig::LIBLOOT)
+ target_link_libraries(lootcli PRIVATE PkgConfig::LIBLOOT)
+else()
+ # Fallback: try to find the library manually
+ find_library(LIBLOOT_LIBRARY NAMES loot libloot)
+ find_path(LIBLOOT_INCLUDE_DIR NAMES loot/api.h)
+ if(LIBLOOT_LIBRARY AND LIBLOOT_INCLUDE_DIR)
+ target_link_libraries(lootcli PRIVATE ${LIBLOOT_LIBRARY})
+ target_include_directories(lootcli PRIVATE ${LIBLOOT_INCLUDE_DIR})
+ else()
+ message(FATAL_ERROR "libloot not found. Install libloot or set CMAKE_PREFIX_PATH.")
+ endif()
+endif()
+
+target_link_libraries(lootcli
+ PRIVATE Boost::headers Boost::locale
+ tomlplusplus::tomlplusplus Qt6::Core)
+
+if(NOT WIN32)
+ find_package(CURL REQUIRED)
+ target_link_libraries(lootcli PRIVATE CURL::libcurl)
+endif()
+
+if (MSVC)
+ target_compile_options(lootcli
+ PRIVATE
+ "/MP"
+ "/W4"
+ "/external:anglebrackets"
+ "/external:W0"
+ )
+ target_link_options(lootcli
+ PRIVATE
+ $<$<CONFIG:RelWithDebInfo>:/LTCG /INCREMENTAL:NO /OPT:REF /OPT:ICF>
+ )
+ target_compile_definitions(lootcli PRIVATE _SILENCE_CXX17_CODECVT_HEADER_DEPRECATION_WARNING)
+
+ set_target_properties(lootcli PROPERTIES VS_STARTUP_PROJECT lootcli)
+endif()
+
+if(WIN32)
+ install(FILES
+ $<TARGET_FILE:lootcli>
+ $<TARGET_FILE:libloot::loot>
+ DESTINATION bin/loot)
+else()
+ install(TARGETS lootcli DESTINATION bin/loot)
+endif()
+
+endif()
+
+# library to make the header available
+add_library(lootcli-header INTERFACE)
+target_sources(lootcli-header INTERFACE
+ FILE_SET HEADERS
+ BASE_DIRS ${CMAKE_CURRENT_LIST_DIR}/../include
+ FILES ${CMAKE_CURRENT_LIST_DIR}/../include/lootcli/lootcli.h)
+add_library(mo2::lootcli-header ALIAS lootcli-header)
+
+install(TARGETS lootcli-header EXPORT lootcliHeaderTargets FILE_SET HEADERS)
+install(EXPORT lootcliHeaderTargets
+ FILE mo2-lootcli-header-targets.cmake
+ NAMESPACE mo2::
+ DESTINATION lib/cmake/mo2-lootcli-header
+)
diff --git a/libs/lootcli/src/game_settings.cpp b/libs/lootcli/src/game_settings.cpp
new file mode 100644
index 0000000..00d74c0
--- /dev/null
+++ b/libs/lootcli/src/game_settings.cpp
@@ -0,0 +1,435 @@
+#include "game_settings.h"
+#include <cstdlib>
+
+namespace fs = std::filesystem;
+namespace loot
+{
+static constexpr float MORROWIND_MINIMUM_HEADER_VERSION = 1.2f;
+static constexpr float OBLIVION_MINIMUM_HEADER_VERSION = 0.8f;
+static constexpr float SKYRIM_FO3_MINIMUM_HEADER_VERSION = 0.94f;
+static constexpr float SKYRIM_SE_MINIMUM_HEADER_VERSION = 1.7f;
+static constexpr float FONV_MINIMUM_HEADER_VERSION = 1.32f;
+static constexpr float FO4_MINIMUM_HEADER_VERSION = 0.95f;
+static constexpr float STARFIELD_MINIMUM_HEADER_VERSION = 0.96f;
+
+std::filesystem::path GetOpenMWDataPath(const std::filesystem::path& gamePath)
+{
+#ifndef _WIN32
+ if (gamePath == "/usr/games") {
+ // Ubuntu, Debian
+ return "/usr/share/games/openmw/resources/vfs";
+ } else if (gamePath == "/run/host/usr/games") {
+ // Ubuntu, Debian from inside a Flatpak sandbox
+ return "/run/host/usr/share/games/openmw/resources/vfs";
+ } else if (gamePath == "/usr/bin") {
+ const auto path = "/usr/share/games/openmw/resources/vfs";
+ if (std::filesystem::exists(path)) {
+ // Arch
+ return path;
+ }
+
+ // OpenSUSE
+ return "/usr/share/openmw/resources/vfs";
+ } else if (gamePath == "/run/host/usr/bin") {
+ const auto path = "/run/host/usr/share/games/openmw/resources/vfs";
+ if (std::filesystem::exists(path)) {
+ // Arch from inside a Flatpak sandbox
+ return path;
+ }
+
+ // OpenSUSE from inside a Flatpak sandbox
+ return "/run/host/usr/share/openmw/resources/vfs";
+ } else if (boost::ends_with(gamePath.u8string(),
+ "/app/org.openmw.OpenMW/current/active/files/bin")) {
+ // Flatpak
+ return gamePath / "../share/games/openmw/resources/vfs";
+ }
+#endif
+ return gamePath / "resources" / "vfs";
+}
+
+GameType GetGameType(const GameId gameId)
+{
+ switch (gameId) {
+ case GameId::tes3:
+ case GameId::openmw:
+ return GameType::tes3;
+ case GameId::tes4:
+ case GameId::nehrim:
+ return GameType::tes4;
+ case GameId::tes5:
+ case GameId::enderal:
+ return GameType::tes5;
+ case GameId::tes5se:
+ case GameId::enderalse:
+ return GameType::tes5se;
+ case GameId::tes5vr:
+ return GameType::tes5vr;
+ case GameId::fo3:
+ return GameType::fo3;
+ case GameId::fonv:
+ return GameType::fonv;
+ case GameId::fo4:
+ return GameType::fo4;
+ case GameId::fo4vr:
+ return GameType::fo4vr;
+ case GameId::starfield:
+ return GameType::starfield;
+ case GameId::oblivionRemastered:
+ return GameType::oblivionRemastered;
+ default:
+ throw std::logic_error("Unrecognised game ID");
+ }
+}
+
+float GetMinimumHeaderVersion(const GameId gameId)
+{
+ switch (gameId) {
+ case GameId::tes3:
+ case GameId::openmw:
+ return MORROWIND_MINIMUM_HEADER_VERSION;
+ case GameId::tes4:
+ case GameId::nehrim:
+ case GameId::oblivionRemastered:
+ return OBLIVION_MINIMUM_HEADER_VERSION;
+ case GameId::tes5:
+ case GameId::enderal:
+ return SKYRIM_FO3_MINIMUM_HEADER_VERSION;
+ case GameId::tes5se:
+ case GameId::tes5vr:
+ case GameId::enderalse:
+ return SKYRIM_SE_MINIMUM_HEADER_VERSION;
+ case GameId::fo3:
+ return SKYRIM_FO3_MINIMUM_HEADER_VERSION;
+ case GameId::fonv:
+ return FONV_MINIMUM_HEADER_VERSION;
+ case GameId::fo4:
+ case GameId::fo4vr:
+ return FO4_MINIMUM_HEADER_VERSION;
+ case GameId::starfield:
+ return STARFIELD_MINIMUM_HEADER_VERSION;
+ default:
+ throw std::logic_error("Unrecognised game ID");
+ }
+}
+
+std::filesystem::path GetDataPath(const GameId gameId,
+ const std::filesystem::path& gamePath)
+{
+ switch (gameId) {
+ case GameId::tes3:
+ return gamePath / "Data Files";
+ case GameId::tes4:
+ case GameId::nehrim:
+ case GameId::tes5:
+ case GameId::enderal:
+ case GameId::tes5se:
+ case GameId::enderalse:
+ case GameId::tes5vr:
+ case GameId::fo3:
+ case GameId::fonv:
+ case GameId::fo4:
+ case GameId::fo4vr:
+ case GameId::starfield:
+ return gamePath / "Data";
+ case GameId::openmw:
+ return GetOpenMWDataPath(gamePath);
+ case GameId::oblivionRemastered:
+ return gamePath / "OblivionRemastered" / "Content" / "Dev" / "ObvData" / "Data";
+ default:
+ throw std::logic_error("Unrecognised game ID");
+ }
+}
+
+std::string ToString(const GameId gameId)
+{
+ switch (gameId) {
+ case GameId::tes3:
+ return "Morrowind";
+ case GameId::tes4:
+ return "Oblivion";
+ case GameId::nehrim:
+ return "Nehrim";
+ case GameId::tes5:
+ return "Skyrim";
+ case GameId::enderal:
+ return "Enderal";
+ case GameId::tes5se:
+ return "Skyrim Special Edition";
+ case GameId::enderalse:
+ return "Enderal Special Edition";
+ case GameId::tes5vr:
+ return "Skyrim VR";
+ case GameId::fo3:
+ return "Fallout3";
+ case GameId::fonv:
+ return "FalloutNV";
+ case GameId::fo4:
+ return "Fallout4";
+ case GameId::fo4vr:
+ return "Fallout4VR";
+ case GameId::starfield:
+ return "Starfield";
+ case GameId::openmw:
+ return "OpenMW";
+ case GameId::oblivionRemastered:
+ return "Oblivion Remastered";
+ default:
+ throw std::logic_error("Unrecognised game ID");
+ }
+}
+
+bool SupportsLightPlugins(const GameType gameType)
+{
+ return gameType == GameType::tes5se || gameType == GameType::tes5vr ||
+ gameType == GameType::fo4 || gameType == GameType::fo4vr;
+}
+
+std::string GetMasterFilename(const GameId gameId)
+{
+ switch (gameId) {
+ case GameId::tes3:
+ return "Morrowind.esm";
+ case GameId::tes4:
+ case GameId::oblivionRemastered:
+ return "Oblivion.esm";
+ case GameId::nehrim:
+ return "Nehrim.esm";
+ case GameId::tes5:
+ case GameId::tes5se:
+ case GameId::tes5vr:
+ case GameId::enderal:
+ case GameId::enderalse:
+ return "Skyrim.esm";
+ case GameId::fo3:
+ return "Fallout3.esm";
+ case GameId::fonv:
+ return "FalloutNV.esm";
+ case GameId::fo4:
+ case GameId::fo4vr:
+ return "Fallout4.esm";
+ case GameId::starfield:
+ return "Starfield.esm";
+ case GameId::openmw:
+ // This isn't actually a master file, but it's hardcoded to load first,
+ // and the value is only used to check the game is installed and to
+ // skip fully loading this file before sorting - and omwscripts files
+ // don't get loaded anyway.
+ return "builtin.omwscripts";
+ default:
+ throw std::logic_error("Unrecognised game ID");
+ }
+}
+
+std::string GetGameName(const GameId gameId)
+{
+ switch (gameId) {
+ case GameId::tes3:
+ return "TES III: Morrowind";
+ case GameId::tes4:
+ return "TES IV: Oblivion";
+ case GameId::nehrim:
+ return "Nehrim - At Fate's Edge";
+ case GameId::tes5:
+ return "TES V: Skyrim";
+ case GameId::enderal:
+ return "Enderal: Forgotten Stories";
+ case GameId::tes5se:
+ return "TES V: Skyrim Special Edition";
+ case GameId::enderalse:
+ return "Enderal: Forgotten Stories (Special Edition)";
+ case GameId::tes5vr:
+ return "TES V: Skyrim VR";
+ case GameId::fo3:
+ return "Fallout 3";
+ case GameId::fonv:
+ return "Fallout: New Vegas";
+ case GameId::fo4:
+ return "Fallout 4";
+ case GameId::fo4vr:
+ return "Fallout 4 VR";
+ case GameId::starfield:
+ return "Starfield";
+ case GameId::openmw:
+ return "OpenMW";
+ case GameId::oblivionRemastered:
+ return "TES IV: Oblivion Remastered";
+ default:
+ throw std::logic_error("Unrecognised game ID");
+ }
+}
+
+std::string GetDefaultMasterlistRepositoryName(const GameId gameId)
+{
+ switch (gameId) {
+ case GameId::tes3:
+ return "morrowind";
+ case GameId::tes4:
+ case GameId::nehrim:
+ case GameId::oblivionRemastered:
+ return "oblivion";
+ case GameId::tes5:
+ return "skyrim";
+ case GameId::enderal:
+ case GameId::enderalse:
+ return "enderal";
+ case GameId::tes5se:
+ return "skyrimse";
+ case GameId::tes5vr:
+ return "skyrimvr";
+ case GameId::fo3:
+ return "fallout3";
+ case GameId::fonv:
+ return "falloutnv";
+ case GameId::fo4:
+ return "fallout4";
+ case GameId::fo4vr:
+ return "fallout4vr";
+ case GameId::starfield:
+ return "starfield";
+ default:
+ throw std::logic_error("Unrecognised game type");
+ }
+}
+
+std::string GetDefaultMasterlistUrl(const std::string& repositoryName)
+{
+ return std::string("https://raw.githubusercontent.com/loot/") + repositoryName + "/" +
+ DEFAULT_MASTERLIST_BRANCH + "/masterlist.yaml";
+}
+
+std::string GetDefaultMasterlistUrl(const GameId gameId)
+{
+ const auto repoName = GetDefaultMasterlistRepositoryName(gameId);
+
+ return GetDefaultMasterlistUrl(repoName);
+}
+
+GameSettings::GameSettings(const GameId gameId, const std::string& lootFolder)
+ : id_(gameId), type_(GetGameType(gameId)), name_(GetGameName(gameId)),
+ masterFile_(GetMasterFilename(gameId)),
+ minimumHeaderVersion_(GetMinimumHeaderVersion(gameId)),
+ lootFolderName_(lootFolder), masterlistSource_(GetDefaultMasterlistUrl(gameId))
+{}
+
+bool GameSettings::operator==(const GameSettings& rhs) const
+{
+ return name_ == rhs.Name() || lootFolderName_ == rhs.FolderName();
+}
+
+GameId GameSettings::Id() const
+{
+ return id_;
+}
+
+GameType GameSettings::Type() const
+{
+ return type_;
+}
+
+std::string GameSettings::Name() const
+{
+ return name_;
+}
+
+std::string GameSettings::FolderName() const
+{
+ return lootFolderName_;
+}
+
+std::string GameSettings::Master() const
+{
+ return masterFile_;
+}
+
+float GameSettings::MinimumHeaderVersion() const
+{
+ return minimumHeaderVersion_;
+}
+
+std::string GameSettings::MasterlistSource() const
+{
+ return masterlistSource_;
+}
+
+std::filesystem::path GameSettings::GamePath() const
+{
+ return gamePath_;
+}
+
+std::filesystem::path GameSettings::GameLocalPath() const
+{
+ return gameLocalPath_;
+}
+
+std::filesystem::path GameSettings::DataPath() const
+{
+ return GetDataPath(id_, gamePath_);
+}
+
+GameSettings& GameSettings::SetName(const std::string& name)
+{
+ name_ = name;
+ return *this;
+}
+
+GameSettings& GameSettings::SetMaster(const std::string& masterFile)
+{
+ masterFile_ = masterFile;
+ return *this;
+}
+
+GameSettings& GameSettings::SetMinimumHeaderVersion(float mininumHeaderVersion)
+{
+ minimumHeaderVersion_ = mininumHeaderVersion;
+ return *this;
+}
+
+GameSettings& GameSettings::SetMasterlistSource(const std::string& source)
+{
+ masterlistSource_ = source;
+ return *this;
+}
+
+GameSettings& GameSettings::SetGamePath(const std::filesystem::path& path)
+{
+ gamePath_ = path;
+ return *this;
+}
+
+GameSettings& GameSettings::SetGameLocalPath(const std::filesystem::path& path)
+{
+ gameLocalPath_ = path;
+ return *this;
+}
+
+GameSettings& GameSettings::SetGameLocalFolder(const std::string& folderName)
+{
+ fs::path appData;
+#ifdef _WIN32
+ TCHAR path[MAX_PATH];
+
+ HRESULT res = ::SHGetFolderPath(nullptr, CSIDL_LOCAL_APPDATA, nullptr,
+ SHGFP_TYPE_CURRENT, path);
+ if (res == S_OK) {
+ appData = fs::path(path);
+ } else {
+ appData = fs::path("");
+ }
+#else
+ const char* xdgData = std::getenv("XDG_DATA_HOME");
+ if (xdgData && xdgData[0] != '\0') {
+ appData = fs::path(xdgData);
+ } else {
+ const char* home = std::getenv("HOME");
+ if (home && home[0] != '\0') {
+ appData = fs::path(home) / ".local" / "share";
+ } else {
+ appData = fs::path("");
+ }
+ }
+#endif
+ gameLocalPath_ = appData / fs::path(folderName);
+ return *this;
+}
+} // namespace loot
diff --git a/libs/lootcli/src/game_settings.h b/libs/lootcli/src/game_settings.h
new file mode 100644
index 0000000..bc775c9
--- /dev/null
+++ b/libs/lootcli/src/game_settings.h
@@ -0,0 +1,101 @@
+#ifndef LOOT_GUI_STATE_GAME_GAME_SETTINGS
+#define LOOT_GUI_STATE_GAME_GAME_SETTINGS
+
+#include <filesystem>
+#include <optional>
+#include <set>
+#include <string>
+#include <vector>
+
+#include "loot/enum/game_type.h"
+
+namespace loot
+{
+constexpr inline std::string_view NEHRIM_STEAM_REGISTRY_KEY =
+ "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Steam App "
+ "1014940\\InstallLocation";
+static constexpr const char* DEFAULT_MASTERLIST_BRANCH = "v0.26";
+
+enum struct GameId : uint8_t
+{
+ tes3,
+ tes4,
+ nehrim,
+ tes5,
+ enderal,
+ tes5se,
+ enderalse,
+ tes5vr,
+ fo3,
+ fonv,
+ fo4,
+ fo4vr,
+ starfield,
+ openmw,
+ oblivionRemastered
+};
+
+GameType GetGameType(const GameId gameId);
+
+float GetMinimumHeaderVersion(const GameId gameId);
+
+std::filesystem::path GetDataPath(const GameId gamiId,
+ const std::filesystem::path& gamePath);
+
+std::string ToString(const GameId gameId);
+
+bool SupportsLightPlugins(const GameType gameType);
+
+std::string GetMasterFilename(const GameId gameId);
+
+std::string GetGameName(const GameId gameId);
+
+std::string GetDefaultMasterlistRepositoryName(GameId gameId);
+
+std::string GetDefaultMasterlistUrl(const std::string& repositoryName);
+std::string GetDefaultMasterlistUrl(const GameId gameId);
+
+class GameSettings
+{
+public:
+ GameSettings() = default;
+ explicit GameSettings(const GameId gameId, const std::string& lootFolder = "");
+
+ bool operator==(const GameSettings& rhs) const; // Compares names and folder names.
+
+ GameId Id() const;
+ GameType Type() const;
+ std::string Name() const; // Returns the game's name, eg. "TES IV: Oblivion".
+ std::string FolderName() const;
+ std::string Master() const;
+ float MinimumHeaderVersion() const;
+ std::string MasterlistSource() const;
+ std::filesystem::path GamePath() const;
+ std::filesystem::path GameLocalPath() const;
+ std::filesystem::path DataPath() const;
+
+ GameSettings& SetName(const std::string& name);
+ GameSettings& SetMaster(const std::string& masterFile);
+ GameSettings& SetMinimumHeaderVersion(float minimumHeaderVersion);
+ GameSettings& SetMasterlistSource(const std::string& source);
+ GameSettings& SetGamePath(const std::filesystem::path& path);
+ GameSettings& SetGameLocalPath(const std::filesystem::path& GameLocalPath);
+ GameSettings& SetGameLocalFolder(const std::string& folderName);
+
+private:
+ GameId id_{GameId::tes4};
+ GameType type_{GameType::tes4};
+ std::string name_;
+ std::string masterFile_;
+ float minimumHeaderVersion_{0.0f};
+
+ std::string lootFolderName_;
+
+ std::string masterlistSource_;
+
+ std::filesystem::path gamePath_; // Path to the game's folder.
+ std::filesystem::path gameLocalPath_;
+};
+} // namespace loot
+
+#endif
diff --git a/libs/lootcli/src/lootthread.cpp b/libs/lootcli/src/lootthread.cpp
new file mode 100644
index 0000000..9f7f60c
--- /dev/null
+++ b/libs/lootcli/src/lootthread.cpp
@@ -0,0 +1,1197 @@
+#ifdef _WIN32
+#pragma comment(lib, "winhttp.lib")
+#endif
+
+#include "lootthread.h"
+#include "game_settings.h"
+#include "version.h"
+#include <codecvt>
+#include <fstream>
+
+#ifdef _WIN32
+#include <strsafe.h>
+#include <windows.h>
+#include <winhttp.h>
+#else
+#include <curl/curl.h>
+#include <cstring>
+#endif
+
+// using namespace loot;
+namespace fs = std::filesystem;
+
+using std::lock_guard;
+using std::recursive_mutex;
+
+namespace lootcli
+{
+static const std::set<std::string>
+ oldDefaultBranches({"master", "v0.7", "v0.8", "v0.10", "v0.13", "v0.14", "v0.15",
+ "v0.17", "v0.18", "v0.21"});
+static const std::regex GITHUB_REPO_URL_REGEX =
+ std::regex(R"(^https://github\.com/([^/]+)/([^/]+?)(?:\.git)?/?$)",
+ std::regex::ECMAScript | std::regex::icase);
+
+std::string toString(loot::MessageType type)
+{
+ switch (type) {
+ case loot::MessageType::say:
+ return "info";
+ case loot::MessageType::warn:
+ return "warn";
+ case loot::MessageType::error:
+ return "error";
+ default:
+ return "unknown";
+ }
+}
+
+LOOTWorker::LOOTWorker()
+ : m_GameId(loot::GameId::tes5), m_GameName("Skyrim"),
+ m_LogLevel(loot::LogLevel::info)
+{}
+
+std::string ToLower(std::string text)
+{
+ std::transform(text.begin(), text.end(), text.begin(), [](unsigned char c) {
+ return static_cast<char>(std::tolower(c));
+ });
+
+ return text;
+}
+
+void LOOTWorker::setGame(const std::string& gameName)
+{
+ static std::map<std::string, loot::GameId> gameMap = {
+ {"morrowind", loot::GameId::tes3},
+ {"oblivion", loot::GameId::tes4},
+ {"fallout3", loot::GameId::fo3},
+ {"fallout4", loot::GameId::fo4},
+ {"fallout4vr", loot::GameId::fo4vr},
+ {"falloutnv", loot::GameId::fonv},
+ {"skyrim", loot::GameId::tes5},
+ {"skyrimse", loot::GameId::tes5se},
+ {"skyrimvr", loot::GameId::tes5vr},
+ {"nehrim", loot::GameId::nehrim},
+ {"enderal", loot::GameId::enderal},
+ {"enderalse", loot::GameId::enderalse},
+ {"starfield", loot::GameId::starfield},
+ {"oblivionremastered", loot::GameId::oblivionRemastered}};
+
+ auto iter = gameMap.find(ToLower(gameName));
+
+ if (iter != gameMap.end()) {
+ m_GameId = iter->second;
+ m_GameName = loot::ToString(m_GameId);
+ } else {
+ throw std::runtime_error("invalid game name \"" + gameName + "\"");
+ }
+}
+
+void LOOTWorker::setGamePath(const std::string& gamePath)
+{
+ m_GamePath = gamePath;
+}
+
+void LOOTWorker::setOutput(const std::string& outputPath)
+{
+ m_OutputPath = outputPath;
+}
+
+void LOOTWorker::setUpdateMasterlist(bool update)
+{
+ m_UpdateMasterlist = update;
+}
+
+void LOOTWorker::setPluginListPath(const std::string& pluginListPath)
+{
+ m_PluginListPath = pluginListPath;
+}
+
+void LOOTWorker::setLanguageCode(const std::string& languageCode)
+{
+ m_Language = languageCode;
+}
+
+void LOOTWorker::setLogLevel(loot::LogLevel level)
+{
+ m_LogLevel = level;
+}
+
+fs::path GetLOOTAppData()
+{
+#ifdef _WIN32
+ TCHAR path[MAX_PATH];
+
+ HRESULT res = ::SHGetFolderPath(nullptr, CSIDL_LOCAL_APPDATA, nullptr,
+ SHGFP_TYPE_CURRENT, path);
+
+ if (res == S_OK) {
+ return fs::path(path) / "LOOT";
+ } else {
+ return fs::path("");
+ }
+#else
+ // On Linux, use XDG_DATA_HOME or fallback to ~/.local/share
+ const char* xdgData = std::getenv("XDG_DATA_HOME");
+ if (xdgData && xdgData[0] != '\0') {
+ return fs::path(xdgData) / "LOOT";
+ }
+ const char* home = std::getenv("HOME");
+ if (home && home[0] != '\0') {
+ return fs::path(home) / ".local" / "share" / "LOOT";
+ }
+ return fs::path("");
+#endif
+}
+
+fs::path LOOTWorker::gamePath() const
+{
+ return GetLOOTAppData() / "games" / m_GameSettings.FolderName();
+}
+
+fs::path LOOTWorker::masterlistPath() const
+{
+ return gamePath() / "masterlist.yaml";
+}
+
+fs::path LOOTWorker::userlistPath() const
+{
+ return gamePath() / "userlist.yaml";
+}
+fs::path LOOTWorker::settingsPath() const
+{
+ return GetLOOTAppData() / "settings.toml";
+}
+
+fs::path LOOTWorker::l10nPath() const
+{
+ return GetLOOTAppData() / "resources" / "l10n";
+}
+
+fs::path LOOTWorker::dataPath() const
+{
+ return m_GameSettings.DataPath();
+}
+
+void LOOTWorker::getSettings(const fs::path& file)
+{
+ lock_guard<recursive_mutex> guard(mutex_);
+ // Don't use cpptoml::parse_file() as it just uses a std stream,
+ // which don't support UTF-8 paths on Windows.
+ std::ifstream in(file);
+ if (!in.is_open())
+ throw std::runtime_error(file.string() + " could not be opened for parsing");
+
+ const auto settings = toml::parse(in, file.string());
+ const auto games = settings["games"];
+ if (games.is_array_of_tables()) {
+ for (const auto& game : *games.as_array()) {
+ try {
+ if (!game.is_table()) {
+ throw std::runtime_error("games array element is not a table");
+ }
+ auto gameTable = *game.as_table();
+
+ using loot::GameId;
+ using loot::GameSettings;
+
+ auto id = gameTable["gameId"].value<std::string>();
+ if (!id) {
+ throw std::runtime_error(
+ "'gameId' and 'type' keys both missing from game settings table");
+ }
+ const auto gameType = *id;
+ GameId gameId;
+
+ if (gameType == "Morrowind") {
+ gameId = GameId::tes3;
+ } else if (gameType == "Oblivion") {
+ // The Oblivion game type is shared between Oblivon and Nehrim.
+ gameId = IsNehrim(gameTable) ? GameId::nehrim : GameId::tes4;
+ } else if (gameType == "Skyrim") {
+ // The Skyrim game type is shared between Skyrim and Enderal.
+ gameId = IsEnderal(gameTable) ? GameId::enderal : GameId::tes5;
+ } else if (gameType == "SkyrimSE" || gameType == "Skyrim Special Edition") {
+ // The Skyrim SE game type is shared between Skyrim SE and Enderal SE.
+ gameId = IsEnderalSE(gameTable) ? GameId::enderalse : GameId::tes5se;
+ } else if (gameType == "Skyrim VR") {
+ gameId = GameId::tes5vr;
+ } else if (gameType == "Fallout3") {
+ gameId = GameId::fo3;
+ } else if (gameType == "FalloutNV") {
+ gameId = GameId::fonv;
+ } else if (gameType == "Fallout4") {
+ gameId = GameId::fo4;
+ } else if (gameType == "Fallout4VR") {
+ gameId = GameId::fo4vr;
+ } else if (gameType == "Starfield") {
+ gameId = GameId::starfield;
+ } else if (gameType == "OpenMW") {
+ gameId = GameId::openmw;
+ } else if (gameType == "Oblivion Remastered") {
+ gameId = GameId::oblivionRemastered;
+ } else {
+ throw std::runtime_error(
+ "invalid value for 'type' key in game settings table");
+ }
+
+ auto folder = gameTable["folder"].value<std::string>();
+ if (!folder) {
+ throw std::runtime_error("'folder' key missing from game settings table");
+ }
+
+ const auto type = gameTable["type"].value<std::string>();
+
+ // SkyrimSE was a previous serialised value for GameType::tes5se,
+ // and the game folder name LOOT created for that game type.
+ if (type && *type == "SkyrimSE" && *folder == *type) {
+ folder = "Skyrim Special Edition";
+ }
+
+ GameSettings newSettings(gameId, folder.value());
+
+ if (newSettings.Type() == m_GameSettings.Type()) {
+
+ auto name = gameTable["name"].value<std::string>();
+ if (name) {
+ newSettings.SetName(*name);
+ }
+
+ auto master = gameTable["master"].value<std::string>();
+ if (master) {
+ newSettings.SetMaster(*master);
+ }
+
+ const auto minimumHeaderVersion =
+ gameTable["minimumHeaderVersion"].value<double>();
+ if (minimumHeaderVersion) {
+ newSettings.SetMinimumHeaderVersion((float)*minimumHeaderVersion);
+ }
+
+ auto source = gameTable["masterlistSource"].value<std::string>();
+ if (source) {
+ newSettings.SetMasterlistSource(migrateMasterlistSource(*source));
+ } else {
+ auto url = gameTable["repo"].value<std::string>();
+ auto branch = gameTable["branch"].value<std::string>();
+ auto migratedSource =
+ migrateMasterlistRepoSettings(newSettings.Id(), *url, *branch);
+ if (migratedSource.has_value()) {
+ newSettings.SetMasterlistSource(migratedSource.value());
+ }
+ }
+
+ auto path = gameTable["path"].value<std::string>();
+ if (path) {
+ newSettings.SetGamePath(std::filesystem::path(*path));
+ }
+
+ auto localPath = gameTable["local_path"].value<std::string>();
+ auto localFolder = gameTable["local_folder"].value<std::string>();
+ if (localPath && localFolder) {
+ throw std::runtime_error(
+ "Game settings have local_path and local_folder set, use only one.");
+ } else if (localPath) {
+ newSettings.SetGameLocalPath(std::filesystem::path(*localPath));
+ } else if (localFolder) {
+ newSettings.SetGameLocalFolder(*localFolder);
+ }
+
+ m_GameSettings = newSettings;
+ break;
+ }
+ } catch (...) {
+ // Skip invalid games.
+ }
+ }
+ }
+
+ if (m_Language.empty()) {
+ m_Language = settings["language"].value_or(loot::MessageContent::DEFAULT_LANGUAGE);
+ }
+}
+
+std::optional<std::string> LOOTWorker::GetLocalFolder(const toml::table& table)
+{
+ const auto localPath = table["local_path"].value<std::string>();
+ const auto localFolder = table["local_folder"].value<std::string>();
+
+ if (localFolder.has_value()) {
+ return localFolder;
+ }
+
+ if (localPath.has_value()) {
+ return std::filesystem::path(*localPath).filename().string();
+ }
+
+ return std::nullopt;
+}
+
+bool LOOTWorker::IsNehrim(const toml::table& table)
+{
+ const auto installPath = table["path"].value<std::string>();
+
+ if (installPath.has_value() && !installPath.value().empty()) {
+ const auto path = std::filesystem::path(installPath.value());
+ if (std::filesystem::exists(path)) {
+ return std::filesystem::exists(path / "NehrimLauncher.exe");
+ }
+ }
+
+ // Fall back to using heuristics based on the existing settings.
+ // Return true if any of these heuristics return a positive match.
+ const auto gameName = table["name"].value<std::string>();
+ const auto masterFilename = table["master"].value<std::string>();
+ const auto isBaseGameInstance = table["isBaseGameInstance"].value<bool>();
+ const auto folder = table["folder"].value<std::string>();
+
+ return
+ // Nehrim uses a different main master file from Oblivion.
+ (masterFilename.has_value() &&
+ masterFilename.value() == loot::GetMasterFilename(loot::GameId::nehrim)) ||
+ // Game name probably includes "nehrim".
+ (gameName.has_value() && boost::icontains(gameName.value(), "nehrim")) ||
+ // LOOT folder name probably includes "nehrim".
+ (folder.has_value() && boost::icontains(folder.value(), "nehrim")) ||
+ // Between 0.18.1 and 0.19.0 inclusive, LOOT had an isBaseGameInstance
+ // game setting that was false for Nehrim, Enderal and Enderal SE.
+ (isBaseGameInstance.has_value() && !isBaseGameInstance.value());
+}
+
+bool LOOTWorker::IsEnderal(const toml::table& table,
+ const std::string& expectedLocalFolder)
+{
+ const auto installPath = table["path"].value<std::string>();
+
+ if (installPath.has_value() && !installPath.value().empty()) {
+ const auto path = std::filesystem::path(installPath.value());
+ if (std::filesystem::exists(path)) {
+ return std::filesystem::exists(path / "Enderal Launcher.exe");
+ }
+ }
+
+ // Fall back to using heuristics based on the existing settings.
+ // Return true if any of these heuristics return a positive match.
+ const auto gameName = table["name"].value<std::string>();
+ const auto isBaseGameInstance = table["isBaseGameInstance"].value<bool>();
+ const auto localFolder = GetLocalFolder(table);
+ const auto folder = table["folder"].value<std::string>();
+
+ return
+ // Enderal and Enderal SE use different local folders than their base
+ // games.
+ (localFolder.has_value() && localFolder.value() == expectedLocalFolder) ||
+ // Game name probably includes "enderal".
+ (gameName.has_value() && boost::icontains(gameName.value(), "enderal")) ||
+ // LOOT folder name probably includes "enderal".
+ (folder.has_value() && boost::icontains(folder.value(), "enderal")) ||
+ // Between 0.18.1 and 0.19.0 inclusive, LOOT had an isBaseGameInstance
+ // game setting that was false for Nehrim, Enderal and Enderal SE.
+ (isBaseGameInstance.has_value() && !isBaseGameInstance.value());
+}
+
+bool LOOTWorker::IsEnderal(const toml::table& table)
+{
+ return IsEnderal(table, "enderal");
+}
+
+bool LOOTWorker::IsEnderalSE(const toml::table& table)
+{
+ return IsEnderal(table, "Enderal Special Edition");
+}
+
+std::string LOOTWorker::getOldDefaultRepoUrl(loot::GameId GameId)
+{
+ switch (GameId) {
+ case loot::GameId::tes3:
+ return "https://github.com/loot/morrowind.git";
+ case loot::GameId::tes4:
+ return "https://github.com/loot/oblivion.git";
+ case loot::GameId::tes5:
+ return "https://github.com/loot/skyrim.git";
+ case loot::GameId::tes5se:
+ return "https://github.com/loot/skyrimse.git";
+ case loot::GameId::tes5vr:
+ return "https://github.com/loot/skyrimvr.git";
+ case loot::GameId::fo3:
+ return "https://github.com/loot/fallout3.git";
+ case loot::GameId::fonv:
+ return "https://github.com/loot/falloutnv.git";
+ case loot::GameId::fo4:
+ return "https://github.com/loot/fallout4.git";
+ case loot::GameId::fo4vr:
+ return "https://github.com/loot/fallout4vr.git";
+ default:
+ throw std::runtime_error(
+ "Unrecognised game type: " +
+ std::to_string(static_cast<std::underlying_type_t<loot::GameId>>(GameId)));
+ }
+}
+
+bool LOOTWorker::isLocalPath(const std::string& location, const std::string& filename)
+{
+ if (boost::starts_with(location, "http://") ||
+ boost::starts_with(location, "https://")) {
+ return false;
+ }
+
+ // Could be a local path. Only return true if it points to a non-bare
+ // Git repository that currently has the given branch checked out and
+ // the given filename exists in the repo root.
+ auto locationPath = std::filesystem::path(location);
+
+ auto filePath = locationPath / std::filesystem::path(filename);
+
+ if (!std::filesystem::is_regular_file(filePath)) {
+ return false;
+ }
+
+ auto headFilePath = locationPath / ".git" / "HEAD";
+
+ return std::filesystem::is_regular_file(headFilePath);
+}
+
+bool LOOTWorker::isBranchCheckedOut(const std::filesystem::path& localGitRepo,
+ const std::string& branch)
+{
+ auto headFilePath = localGitRepo / ".git" / "HEAD";
+
+ std::ifstream in(headFilePath);
+ if (!in.is_open()) {
+ return false;
+ }
+
+ std::string line;
+ std::getline(in, line);
+ in.close();
+
+ return line == "ref: refs/heads/" + branch;
+}
+
+std::optional<std::string>
+LOOTWorker::migrateMasterlistRepoSettings(loot::GameId GameId, std::string url,
+ std::string branch)
+{
+
+ if (oldDefaultBranches.count(branch) == 1) {
+ // Update to the latest masterlist branch.
+ log(loot::LogLevel::info, "Updating masterlist repository branch from " + branch +
+ " to " + loot::DEFAULT_MASTERLIST_BRANCH);
+ branch = loot::DEFAULT_MASTERLIST_BRANCH;
+ }
+
+ if (GameId == loot::GameId::tes5vr && url == "https://github.com/loot/skyrimse.git") {
+ // Switch to the VR-specific repository (introduced for LOOT v0.17.0).
+ auto newUrl = "https://github.com/loot/skyrimvr.git";
+ log(loot::LogLevel::info,
+ "Updating masterlist repository URL from" + url + " to " + newUrl);
+ url = newUrl;
+ }
+
+ if (GameId == loot::GameId::fo4vr && url == "https://github.com/loot/fallout4.git") {
+ // Switch to the VR-specific repository (introduced for LOOT v0.17.0).
+ auto newUrl = "https://github.com/loot/fallout4vr.git";
+ log(loot::LogLevel::info,
+ "Updating masterlist repository URL from " + url + " to " + newUrl);
+ url = newUrl;
+ }
+
+ auto filename = "masterlist.yaml";
+ if (isLocalPath(url, filename)) {
+ auto localRepoPath = std::filesystem::path(url);
+ if (!isBranchCheckedOut(localRepoPath, branch)) {
+ log(loot::LogLevel::warning,
+ "The URL " + url +
+ " is a local Git repository path but the configured branch " + branch +
+ " is not checked out. LOOT will use the path as the masterlist "
+ "source, but there may be unexpected differences in the loaded "
+ "metadata if the " +
+ branch +
+ " branch is not manually checked out before the "
+ "next time the masterlist is updated.");
+ }
+
+ return (localRepoPath / filename).string();
+ }
+
+ std::smatch regexMatches;
+ std::regex_match(url, regexMatches, GITHUB_REPO_URL_REGEX);
+ if (regexMatches.size() != 3) {
+ log(loot::LogLevel::warning,
+ "Cannot migrate masterlist repository settings as the URL does not "
+ "point to a repository on GitHub.");
+ return std::nullopt;
+ }
+
+ auto githubOwner = regexMatches.str(1);
+ auto githubRepo = regexMatches.str(2);
+
+ return "https://raw.githubusercontent.com/" + githubOwner + "/" + githubRepo + "/" +
+ branch + "/masterlist.yaml";
+}
+
+std::string LOOTWorker::migrateMasterlistSource(const std::string& source)
+{
+ static const std::vector<std::string> officialMasterlistRepos = {
+ "morrowind", "oblivion", "skyrim", "skyrimse", "skyrimvr",
+ "fallout3", "falloutnv", "fallout4", "fallout4vr", "enderal"};
+
+ for (const auto& repo : officialMasterlistRepos) {
+ for (const auto& branch : oldDefaultBranches) {
+ const auto url = "https://raw.githubusercontent.com/loot/" + repo + "/" + branch +
+ "/masterlist.yaml";
+
+ if (source == url) {
+ const auto newSource = loot::GetDefaultMasterlistUrl(repo);
+
+ log(loot::LogLevel::info,
+ "Migrating masterlist source from " + source + " to " + newSource);
+
+ return newSource;
+ }
+ }
+ }
+
+ return source;
+}
+
+#ifdef _WIN32
+DWORD LOOTWorker::GetFile(const WCHAR* szUrl, // Full URL
+ const CHAR* szFileName) // Local file name
+{
+ BYTE szTemp[25];
+ DWORD dwSize = 0;
+ DWORD dwDownloaded = 0;
+ LPSTR pszOutBuffer;
+ BOOL bResults = FALSE;
+ HINTERNET hSession = NULL, hConnect = NULL, hRequest = NULL;
+ FILE* pFile;
+ std::wstring_convert<std::codecvt_utf8_utf16<wchar_t>> converter;
+
+ URL_COMPONENTS urlComp;
+ DWORD dwUrlLen = 0;
+
+ DWORD result = ERROR_SUCCESS;
+
+ // Initialize the URL_COMPONENTS structure.
+ ZeroMemory(&urlComp, sizeof(urlComp));
+ urlComp.dwStructSize = sizeof(urlComp);
+
+ // Set required component lengths to non-zero
+ // so that they are cracked.
+ wchar_t szHostName[MAX_PATH] = L"";
+ wchar_t szURLPath[MAX_PATH * 4] = L"";
+ urlComp.lpszHostName = szHostName;
+ urlComp.lpszUrlPath = szURLPath;
+ urlComp.dwSchemeLength = (DWORD)-1;
+ urlComp.dwHostNameLength = (DWORD)-1;
+ urlComp.dwUrlPathLength = (DWORD)-1;
+ urlComp.dwExtraInfoLength = (DWORD)-1;
+ if (WinHttpCrackUrl(szUrl, (DWORD)wcslen(szUrl), 0, &urlComp)) {
+ // Use WinHttpOpen to obtain a session handle.
+ hSession = WinHttpOpen(L"lootcli/1.5.0", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY,
+ WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0);
+
+ // Specify an HTTP server.
+ if (hSession)
+ hConnect = WinHttpConnect(hSession, szHostName, urlComp.nPort, 0);
+
+ // Create an HTTP request handle.
+ if (hConnect)
+ hRequest =
+ WinHttpOpenRequest(hConnect, L"GET", szURLPath, NULL, WINHTTP_NO_REFERER,
+ WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE);
+
+ // Send a request.
+ if (hRequest)
+ bResults = WinHttpSendRequest(hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0,
+ WINHTTP_NO_REQUEST_DATA, 0, 0, 0);
+
+ // End the request.
+ if (bResults)
+ bResults = WinHttpReceiveResponse(hRequest, NULL);
+
+ // Keep checking for data until there is nothing left.
+ if (bResults) {
+ if (!(pFile = fopen(szFileName, "wb"))) {
+ log(loot::LogLevel::debug, "File open failure");
+ result = GetLastError();
+ }
+ do {
+ // Check for available data.
+ dwSize = 0;
+ if (!WinHttpQueryDataAvailable(hRequest, &dwSize)) {
+ log(loot::LogLevel::debug, "No data");
+ result = GetLastError();
+ break;
+ }
+
+ // No more available data.
+ if (!dwSize) {
+ log(loot::LogLevel::debug, "No data");
+ result = GetLastError();
+ break;
+ }
+
+ // Allocate space for the buffer.
+ pszOutBuffer = new char[dwSize + 1];
+ if (!pszOutBuffer) {
+ log(loot::LogLevel::debug, "Bad buffer");
+ result = GetLastError();
+ }
+
+ // Read the Data.
+ ZeroMemory(pszOutBuffer, dwSize + 1);
+
+ if (!WinHttpReadData(hRequest, (LPVOID)pszOutBuffer, dwSize, &dwDownloaded)) {
+ log(loot::LogLevel::debug, "Read data failure");
+ result = GetLastError();
+ } else {
+ fwrite(pszOutBuffer, sizeof(char), dwSize, pFile);
+ }
+
+ // Free the memory allocated to the buffer.
+ delete[] pszOutBuffer;
+
+ // This condition should never be reached since WinHttpQueryDataAvailable
+ // reported that there are bits to read.
+ if (!dwDownloaded)
+ break;
+
+ } while (dwSize > 0);
+ } else {
+ log(loot::LogLevel::debug, "Response failure");
+ result = GetLastError();
+ }
+
+ // Close any open handles.
+ if (hRequest)
+ WinHttpCloseHandle(hRequest);
+ if (hConnect)
+ WinHttpCloseHandle(hConnect);
+ if (hSession)
+ WinHttpCloseHandle(hSession);
+ fflush(pFile);
+ fclose(pFile);
+ } else {
+ log(loot::LogLevel::debug, "URL parse failure: " + converter.to_bytes(szUrl));
+ result = GetLastError();
+ }
+ return result;
+}
+#else
+// Linux implementation using libcurl
+static size_t curlWriteCallback(void* contents, size_t size, size_t nmemb, void* userp)
+{
+ FILE* fp = static_cast<FILE*>(userp);
+ return fwrite(contents, size, nmemb, fp);
+}
+
+int LOOTWorker::GetFile(const std::string& url, const std::string& fileName)
+{
+ CURL* curl = curl_easy_init();
+ if (!curl) {
+ log(loot::LogLevel::error, "Failed to initialize curl");
+ return 1;
+ }
+
+ FILE* fp = fopen(fileName.c_str(), "wb");
+ if (!fp) {
+ log(loot::LogLevel::debug, "File open failure: " + fileName);
+ curl_easy_cleanup(curl);
+ return 1;
+ }
+
+ curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
+ curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curlWriteCallback);
+ curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
+ curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
+ curl_easy_setopt(curl, CURLOPT_USERAGENT, "lootcli/" LOOTCLI_VERSION_STRING);
+
+ CURLcode res = curl_easy_perform(curl);
+
+ fflush(fp);
+ fclose(fp);
+ curl_easy_cleanup(curl);
+
+ if (res != CURLE_OK) {
+ log(loot::LogLevel::error,
+ std::string("curl download failed: ") + curl_easy_strerror(res));
+ return 1;
+ }
+
+ return 0;
+}
+#endif
+
+std::string escape(const std::string& s)
+{
+ return boost::replace_all_copy(s, "\"", "\\\"");
+}
+
+int LOOTWorker::run()
+{
+ m_startTime = std::chrono::high_resolution_clock::now();
+
+ {
+ // Do some preliminary locale / UTF-8 support setup here, in case the settings file
+ // reading requires it.
+ // Boost.Locale initialisation: Specify location of language dictionaries.
+ boost::locale::generator gen;
+ gen.add_messages_path(l10nPath().string());
+ gen.add_messages_domain("loot");
+
+ // Boost.Locale initialisation: Generate and imbue locales.
+ std::locale::global(gen("en.UTF-8"));
+ }
+
+ loot::SetLoggingCallback([&](loot::LogLevel level, std::string_view message) {
+ log(level, message);
+ });
+
+ try {
+ fs::path profile(m_PluginListPath);
+ profile = profile.parent_path();
+
+ m_GameSettings = loot::GameSettings(m_GameId, loot::ToString(m_GameId));
+
+ fs::path settings = settingsPath();
+
+ if (fs::exists(settings))
+ getSettings(settings);
+
+ m_GameSettings.SetGamePath(m_GamePath);
+
+ std::unique_ptr<loot::GameInterface> gameHandle = CreateGameHandle(
+ m_GameSettings.Type(), m_GameSettings.GamePath(), profile.string());
+
+ if (!GetLOOTAppData().empty()) {
+ // Make sure that the LOOT game path exists.
+ auto lootGamePath = gamePath();
+ if (!fs::is_directory(lootGamePath)) {
+ if (fs::exists(lootGamePath)) {
+ throw std::runtime_error(
+ "Could not create LOOT folder for game, the path exists but is not "
+ "a directory");
+ }
+
+ std::vector<fs::path> legacyGamePaths{GetLOOTAppData() /
+ fs::path(m_GameSettings.FolderName())};
+
+ if (m_GameSettings.Id() == loot::GameId::tes5se) {
+ // LOOT v0.10.0 used SkyrimSE as its folder name for Skyrim SE, so
+ // migrate from that if it's present.
+ legacyGamePaths.insert(legacyGamePaths.begin(),
+ GetLOOTAppData() / "SkyrimSE");
+ }
+
+ for (const auto& legacyGamePath : legacyGamePaths) {
+ if (fs::is_directory(legacyGamePath)) {
+ log(loot::LogLevel::info,
+ "Found a folder for this game in the LOOT data folder, "
+ "assuming "
+ "that it's a legacy game folder and moving into the correct "
+ "subdirectory...");
+
+ fs::create_directories(lootGamePath.parent_path());
+ fs::rename(legacyGamePath, lootGamePath);
+ break;
+ }
+ }
+
+ fs::create_directories(lootGamePath);
+ }
+ }
+
+ if (m_Language != loot::MessageContent::DEFAULT_LANGUAGE) {
+ log(loot::LogLevel::debug, "initialising language settings");
+ log(loot::LogLevel::debug, "selected language: " + m_Language);
+
+ // Boost.Locale initialisation: Generate and imbue locales.
+ boost::locale::generator gen;
+ std::locale::global(gen(m_Language + ".UTF-8"));
+ }
+
+ progress(Progress::CheckingMasterlistExistence);
+ if (!fs::exists(masterlistPath())) {
+ if (!m_UpdateMasterlist) {
+ log(loot::LogLevel::error,
+ "Masterlist not found at: " + masterlistPath().string());
+ return 0; // was FALSE on Windows
+ }
+ fs::create_directories(masterlistPath().parent_path());
+ }
+
+ if (m_UpdateMasterlist) {
+ progress(Progress::UpdatingMasterlist);
+
+#ifdef _WIN32
+ std::wstring_convert<std::codecvt_utf8_utf16<wchar_t>> converter;
+ std::wstring masterlistSource =
+ converter.from_bytes(m_GameSettings.MasterlistSource());
+
+ log(loot::LogLevel::info, "Downloading latest masterlist file from " +
+ m_GameSettings.MasterlistSource() + " to " +
+ masterlistPath().string());
+ DWORD result =
+ GetFile(masterlistSource.c_str(), masterlistPath().string().c_str());
+ if (result != ERROR_SUCCESS) {
+ LPVOID lpMsgBuf;
+ LPVOID lpDisplayBuf;
+ LPCWSTR lpszFunction = TEXT("GetFile");
+ DWORD dw = result;
+
+ FormatMessage(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM |
+ FORMAT_MESSAGE_IGNORE_INSERTS,
+ NULL, dw, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT),
+ (LPTSTR)&lpMsgBuf, 0, NULL);
+
+ lpDisplayBuf =
+ (LPVOID)LocalAlloc(LMEM_ZEROINIT, (lstrlen((LPCTSTR)lpMsgBuf) +
+ lstrlen((LPCTSTR)lpszFunction) + 40) *
+ sizeof(TCHAR));
+ StringCchPrintf((LPTSTR)lpDisplayBuf, LocalSize(lpDisplayBuf) / sizeof(TCHAR),
+ TEXT("%s failed with error %d: %s"), lpszFunction, dw,
+ lpMsgBuf);
+
+ std::wstring errorMessage = (LPTSTR)lpDisplayBuf;
+
+ log(loot::LogLevel::error,
+ "Error downloading masterlist: " + converter.to_bytes(errorMessage));
+ return 0; // was FALSE
+ }
+#else
+ log(loot::LogLevel::info, "Downloading latest masterlist file from " +
+ m_GameSettings.MasterlistSource() + " to " +
+ masterlistPath().string());
+ int result = GetFile(m_GameSettings.MasterlistSource(),
+ masterlistPath().string());
+ if (result != 0) {
+ log(loot::LogLevel::error, "Error downloading masterlist");
+ return 0;
+ }
+#endif
+ }
+
+ progress(Progress::LoadingLists);
+
+ gameHandle->GetDatabase().LoadMasterlist(masterlistPath().string());
+ fs::path userlist = userlistPath();
+ if (fs::exists(userlist))
+ gameHandle->GetDatabase().LoadUserlist(userlist.string());
+
+ progress(Progress::ReadingPlugins);
+ gameHandle->LoadCurrentLoadOrderState();
+ auto loadOrder = gameHandle->GetLoadOrder();
+ std::vector<std::filesystem::path> pluginsList;
+ for (auto plugin : gameHandle->GetLoadOrder()) {
+ std::filesystem::path pluginPath(plugin);
+ pluginsList.push_back(pluginPath);
+ }
+ gameHandle->LoadPlugins(pluginsList, false);
+
+ progress(Progress::SortingPlugins);
+ std::vector<std::string> sortedPlugins = gameHandle->SortPlugins(loadOrder);
+
+ progress(Progress::WritingLoadorder);
+
+ std::ofstream outf(m_PluginListPath);
+ if (!outf) {
+ log(loot::LogLevel::error,
+ "failed to open " + m_PluginListPath + " to rewrite it");
+ return 1;
+ }
+ outf << "# This file was automatically generated by Mod Organizer." << std::endl;
+ for (const std::string& plugin : sortedPlugins) {
+ outf << plugin << std::endl;
+ }
+ outf.close();
+
+ progress(Progress::ParsingLootMessages);
+ std::ofstream(m_OutputPath) << createJsonReport(*gameHandle, sortedPlugins);
+ } catch (std::system_error& e) {
+ log(loot::LogLevel::error, e.what());
+ return 1;
+ } catch (const std::exception& e) {
+ log(loot::LogLevel::error, e.what());
+ return 1;
+ }
+
+ progress(Progress::Done);
+
+ return 0;
+}
+
+void set(QJsonObject& o, const char* e, const QJsonValue& v)
+{
+ if (v.isObject() && v.toObject().isEmpty()) {
+ return;
+ }
+
+ if (v.isArray() && v.toArray().isEmpty()) {
+ return;
+ }
+
+ if (v.isString() && v.toString().isEmpty()) {
+ return;
+ }
+
+ o[e] = v;
+}
+
+std::string
+LOOTWorker::createJsonReport(loot::GameInterface& game,
+ const std::vector<std::string>& sortedPlugins) const
+{
+ QJsonObject root;
+
+ set(root, "messages", createMessages(game.GetDatabase().GetGeneralMessages(true)));
+ set(root, "plugins", createPlugins(game, sortedPlugins));
+
+ const auto end = std::chrono::high_resolution_clock::now();
+
+ set(root, "stats",
+ QJsonObject{{"time", static_cast<qint64>(std::chrono::duration_cast<std::chrono::milliseconds>(
+ end - m_startTime)
+ .count())},
+ {"lootcliVersion", LOOTCLI_VERSION_STRING},
+ {"lootVersion", QString::fromStdString(loot::GetLiblootVersion())}});
+
+ QJsonDocument doc(root);
+ return doc.toJson(QJsonDocument::Indented).toStdString();
+}
+
+template <class Container>
+QJsonArray createStringArray(const Container& c)
+{
+ QJsonArray array;
+
+ for (auto&& e : c) {
+ array.push_back(QString::fromStdString(e));
+ }
+
+ return array;
+}
+
+QJsonArray
+LOOTWorker::createPlugins(loot::GameInterface& game,
+ const std::vector<std::string>& sortedPlugins) const
+{
+ QJsonArray plugins;
+
+ for (auto&& pluginName : sortedPlugins) {
+
+ auto plugin = game.GetPlugin(pluginName);
+
+ QJsonObject o;
+ o["name"] = QString::fromStdString(pluginName);
+
+ if (auto metaData = game.GetDatabase().GetPluginMetadata(pluginName, true, true)) {
+ set(o, "incompatibilities",
+ createIncompatibilities(game, metaData->GetIncompatibilities()));
+ set(o, "messages", createMessages(metaData->GetMessages()));
+ set(o, "dirty", createDirty(metaData->GetDirtyInfo()));
+ set(o, "clean", createClean(metaData->GetCleanInfo()));
+ }
+
+ set(o, "missingMasters", createMissingMasters(game, pluginName));
+
+ if (plugin->LoadsArchive()) {
+ o["loadsArchive"] = true;
+ }
+
+ if (plugin->IsMaster()) {
+ o["isMaster"] = true;
+ }
+
+ if (plugin->IsLightPlugin()) {
+ o["isLightMaster"] = true;
+ }
+
+ // don't add if the name is the only thing in there
+ if (o.size() > 1) {
+ plugins.push_back(o);
+ }
+ }
+
+ return plugins;
+}
+
+QJsonValue LOOTWorker::createMessages(const std::vector<loot::Message>& list) const
+{
+ QJsonArray messages;
+
+ for (loot::Message m : list) {
+ auto simpleMessage = loot::SelectMessageContent(m.GetContent(), m_Language);
+ if (simpleMessage.has_value()) {
+ messages.push_back(QJsonObject{
+ {"type", QString::fromStdString(toString(m.GetType()))},
+ {"text", QString::fromStdString(simpleMessage.value().GetText())}});
+ }
+ }
+
+ return messages;
+}
+
+QJsonValue
+LOOTWorker::createDirty(const std::vector<loot::PluginCleaningData>& data) const
+{
+ QJsonArray array;
+
+ for (const auto& d : data) {
+ QJsonObject o{
+ {"crc", static_cast<qint64>(d.GetCRC())},
+ {"itm", static_cast<qint64>(d.GetITMCount())},
+ {"deletedReferences", static_cast<qint64>(d.GetDeletedReferenceCount())},
+ {"deletedNavmesh", static_cast<qint64>(d.GetDeletedNavmeshCount())},
+ };
+
+ set(o, "cleaningUtility", QString::fromStdString(d.GetCleaningUtility()));
+ auto simpleMessage = loot::SelectMessageContent(
+ loot::Message(loot::MessageType::say, d.GetDetail()).GetContent(), m_Language);
+ if (simpleMessage.has_value()) {
+ set(o, "info", QString::fromStdString(simpleMessage.value().GetText()));
+ } else {
+ set(o, "info", QString::fromStdString(""));
+ }
+
+ array.push_back(o);
+ }
+
+ return array;
+}
+
+QJsonValue
+LOOTWorker::createClean(const std::vector<loot::PluginCleaningData>& data) const
+{
+ QJsonArray array;
+
+ for (const auto& d : data) {
+ QJsonObject o{
+ {"crc", static_cast<qint64>(d.GetCRC())},
+ };
+
+ set(o, "cleaningUtility", QString::fromStdString(d.GetCleaningUtility()));
+ auto simpleMessage = loot::SelectMessageContent(
+ loot::Message(loot::MessageType::say, d.GetDetail()).GetContent(), m_Language);
+ if (simpleMessage.has_value()) {
+ set(o, "info", QString::fromStdString(simpleMessage.value().GetText()));
+ } else {
+ set(o, "info", QString::fromStdString(""));
+ }
+
+ array.push_back(o);
+ }
+
+ return array;
+}
+
+QJsonValue
+LOOTWorker::createIncompatibilities(loot::GameInterface& game,
+ const std::vector<loot::File>& data) const
+{
+ QJsonArray array;
+
+ for (auto&& f : data) {
+ const auto n = static_cast<std::string>(f.GetName());
+ if (!game.GetPlugin(n)) {
+ continue;
+ }
+
+ const auto name = QString::fromStdString(n);
+ const auto displayName = QString::fromStdString(f.GetDisplayName());
+
+ QJsonObject o{{"name", name}};
+
+ if (displayName != name) {
+ set(o, "displayName", displayName);
+ }
+
+ array.push_back(std::move(o));
+ }
+
+ return array;
+}
+
+QJsonValue LOOTWorker::createMissingMasters(loot::GameInterface& game,
+ const std::string& pluginName) const
+{
+ QJsonArray array;
+
+ for (auto&& master : game.GetPlugin(pluginName)->GetMasters()) {
+ if (!game.GetPlugin(master)) {
+ array.push_back(QString::fromStdString(master));
+ }
+ }
+
+ return array;
+}
+
+void LOOTWorker::progress(Progress p)
+{
+ std::cout << "[progress] " << static_cast<int>(p) << "\n";
+ std::cout.flush();
+}
+
+void LOOTWorker::log(loot::LogLevel level, const std::string_view message) const
+{
+ if (level < m_LogLevel) {
+ return;
+ }
+
+ const auto ll = fromLootLogLevel(level);
+ const auto levelName = logLevelToString(ll);
+
+ std::cout << "[" << levelName << "] " << message << "\n";
+ std::cout.flush();
+}
+
+loot::LogLevel toLootLogLevel(lootcli::LogLevels level)
+{
+ using L = loot::LogLevel;
+ using LC = lootcli::LogLevels;
+
+ switch (level) {
+ case LC::Trace:
+ return L::trace;
+ case LC::Debug:
+ return L::debug;
+ case LC::Info:
+ return L::info;
+ case LC::Warning:
+ return L::warning;
+ case LC::Error:
+ return L::error;
+ default:
+ return L::info;
+ }
+}
+
+lootcli::LogLevels fromLootLogLevel(loot::LogLevel level)
+{
+ using L = loot::LogLevel;
+ using LC = lootcli::LogLevels;
+
+ switch (level) {
+ case L::trace:
+ return LC::Trace;
+
+ case L::debug:
+ return LC::Debug;
+
+ case L::info:
+ return LC::Info;
+
+ case L::warning:
+ return LC::Warning;
+
+ case L::error:
+ return LC::Error;
+
+ default:
+ return LC::Info;
+ }
+}
+
+} // namespace lootcli
diff --git a/libs/lootcli/src/lootthread.h b/libs/lootcli/src/lootthread.h
new file mode 100644
index 0000000..d4f4e65
--- /dev/null
+++ b/libs/lootcli/src/lootthread.h
@@ -0,0 +1,107 @@
+#ifndef LOOTTHREAD_H
+#define LOOTTHREAD_H
+
+#include "game_settings.h"
+#include "loot/database_interface.h"
+#include <lootcli/lootcli.h>
+
+namespace loot
+{
+class Game;
+}
+
+namespace lootcli
+{
+
+loot::LogLevel toLootLogLevel(lootcli::LogLevels level);
+lootcli::LogLevels fromLootLogLevel(loot::LogLevel level);
+
+class LOOTWorker
+{
+public:
+ explicit LOOTWorker();
+
+ void setGame(const std::string& gameName);
+ void setGamePath(const std::string& gamePath);
+ void setOutput(const std::string& outputPath);
+ void setPluginListPath(const std::string& pluginListPath);
+ void
+ setLanguageCode(const std::string& language_code); // Will add this when I figure out
+ // how languages work on MO
+ void setLogLevel(loot::LogLevel level);
+
+ void setUpdateMasterlist(bool update);
+
+ int run();
+
+private:
+ void progress(Progress p);
+ void log(loot::LogLevel level, const std::string_view message) const;
+
+#ifdef _WIN32
+ DWORD GetFile(const WCHAR* szUrl, const CHAR* szFileName);
+#else
+ int GetFile(const std::string& url, const std::string& fileName);
+#endif
+ void getSettings(const std::filesystem::path& file);
+ std::string getOldDefaultRepoUrl(loot::GameId gameType);
+ std::optional<std::string> GetLocalFolder(const toml::table& table);
+ bool IsNehrim(const toml::table& table);
+ bool IsEnderal(const toml::table& table, const std::string& expectedLocalFolder);
+ bool IsEnderal(const toml::table& table);
+ bool IsEnderalSE(const toml::table& table);
+ bool isLocalPath(const std::string& location, const std::string& filename);
+ bool isBranchCheckedOut(const std::filesystem::path& localGitRepo,
+ const std::string& branch);
+ std::optional<std::string> migrateMasterlistRepoSettings(loot::GameId gameType,
+ std::string url,
+ std::string branch);
+ std::string migrateMasterlistSource(const std::string& source);
+
+ std::filesystem::path gamePath() const;
+ std::filesystem::path masterlistPath() const;
+ std::filesystem::path settingsPath() const;
+ std::filesystem::path userlistPath() const;
+ std::filesystem::path l10nPath() const;
+ std::filesystem::path dataPath() const;
+
+private:
+ // void handleErr(unsigned int resultCode, const char *description);
+ bool sort(loot::Game& game);
+ // const char *lootErrorString(unsigned int errorCode);
+ // template <typename T> T resolveVariable(HMODULE lib, const char *name);
+ // template <typename T> T resolveFunction(HMODULE lib, const char *name);
+
+private:
+ loot::GameId m_GameId;
+ std::string m_Language;
+ std::string m_GameName;
+ std::string m_GamePath;
+ std::string m_OutputPath;
+ std::string m_PluginListPath;
+ loot::LogLevel m_LogLevel;
+ bool m_UpdateMasterlist;
+ mutable std::recursive_mutex mutex_;
+ loot::GameSettings m_GameSettings;
+ std::chrono::high_resolution_clock::time_point m_startTime;
+
+ std::string createJsonReport(loot::GameInterface& game,
+ const std::vector<std::string>& sortedPlugins) const;
+
+ QJsonArray createPlugins(loot::GameInterface& game,
+ const std::vector<std::string>& sortedPlugins) const;
+
+ QJsonValue createMessages(const std::vector<loot::Message>& list) const;
+ QJsonValue createDirty(const std::vector<loot::PluginCleaningData>& data) const;
+ QJsonValue createClean(const std::vector<loot::PluginCleaningData>& data) const;
+
+ QJsonValue createIncompatibilities(loot::GameInterface& game,
+ const std::vector<loot::File>& data) const;
+
+ QJsonValue createMissingMasters(loot::GameInterface& game,
+ const std::string& pluginName) const;
+};
+
+} // namespace lootcli
+
+#endif // LOOTTHREAD_H
diff --git a/libs/lootcli/src/main.cpp b/libs/lootcli/src/main.cpp
new file mode 100644
index 0000000..d06e0ef
--- /dev/null
+++ b/libs/lootcli/src/main.cpp
@@ -0,0 +1,102 @@
+#include "lootthread.h"
+#include <lootcli/lootcli.h>
+
+using namespace std;
+
+template <typename T>
+T getParameter(const std::vector<std::string>& arguments, const std::string& key)
+{
+ auto iter = std::find(arguments.begin(), arguments.end(), std::string("--") + key);
+ if ((iter != arguments.end()) && ((iter + 1) != arguments.end())) {
+ return boost::lexical_cast<T>(*(iter + 1));
+ } else {
+ throw std::runtime_error(std::string("argument missing " + key));
+ }
+}
+
+template <>
+bool getParameter<bool>(const std::vector<std::string>& arguments,
+ const std::string& key)
+{
+ auto iter = std::find(arguments.begin(), arguments.end(), std::string("--") + key);
+ if (iter != arguments.end()) {
+ return true;
+ } else {
+ return false;
+ }
+}
+
+template <typename T>
+T getOptionalParameter(const std::vector<std::string>& arguments,
+ const std::string& key, T def)
+{
+ try {
+ return getParameter<T>(arguments, key);
+ } catch (std::runtime_error&) {
+ return def;
+ }
+}
+
+loot::LogLevel getLogLevel(const std::vector<std::string>& arguments)
+{
+ const auto s = getOptionalParameter<std::string>(arguments, "logLevel", "");
+ const auto level = lootcli::logLevelFromString(s);
+
+ return lootcli::toLootLogLevel(level);
+}
+
+#ifdef _WIN32
+int wWinMain(HINSTANCE, HINSTANCE, LPTSTR, int)
+{
+ _setmode(_fileno(stdout), _O_BINARY);
+ setlocale(LC_ALL, "en.UTF-8");
+
+ std::vector<std::string> arguments;
+ int argc;
+ LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc);
+
+ if (argv) {
+ for (int i = 0; i < argc; ++i) {
+ size_t num_converted;
+ std::vector<char> arg(wcslen(argv[i]) * sizeof(wchar_t) + 1);
+
+ wcstombs_s(&num_converted, &(arg[0]), arg.size(), argv[i], arg.size() - 1);
+
+ arguments.push_back(&(arg[0]));
+ }
+ }
+#else
+int main(int argc, char* argv[])
+{
+ setlocale(LC_ALL, "en_US.UTF-8");
+
+ std::vector<std::string> arguments;
+ for (int i = 0; i < argc; ++i) {
+ arguments.push_back(argv[i]);
+ }
+#endif
+
+ // design rationale: this was designed to have the actual loot stuff run in a separate
+ // thread. That turned out to be unnecessary atm.
+
+ try {
+ lootcli::LOOTWorker worker;
+
+ worker.setUpdateMasterlist(!getParameter<bool>(arguments, "skipUpdateMasterlist"));
+ worker.setGame(getParameter<std::string>(arguments, "game"));
+ worker.setGamePath(getParameter<std::string>(arguments, "gamePath"));
+ worker.setPluginListPath(getParameter<std::string>(arguments, "pluginListPath"));
+ worker.setOutput(getParameter<std::string>(arguments, "out"));
+ worker.setLogLevel(getLogLevel(arguments));
+
+ const auto lang = getOptionalParameter<std::string>(arguments, "language", "");
+ if (!lang.empty()) {
+ worker.setLanguageCode(lang);
+ }
+
+ return worker.run();
+ } catch (const std::exception& e) {
+ std::cerr << "Error: " << e.what();
+ return 1;
+ }
+}
diff --git a/libs/lootcli/src/pch.cpp b/libs/lootcli/src/pch.cpp
new file mode 100644
index 0000000..1d9f38c
--- /dev/null
+++ b/libs/lootcli/src/pch.cpp
@@ -0,0 +1 @@
+#include "pch.h"
diff --git a/libs/lootcli/src/pch.h b/libs/lootcli/src/pch.h
new file mode 100644
index 0000000..cf460fd
--- /dev/null
+++ b/libs/lootcli/src/pch.h
@@ -0,0 +1,84 @@
+#ifdef _MSC_VER
+#pragma warning(disable : 4251) // neds to have dll-interface
+#pragma warning(disable : 4355) // this used in initializer list
+#pragma warning(disable : 4371) // layout may have changed
+#pragma warning(disable : 4514) // unreferenced inline function removed
+#pragma warning(disable : 4571) // catch semantics changed
+#pragma warning(disable : 4619) // no warning X
+#pragma warning(disable : 4623) // default constructor deleted
+#pragma warning(disable : 4625) // copy constructor deleted
+#pragma warning(disable : 4626) // copy assignment operator deleted
+#pragma warning(disable : 4710) // function not inlined
+#pragma warning(disable : 4820) // padding
+#pragma warning(disable : 4866) // left-to-right evaluation order
+#pragma warning(disable : 4868) // left-to-right evaluation order
+#pragma warning(disable : 5026) // move constructor deleted
+#pragma warning(disable : 5027) // move assignment operator deleted
+#pragma warning(disable : 5045) // spectre mitigation
+
+#pragma warning(push, 3)
+#pragma warning(disable : 4365) // signed/unsigned mismatch
+#pragma warning(disable : 4774) // bad format string
+#pragma warning(disable : 4946) // reinterpret_cast used between related classes
+#pragma warning(disable : 4800) // implicit conversion
+#endif
+
+// std
+#include <algorithm>
+#include <cstdint>
+#include <cstring>
+#include <exception>
+#include <filesystem>
+#include <fstream>
+#include <iostream>
+#include <locale>
+#include <map>
+#include <memory>
+#include <mutex>
+#include <optional>
+#include <regex>
+#include <set>
+#include <sstream>
+#include <stdexcept>
+#include <string>
+#include <thread>
+#include <utility>
+#include <vector>
+
+// qt
+#include <QDir>
+#include <QFileInfo>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+
+// boost
+#include <boost/algorithm/string.hpp>
+#include <boost/lexical_cast.hpp>
+#include <boost/locale.hpp>
+
+// loot
+#include <loot/api.h>
+#include <loot/enum/game_type.h>
+
+// third-party
+#include <toml++/toml.h>
+
+#ifdef _WIN32
+// windows
+#define WIN32_LEAN_AND_MEAN
+#include <Shlobj.h>
+#include <Windows.h>
+#include <fcntl.h>
+#include <io.h>
+#include <shellapi.h>
+#else
+// Linux equivalents
+#include <cstdlib>
+#include <unistd.h>
+#endif
+
+#ifdef _MSC_VER
+#undef _SILENCE_CXX17_CODECVT_HEADER_DEPRECATION_WARNING
+#pragma warning(pop)
+#endif
diff --git a/libs/lootcli/src/version.h b/libs/lootcli/src/version.h
new file mode 100644
index 0000000..d496a56
--- /dev/null
+++ b/libs/lootcli/src/version.h
@@ -0,0 +1,4 @@
+#define LOOTCLI_VERSION_MAJOR 1
+#define LOOTCLI_VERSION_MINOR 7
+#define LOOTCLI_VERSION_MAINTENANCE 0
+#define LOOTCLI_VERSION_STRING "1.7.0"
diff --git a/libs/lootcli/src/version.rc b/libs/lootcli/src/version.rc
new file mode 100644
index 0000000..bf6ebb4
--- /dev/null
+++ b/libs/lootcli/src/version.rc
@@ -0,0 +1,35 @@
+#include "version.h"
+#include "Winver.h"
+
+#define VER_FILEVERSION LOOTCLI_VERSION_MAJOR,LOOTCLI_VERSION_MINOR,LOOTCLI_VERSION_MAINTENANCE,0
+#define VER_FILEVERSION_STR LOOTCLI_VERSION_STRING
+
+VS_VERSION_INFO VERSIONINFO
+FILEVERSION VER_FILEVERSION
+PRODUCTVERSION VER_FILEVERSION
+FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
+FILEFLAGS (0)
+FILEOS VOS__WINDOWS32
+FILETYPE VFT_APP
+FILESUBTYPE (0)
+BEGIN
+BLOCK "StringFileInfo"
+BEGIN
+BLOCK "040904B0"
+BEGIN
+VALUE "FileVersion", VER_FILEVERSION_STR
+VALUE "CompanyName", "Mod Organizer 2 Team\0"
+VALUE "FileDescription", "LOOT Command line interface\0"
+VALUE "OriginalFilename", "lootcli.exe\0"
+VALUE "InternalName", "lootcli\0"
+VALUE "LegalCopyright", "Copyright 2011-2016 Sebastian Herbord\r\nCopyright 2016-2025 Mod Organizer 2 contributors\0"
+VALUE "ProductName", "lootcli\0"
+VALUE "ProductVersion", VER_FILEVERSION_STR
+END
+END
+
+BLOCK "VarFileInfo"
+BEGIN
+VALUE "Translation", 0x0409, 1200
+END
+END