aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-03-12 07:20:42 -0500
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-03-12 07:20:42 -0500
commit06826ca53dd54e73169514b3de2f81f2a7ca31f4 (patch)
treedc2d91797aef6d3a979ae9e136684b73d7ea868a
parent755698f1adceef55f52c8e44c9746537885c9bf7 (diff)
Add native Root Builder and OMOD Installer plugins
- libs/rootbuilder_native/: Native C++ port of rootbuilder.py (IPluginTool) - Root/ dir scanning, copy (reflink/CoW) and symlink modes - JSON manifest tracking, backup/restore, auto-deploy hooks - Legacy migration and third-party conflict detection - libs/installer_omod_native/: Native C++ port of installer_omod.py - Handles .omod archives (Oblivion Mod Manager format) - Binary config parsing, .NET string format, CRC file parsing - zlib raw deflate and LZMA decompression - Repackages to zip for MO2's installation manager - Added both to build-inner.sh plugin packaging Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
-rw-r--r--CMakeLists.txt2
-rwxr-xr-xdocker/build-inner.sh4
-rw-r--r--libs/installer_omod_native/CMakeLists.txt5
-rw-r--r--libs/installer_omod_native/src/CMakeLists.txt11
-rw-r--r--libs/installer_omod_native/src/omodinstaller.cpp472
-rw-r--r--libs/installer_omod_native/src/omodinstaller.h101
-rw-r--r--libs/rootbuilder_native/CMakeLists.txt3
-rw-r--r--libs/rootbuilder_native/src/CMakeLists.txt18
-rw-r--r--libs/rootbuilder_native/src/rootbuilder.cpp674
-rw-r--r--libs/rootbuilder_native/src/rootbuilder.h79
10 files changed, 1368 insertions, 1 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 3080998..700e55f 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -216,6 +216,7 @@ add_subdirectory(libs/game_features)
add_subdirectory(libs/game_bethesda)
add_subdirectory(libs/installer_fomod)
add_subdirectory(libs/installer_fomod_plus)
+add_subdirectory(libs/installer_omod_native)
add_subdirectory(libs/installer_bain)
add_subdirectory(libs/installer_bundle)
add_subdirectory(libs/installer_manual)
@@ -232,6 +233,7 @@ add_subdirectory(libs/basic_games_native)
add_subdirectory(libs/check_fnis)
add_subdirectory(libs/tool_inibakery)
add_subdirectory(libs/tool_inieditor)
+add_subdirectory(libs/rootbuilder_native)
if(BUILD_DOTNET_PLUGINS)
if(WIN32 AND MSVC)
add_subdirectory(libs/installer_fomod_csharp)
diff --git a/docker/build-inner.sh b/docker/build-inner.sh
index 0bcb8b0..d4d3755 100755
--- a/docker/build-inner.sh
+++ b/docker/build-inner.sh
@@ -71,7 +71,9 @@ find build/libs -type f \( \
-name "libform43_checker_native.so" -o \
-name "libscript_extender_checker_native.so" -o \
-name "libpreview_dds_native.so" -o \
- -name "libbasic_games_native.so" \
+ -name "libbasic_games_native.so" -o \
+ -name "librootbuilder_native.so" -o \
+ -name "libinstaller_omod_native.so" \
\) -exec cp -f {} "${OUT_DIR}/plugins/" \;
# Python plugin loader (small — kept for optional Python support).
diff --git a/libs/installer_omod_native/CMakeLists.txt b/libs/installer_omod_native/CMakeLists.txt
new file mode 100644
index 0000000..920de28
--- /dev/null
+++ b/libs/installer_omod_native/CMakeLists.txt
@@ -0,0 +1,5 @@
+cmake_minimum_required(VERSION 3.16)
+
+project(installer_omod_native)
+
+add_subdirectory(src)
diff --git a/libs/installer_omod_native/src/CMakeLists.txt b/libs/installer_omod_native/src/CMakeLists.txt
new file mode 100644
index 0000000..a4564e1
--- /dev/null
+++ b/libs/installer_omod_native/src/CMakeLists.txt
@@ -0,0 +1,11 @@
+cmake_minimum_required(VERSION 3.16)
+
+file(GLOB installer_omod_native_SOURCES CONFIGURE_DEPENDS
+ ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/*.h
+)
+
+add_library(installer_omod_native SHARED ${installer_omod_native_SOURCES})
+mo2_configure_plugin(installer_omod_native NO_SOURCES WARNINGS OFF)
+target_link_libraries(installer_omod_native PRIVATE mo2::uibase Qt6::Widgets z lzma)
+mo2_install_plugin(installer_omod_native)
diff --git a/libs/installer_omod_native/src/omodinstaller.cpp b/libs/installer_omod_native/src/omodinstaller.cpp
new file mode 100644
index 0000000..592111a
--- /dev/null
+++ b/libs/installer_omod_native/src/omodinstaller.cpp
@@ -0,0 +1,472 @@
+#include "omodinstaller.h"
+
+#include <uibase/guessedvalue.h>
+#include <uibase/iinstallationmanager.h>
+#include <uibase/log.h>
+
+#include <QDir>
+#include <QDirIterator>
+#include <QFile>
+#include <QFileInfo>
+#include <QProcess>
+#include <QTemporaryDir>
+
+#include <zlib.h>
+#include <lzma.h>
+
+#include <cstring>
+#include <stdexcept>
+
+// ============================================================================
+// BinaryReader
+// ============================================================================
+
+OmodInstaller::BinaryReader::BinaryReader(const QByteArray& data) : m_data(data) {}
+
+uint8_t OmodInstaller::BinaryReader::readByte()
+{
+ if (m_pos >= m_data.size())
+ throw std::runtime_error("BinaryReader: read past end");
+ return static_cast<uint8_t>(m_data[m_pos++]);
+}
+
+int32_t OmodInstaller::BinaryReader::readInt32LE()
+{
+ if (m_pos + 4 > m_data.size())
+ throw std::runtime_error("BinaryReader: read past end");
+ int32_t val;
+ std::memcpy(&val, m_data.constData() + m_pos, 4);
+ m_pos += 4;
+ return val;
+}
+
+uint32_t OmodInstaller::BinaryReader::readUInt32LE()
+{
+ if (m_pos + 4 > m_data.size())
+ throw std::runtime_error("BinaryReader: read past end");
+ uint32_t val;
+ std::memcpy(&val, m_data.constData() + m_pos, 4);
+ m_pos += 4;
+ return val;
+}
+
+int64_t OmodInstaller::BinaryReader::readInt64LE()
+{
+ if (m_pos + 8 > m_data.size())
+ throw std::runtime_error("BinaryReader: read past end");
+ int64_t val;
+ std::memcpy(&val, m_data.constData() + m_pos, 8);
+ m_pos += 8;
+ return val;
+}
+
+void OmodInstaller::BinaryReader::skip(int bytes)
+{
+ m_pos += bytes;
+ if (m_pos > m_data.size())
+ throw std::runtime_error("BinaryReader: skip past end");
+}
+
+int OmodInstaller::BinaryReader::read7BitEncodedInt()
+{
+ int result = 0;
+ int shift = 0;
+ while (true) {
+ uint8_t b = readByte();
+ result |= (b & 0x7F) << shift;
+ shift += 7;
+ if ((b & 0x80) == 0)
+ break;
+ }
+ return result;
+}
+
+QString OmodInstaller::BinaryReader::readNetString()
+{
+ int length = read7BitEncodedInt();
+ if (length == 0)
+ return {};
+ QByteArray raw = readBytes(length);
+ return QString::fromUtf8(raw);
+}
+
+QByteArray OmodInstaller::BinaryReader::readBytes(int count)
+{
+ if (m_pos + count > m_data.size())
+ throw std::runtime_error("BinaryReader: read past end");
+ QByteArray result = m_data.mid(m_pos, count);
+ m_pos += count;
+ return result;
+}
+
+bool OmodInstaller::BinaryReader::atEnd() const
+{
+ return m_pos >= m_data.size();
+}
+
+// ============================================================================
+// OmodInstaller
+// ============================================================================
+
+OmodInstaller::OmodInstaller() = default;
+
+bool OmodInstaller::init(MOBase::IOrganizer* organizer)
+{
+ m_organizer = organizer;
+ return true;
+}
+
+QString OmodInstaller::name() const
+{
+ return "OMOD Installer (Native)";
+}
+
+QString OmodInstaller::localizedName() const
+{
+ return "OMOD Installer (Native)";
+}
+
+QString OmodInstaller::author() const
+{
+ return "Fluorine Manager";
+}
+
+QString OmodInstaller::description() const
+{
+ return "Installer for .omod archives (Oblivion Mod Manager format)";
+}
+
+MOBase::VersionInfo OmodInstaller::version() const
+{
+ return MOBase::VersionInfo(1, 0, 0);
+}
+
+QList<MOBase::PluginSetting> OmodInstaller::settings() const
+{
+ return {};
+}
+
+unsigned int OmodInstaller::priority() const
+{
+ return 500;
+}
+
+bool OmodInstaller::isManualInstaller() const
+{
+ return false;
+}
+
+bool OmodInstaller::isArchiveSupported(
+ std::shared_ptr<const MOBase::IFileTree> tree) const
+{
+ if (!tree)
+ return false;
+
+ // An OMOD zip always contains a "config" entry.
+ for (auto it = tree->begin(); it != tree->end(); ++it) {
+ auto entry = *it;
+ if (entry && !entry->isDir() && entry->name().compare("config", Qt::CaseInsensitive) == 0)
+ return true;
+ }
+ return false;
+}
+
+bool OmodInstaller::isArchiveSupported(const QString& archiveName) const
+{
+ return archiveName.toLower().endsWith(".omod");
+}
+
+std::set<QString> OmodInstaller::supportedExtensions() const
+{
+ return {"omod"};
+}
+
+OmodInstaller::EInstallResult OmodInstaller::install(
+ MOBase::GuessedValue<QString>& modName, QString /*gameName*/,
+ const QString& archiveName, const QString& /*version*/, int /*nexusID*/)
+{
+ try {
+ return doInstall(modName, archiveName);
+ } catch (const std::exception& e) {
+ MOBase::log::error("OMOD install failed: {}", e.what());
+ return RESULT_FAILED;
+ }
+}
+
+OmodInstaller::EInstallResult OmodInstaller::doInstall(
+ MOBase::GuessedValue<QString>& modName, const QString& archiveName)
+{
+ // Extract the .omod (zip) to a temp directory using unzip.
+ QTemporaryDir omodTmp;
+ if (!omodTmp.isValid()) {
+ MOBase::log::error("OMOD: failed to create temp directory");
+ return RESULT_FAILED;
+ }
+
+ {
+ QProcess unzip;
+ unzip.setWorkingDirectory(omodTmp.path());
+ unzip.start("unzip", {"-o", archiveName, "-d", omodTmp.path()});
+ unzip.waitForFinished(60000);
+ if (unzip.exitCode() != 0) {
+ MOBase::log::error("OMOD: unzip failed: {}",
+ unzip.readAllStandardError().toStdString());
+ return RESULT_FAILED;
+ }
+ }
+
+ // Read and parse the config entry.
+ QString configPath = QDir(omodTmp.path()).filePath("config");
+ if (!QFile::exists(configPath)) {
+ MOBase::log::warn("OMOD: no config entry found");
+ return RESULT_NOTATTEMPTED;
+ }
+
+ QFile configFile(configPath);
+ if (!configFile.open(QIODevice::ReadOnly)) {
+ MOBase::log::error("OMOD: could not read config");
+ return RESULT_FAILED;
+ }
+ QByteArray configData = configFile.readAll();
+ configFile.close();
+
+ OmodConfig config = parseConfig(configData);
+ if (!config.modName.isEmpty()) {
+ modName.update(config.modName, MOBase::GUESS_META);
+ }
+
+ // Create a separate temp directory for the extracted files.
+ QTemporaryDir extractTmp;
+ if (!extractTmp.isValid()) {
+ MOBase::log::error("OMOD: failed to create extraction temp directory");
+ return RESULT_FAILED;
+ }
+
+ // Extract data and plugins streams.
+ extractStream(omodTmp.path(), "data", "data.crc", config.compressionType,
+ extractTmp.path());
+ extractStream(omodTmp.path(), "plugins", "plugins.crc", config.compressionType,
+ extractTmp.path());
+
+ // Copy readme files if present.
+ QDir omodDir(omodTmp.path());
+ QStringList entries = omodDir.entryList(QDir::Files);
+ for (const QString& entry : entries) {
+ QString lower = entry.toLower();
+ if (lower == "readme" || lower.startsWith("readme.")) {
+ QFile::copy(omodDir.filePath(entry),
+ QDir(extractTmp.path()).filePath(entry));
+ }
+ }
+
+ // Verify we have files to install.
+ QDirIterator dirIt(extractTmp.path(), QDir::Files, QDirIterator::Subdirectories);
+ if (!dirIt.hasNext()) {
+ MOBase::log::warn("OMOD: no files extracted");
+ return RESULT_FAILED;
+ }
+
+ // Repackage extracted files as a standard zip for MO2's installer.
+ QString repackPath = QDir(extractTmp.path()).filePath("_repack.zip");
+ {
+ QProcess zip;
+ zip.setWorkingDirectory(extractTmp.path());
+ // Collect all files relative to extractTmp, excluding the repack zip itself.
+ QStringList zipArgs;
+ zipArgs << "-r" << repackPath << ".";
+ zipArgs << "-x" << "./_repack.zip";
+ zip.start("zip", zipArgs);
+ zip.waitForFinished(120000);
+ if (zip.exitCode() != 0) {
+ MOBase::log::error("OMOD: zip repackaging failed: {}",
+ zip.readAllStandardError().toStdString());
+ return RESULT_FAILED;
+ }
+ }
+
+ return manager()->installArchive(modName, repackPath);
+}
+
+// ============================================================================
+// OMOD binary format parsing
+// ============================================================================
+
+OmodInstaller::OmodConfig OmodInstaller::parseConfig(const QByteArray& data)
+{
+ BinaryReader reader(data);
+ OmodConfig config;
+
+ config.fileVersion = reader.readByte();
+ config.modName = reader.readNetString();
+ config.major = reader.readInt32LE();
+ config.minor = reader.readInt32LE();
+ config.authorName = reader.readNetString();
+ config.email = reader.readNetString();
+ config.website = reader.readNetString();
+ config.desc = reader.readNetString();
+
+ // Windows FILETIME (8 bytes) - skip.
+ reader.skip(8);
+
+ // Compression type: 0 = deflate, 1 = lzma.
+ config.compressionType = reader.readByte();
+
+ return config;
+}
+
+std::vector<OmodInstaller::CrcEntry>
+OmodInstaller::parseCrcFile(const QByteArray& data)
+{
+ BinaryReader reader(data);
+ int count = reader.read7BitEncodedInt();
+
+ std::vector<CrcEntry> entries;
+ entries.reserve(count);
+
+ for (int i = 0; i < count; ++i) {
+ CrcEntry entry;
+ entry.path = reader.readNetString();
+ entry.crc = reader.readUInt32LE();
+ entry.size = reader.readInt64LE();
+ entries.push_back(std::move(entry));
+ }
+
+ return entries;
+}
+
+QByteArray OmodInstaller::decompressStream(const QByteArray& data,
+ uint8_t compressionType)
+{
+ if (compressionType == 0) {
+ // Raw deflate (zlib with wbits = -15, no header).
+ z_stream strm{};
+ if (inflateInit2(&strm, -15) != Z_OK) {
+ throw std::runtime_error("inflateInit2 failed");
+ }
+
+ strm.next_in = reinterpret_cast<Bytef*>(const_cast<char*>(data.constData()));
+ strm.avail_in = static_cast<uInt>(data.size());
+
+ QByteArray result;
+ char buffer[65536];
+
+ int ret;
+ do {
+ strm.next_out = reinterpret_cast<Bytef*>(buffer);
+ strm.avail_out = sizeof(buffer);
+ ret = inflate(&strm, Z_NO_FLUSH);
+ if (ret == Z_STREAM_ERROR || ret == Z_DATA_ERROR || ret == Z_MEM_ERROR) {
+ inflateEnd(&strm);
+ throw std::runtime_error("zlib inflate failed");
+ }
+ int have = sizeof(buffer) - strm.avail_out;
+ result.append(buffer, have);
+ } while (ret != Z_STREAM_END);
+
+ inflateEnd(&strm);
+ return result;
+
+ } else if (compressionType == 1) {
+ // LZMA decompression.
+ lzma_stream strm = LZMA_STREAM_INIT;
+ lzma_ret ret = lzma_alone_decoder(&strm, UINT64_MAX);
+ if (ret != LZMA_OK) {
+ throw std::runtime_error("lzma_alone_decoder init failed");
+ }
+
+ strm.next_in = reinterpret_cast<const uint8_t*>(data.constData());
+ strm.avail_in = data.size();
+
+ QByteArray result;
+ uint8_t buffer[65536];
+
+ do {
+ strm.next_out = buffer;
+ strm.avail_out = sizeof(buffer);
+ ret = lzma_code(&strm, LZMA_FINISH);
+ if (ret != LZMA_OK && ret != LZMA_STREAM_END) {
+ lzma_end(&strm);
+ throw std::runtime_error("lzma decompression failed");
+ }
+ size_t have = sizeof(buffer) - strm.avail_out;
+ result.append(reinterpret_cast<const char*>(buffer), static_cast<int>(have));
+ } while (ret != LZMA_STREAM_END);
+
+ lzma_end(&strm);
+ return result;
+
+ } else {
+ throw std::runtime_error(
+ std::string("Unknown OMOD compression type: ") +
+ std::to_string(compressionType));
+ }
+}
+
+void OmodInstaller::extractStream(const QString& omodDir, const QString& streamName,
+ const QString& crcName, uint8_t compressionType,
+ const QString& outDir)
+{
+ QDir dir(omodDir);
+ QString streamPath = dir.filePath(streamName);
+ QString crcPath = dir.filePath(crcName);
+
+ if (!QFile::exists(streamPath))
+ return;
+
+ if (!QFile::exists(crcPath)) {
+ MOBase::log::warn("OMOD: {} present but {} missing", streamName.toStdString(),
+ crcName.toStdString());
+ return;
+ }
+
+ // Read CRC file.
+ QFile crcFile(crcPath);
+ if (!crcFile.open(QIODevice::ReadOnly))
+ return;
+ QByteArray crcData = crcFile.readAll();
+ crcFile.close();
+
+ std::vector<CrcEntry> fileList = parseCrcFile(crcData);
+ if (fileList.empty())
+ return;
+
+ // Read and decompress the stream.
+ QFile streamFile(streamPath);
+ if (!streamFile.open(QIODevice::ReadOnly))
+ return;
+ QByteArray rawData = streamFile.readAll();
+ streamFile.close();
+
+ QByteArray decompressed = decompressStream(rawData, compressionType);
+
+ // Split the decompressed data into individual files.
+ int offset = 0;
+ for (const CrcEntry& entry : fileList) {
+ if (offset + entry.size > decompressed.size()) {
+ MOBase::log::warn(
+ "OMOD: truncated stream for {} (need {} bytes at offset {}, have {})",
+ entry.path.toStdString(), entry.size, offset, decompressed.size());
+ break;
+ }
+
+ QByteArray fileData = decompressed.mid(offset, static_cast<int>(entry.size));
+ offset += static_cast<int>(entry.size);
+
+ // Normalise Windows path separators.
+ QString normalPath = entry.path;
+ normalPath.replace('\\', '/');
+
+ QString destPath = QDir(outDir).filePath(normalPath);
+
+ // Ensure parent directory exists.
+ QDir().mkpath(QFileInfo(destPath).absolutePath());
+
+ QFile outFile(destPath);
+ if (outFile.open(QIODevice::WriteOnly)) {
+ outFile.write(fileData);
+ outFile.close();
+ } else {
+ MOBase::log::warn("OMOD: could not write {}", destPath.toStdString());
+ }
+ }
+}
diff --git a/libs/installer_omod_native/src/omodinstaller.h b/libs/installer_omod_native/src/omodinstaller.h
new file mode 100644
index 0000000..de48b77
--- /dev/null
+++ b/libs/installer_omod_native/src/omodinstaller.h
@@ -0,0 +1,101 @@
+#ifndef OMODINSTALLER_H
+#define OMODINSTALLER_H
+
+#include <uibase/iplugininstallercustom.h>
+
+#include <QByteArray>
+#include <QList>
+#include <QString>
+
+#include <cstdint>
+#include <vector>
+
+class OmodInstaller : public MOBase::IPluginInstallerCustom
+{
+ Q_OBJECT
+ Q_INTERFACES(MOBase::IPlugin MOBase::IPluginInstaller MOBase::IPluginInstallerCustom)
+ Q_PLUGIN_METADATA(IID "com.tannin.ModOrganizer.PluginInstallerCustom/1.0")
+
+public:
+ OmodInstaller();
+
+ // IPlugin
+ bool init(MOBase::IOrganizer* organizer) override;
+ QString name() const override;
+ QString localizedName() const override;
+ QString author() const override;
+ QString description() const override;
+ MOBase::VersionInfo version() const override;
+ QList<MOBase::PluginSetting> settings() const override;
+
+ // IPluginInstaller
+ unsigned int priority() const override;
+ bool isManualInstaller() const override;
+ bool isArchiveSupported(std::shared_ptr<const MOBase::IFileTree> tree) const override;
+
+ // IPluginInstallerCustom
+ bool isArchiveSupported(const QString& archiveName) const override;
+ std::set<QString> supportedExtensions() const override;
+ EInstallResult install(MOBase::GuessedValue<QString>& modName, QString gameName,
+ const QString& archiveName, const QString& version,
+ int nexusID) override;
+
+private:
+ MOBase::IOrganizer* m_organizer = nullptr;
+
+ // OMOD config parsed from the binary "config" entry.
+ struct OmodConfig
+ {
+ uint8_t fileVersion = 0;
+ QString modName;
+ int32_t major = 0;
+ int32_t minor = 0;
+ QString authorName;
+ QString email;
+ QString website;
+ QString desc;
+ uint8_t compressionType = 0; // 0 = deflate, 1 = lzma
+ };
+
+ // Entry from data.crc / plugins.crc.
+ struct CrcEntry
+ {
+ QString path;
+ uint32_t crc = 0;
+ int64_t size = 0;
+ };
+
+ // Binary reader helper over a QByteArray.
+ class BinaryReader
+ {
+ public:
+ explicit BinaryReader(const QByteArray& data);
+
+ uint8_t readByte();
+ int32_t readInt32LE();
+ uint32_t readUInt32LE();
+ int64_t readInt64LE();
+ void skip(int bytes);
+ int read7BitEncodedInt();
+ QString readNetString();
+ QByteArray readBytes(int count);
+ bool atEnd() const;
+
+ private:
+ const QByteArray& m_data;
+ int m_pos = 0;
+ };
+
+ OmodConfig parseConfig(const QByteArray& data);
+ std::vector<CrcEntry> parseCrcFile(const QByteArray& data);
+ QByteArray decompressStream(const QByteArray& data, uint8_t compressionType);
+
+ EInstallResult doInstall(MOBase::GuessedValue<QString>& modName,
+ const QString& archiveName);
+
+ void extractStream(const QString& omodDir, const QString& streamName,
+ const QString& crcName, uint8_t compressionType,
+ const QString& outDir);
+};
+
+#endif // OMODINSTALLER_H
diff --git a/libs/rootbuilder_native/CMakeLists.txt b/libs/rootbuilder_native/CMakeLists.txt
new file mode 100644
index 0000000..583c0ac
--- /dev/null
+++ b/libs/rootbuilder_native/CMakeLists.txt
@@ -0,0 +1,3 @@
+cmake_minimum_required(VERSION 3.16)
+project(rootbuilder_native)
+add_subdirectory(src)
diff --git a/libs/rootbuilder_native/src/CMakeLists.txt b/libs/rootbuilder_native/src/CMakeLists.txt
new file mode 100644
index 0000000..438c068
--- /dev/null
+++ b/libs/rootbuilder_native/src/CMakeLists.txt
@@ -0,0 +1,18 @@
+cmake_minimum_required(VERSION 3.16)
+
+find_package(Qt6 REQUIRED COMPONENTS Widgets)
+qt_standard_project_setup()
+
+file(GLOB SOURCES *.cpp *.h)
+
+add_library(rootbuilder_native SHARED ${SOURCES})
+
+set_target_properties(rootbuilder_native PROPERTIES
+ AUTOMOC ON
+ CXX_STANDARD 23
+ CXX_STANDARD_REQUIRED ON
+ PREFIX "lib"
+)
+
+target_link_libraries(rootbuilder_native PRIVATE mo2::uibase Qt6::Widgets)
+install(TARGETS rootbuilder_native LIBRARY DESTINATION bin/plugins)
diff --git a/libs/rootbuilder_native/src/rootbuilder.cpp b/libs/rootbuilder_native/src/rootbuilder.cpp
new file mode 100644
index 0000000..6ee2e1c
--- /dev/null
+++ b/libs/rootbuilder_native/src/rootbuilder.cpp
@@ -0,0 +1,674 @@
+#include "rootbuilder.h"
+
+#include <uibase/imodinterface.h>
+#include <uibase/imodlist.h>
+#include <uibase/iplugingame.h>
+#include <uibase/versioninfo.h>
+
+#include <QCoreApplication>
+#include <QCheckBox>
+#include <QComboBox>
+#include <QDialog>
+#include <QDir>
+#include <QDirIterator>
+#include <QFile>
+#include <QFileInfo>
+#include <QHBoxLayout>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QLabel>
+#include <QProcess>
+#include <QPushButton>
+#include <QVBoxLayout>
+
+#include <algorithm>
+#include <set>
+
+// Storage constants
+static const QString STORAGE_SUBDIR = QStringLiteral("rootbuilder");
+static const QString MANIFEST_NAME = QStringLiteral("manifest.json");
+static const QString SETTINGS_NAME = QStringLiteral("settings.json");
+static const QString BACKUP_SUBDIR = QStringLiteral("backup");
+static const QString LEGACY_MANIFEST = QStringLiteral(".rootbuilder_manifest.json");
+static const QString LEGACY_BACKUP = QStringLiteral(".rootbuilder_backup");
+
+// ============================================================================
+// Construction / IPlugin
+// ============================================================================
+
+RootBuilderNative::RootBuilderNative() = default;
+
+bool RootBuilderNative::init(MOBase::IOrganizer* organizer)
+{
+ m_organizer = organizer;
+ migrateLegacy();
+ checkThirdPartyRootBuilder();
+
+ organizer->onAboutToRun([this](const QString& exe) {
+ return onAboutToRun(exe);
+ });
+ organizer->onFinishedRun([this](const QString& exe, unsigned int code) {
+ onFinishedRun(exe, code);
+ });
+
+ return true;
+}
+
+QString RootBuilderNative::name() const
+{
+ return QStringLiteral("Root Builder (Native)");
+}
+
+QString RootBuilderNative::localizedName() const
+{
+ return QStringLiteral("Root Builder (Native)");
+}
+
+QString RootBuilderNative::author() const
+{
+ return QStringLiteral("Fluorine Manager");
+}
+
+QString RootBuilderNative::description() const
+{
+ return QStringLiteral(
+ "Deploys mod files from Root/ subdirectories to the game's root directory. "
+ "Supports copy and symlink modes with auto-deploy on launch.");
+}
+
+MOBase::VersionInfo RootBuilderNative::version() const
+{
+ return MOBase::VersionInfo(1, 0, 0);
+}
+
+QList<MOBase::PluginSetting> RootBuilderNative::settings() const
+{
+ return {};
+}
+
+bool RootBuilderNative::enabledByDefault() const
+{
+ return true;
+}
+
+// ============================================================================
+// IPluginTool
+// ============================================================================
+
+QString RootBuilderNative::displayName() const
+{
+ return QStringLiteral("Root Builder");
+}
+
+QString RootBuilderNative::tooltip() const
+{
+ return QStringLiteral("Deploy mod Root/ files to the game directory");
+}
+
+QIcon RootBuilderNative::icon() const
+{
+ return QIcon();
+}
+
+void RootBuilderNative::display() const
+{
+ QJsonObject settings = loadSettings();
+
+ // We need non-const this for build/clear — display() is const in the
+ // interface, but build/clear mutate the filesystem (not object state).
+ auto* self = const_cast<RootBuilderNative*>(this);
+
+ auto* dialog = new QDialog(parentWidget());
+ dialog->setAttribute(Qt::WA_DeleteOnClose);
+ dialog->setWindowTitle(QStringLiteral("Root Builder"));
+ dialog->resize(350, 220);
+
+ auto* layout = new QVBoxLayout(dialog);
+
+ auto* desc = new QLabel(
+ QStringLiteral("Deploys files from mod Root/ folders to the game directory."));
+ desc->setWordWrap(true);
+ layout->addWidget(desc);
+
+ // Enable checkbox
+ auto* enableCheck = new QCheckBox(QStringLiteral("Auto-deploy on game launch"));
+ enableCheck->setChecked(settings.value(QStringLiteral("enabled")).toBool(false));
+ layout->addWidget(enableCheck);
+
+ // Mode selector
+ auto* modeLayout = new QHBoxLayout();
+ modeLayout->addWidget(new QLabel(QStringLiteral("Deploy mode:")));
+ auto* modeCombo = new QComboBox();
+ modeCombo->addItems({QStringLiteral("copy"), QStringLiteral("link")});
+ modeCombo->setCurrentText(
+ settings.value(QStringLiteral("mode")).toString(QStringLiteral("copy")));
+ modeLayout->addWidget(modeCombo);
+ layout->addLayout(modeLayout);
+
+ // Manual build/clear buttons
+ auto* btnLayout = new QHBoxLayout();
+ auto* buildBtn = new QPushButton(QStringLiteral("Build Now"));
+ auto* clearBtn = new QPushButton(QStringLiteral("Clear Now"));
+ btnLayout->addWidget(buildBtn);
+ btnLayout->addWidget(clearBtn);
+ layout->addLayout(btnLayout);
+
+ // Status label
+ auto* statusLabel = new QLabel();
+ layout->addWidget(statusLabel);
+
+ // Close button
+ auto* closeBtn = new QPushButton(QStringLiteral("Close"));
+ layout->addWidget(closeBtn);
+
+ // Save helper — captures enableCheck and modeCombo
+ auto doSave = [this, enableCheck, modeCombo]() {
+ QJsonObject s;
+ s[QStringLiteral("enabled")] = enableCheck->isChecked();
+ s[QStringLiteral("mode")] = modeCombo->currentText();
+ saveSettings(s);
+ };
+
+ QObject::connect(enableCheck, &QCheckBox::stateChanged, dialog, [doSave](int) {
+ doSave();
+ });
+ QObject::connect(modeCombo, &QComboBox::currentTextChanged, dialog,
+ [doSave](const QString&) { doSave(); });
+
+ QObject::connect(buildBtn, &QPushButton::clicked, dialog,
+ [self, statusLabel]() {
+ int count = self->build();
+ statusLabel->setText(
+ QStringLiteral("Deployed %1 file(s).").arg(count));
+ });
+
+ QObject::connect(clearBtn, &QPushButton::clicked, dialog,
+ [self, statusLabel]() {
+ int count = self->clear();
+ statusLabel->setText(
+ QStringLiteral("Cleared %1 file(s).").arg(count));
+ });
+
+ QObject::connect(closeBtn, &QPushButton::clicked, dialog, &QDialog::accept);
+
+ dialog->exec();
+}
+
+// ============================================================================
+// Hooks
+// ============================================================================
+
+bool RootBuilderNative::onAboutToRun(const QString& /*executable*/)
+{
+ if (isAutoEnabled())
+ build();
+ return true;
+}
+
+void RootBuilderNative::onFinishedRun(const QString& /*executable*/,
+ unsigned int /*exitCode*/)
+{
+ if (isAutoEnabled())
+ clear();
+}
+
+// ============================================================================
+// Storage paths
+// ============================================================================
+
+QString RootBuilderNative::storageDir() const
+{
+ QString d = m_organizer->basePath() + QStringLiteral("/") + STORAGE_SUBDIR;
+ QDir().mkpath(d);
+ return d;
+}
+
+QString RootBuilderNative::backupDir() const
+{
+ return storageDir() + QStringLiteral("/") + BACKUP_SUBDIR;
+}
+
+QString RootBuilderNative::manifestPath() const
+{
+ return storageDir() + QStringLiteral("/") + MANIFEST_NAME;
+}
+
+QString RootBuilderNative::settingsPath() const
+{
+ return storageDir() + QStringLiteral("/") + SETTINGS_NAME;
+}
+
+// ============================================================================
+// Settings (own JSON)
+// ============================================================================
+
+QJsonObject RootBuilderNative::loadSettings() const
+{
+ QFile f(settingsPath());
+ if (!f.open(QIODevice::ReadOnly))
+ return QJsonObject{
+ {QStringLiteral("enabled"), false},
+ {QStringLiteral("mode"), QStringLiteral("copy")}};
+
+ QJsonParseError err;
+ auto doc = QJsonDocument::fromJson(f.readAll(), &err);
+ if (err.error != QJsonParseError::NoError || !doc.isObject())
+ return QJsonObject{
+ {QStringLiteral("enabled"), false},
+ {QStringLiteral("mode"), QStringLiteral("copy")}};
+
+ return doc.object();
+}
+
+void RootBuilderNative::saveSettings(const QJsonObject& settings) const
+{
+ QDir().mkpath(storageDir());
+ QFile f(settingsPath());
+ if (f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
+ f.write(QJsonDocument(settings).toJson(QJsonDocument::Indented));
+ }
+}
+
+bool RootBuilderNative::isAutoEnabled() const
+{
+ return loadSettings().value(QStringLiteral("enabled")).toBool(false);
+}
+
+// ============================================================================
+// Manifest
+// ============================================================================
+
+QJsonObject RootBuilderNative::loadManifest() const
+{
+ QFile f(manifestPath());
+ if (!f.open(QIODevice::ReadOnly))
+ return QJsonObject();
+
+ QJsonParseError err;
+ auto doc = QJsonDocument::fromJson(f.readAll(), &err);
+ if (err.error != QJsonParseError::NoError || !doc.isObject())
+ return QJsonObject();
+
+ return doc.object();
+}
+
+void RootBuilderNative::saveManifest(const QJsonObject& manifest) const
+{
+ QDir().mkpath(storageDir());
+ QFile f(manifestPath());
+ if (f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
+ f.write(QJsonDocument(manifest).toJson(QJsonDocument::Indented));
+ }
+}
+
+void RootBuilderNative::removeManifest() const
+{
+ QFile::remove(manifestPath());
+}
+
+// ============================================================================
+// Legacy migration
+// ============================================================================
+
+void RootBuilderNative::migrateLegacy() const
+{
+ auto* game = m_organizer->managedGame();
+ if (!game)
+ return;
+
+ QString gameDir = game->gameDirectory().absolutePath();
+ QString storage = storageDir();
+
+ // Migrate manifest
+ QString oldManifest = gameDir + QStringLiteral("/") + LEGACY_MANIFEST;
+ if (QFileInfo::exists(oldManifest)) {
+ QString newPath = storage + QStringLiteral("/") + MANIFEST_NAME;
+ if (!QFileInfo::exists(newPath))
+ QFile::copy(oldManifest, newPath);
+ forceRemove(oldManifest);
+ }
+
+ // Migrate backup directory
+ QString oldBackup = gameDir + QStringLiteral("/") + LEGACY_BACKUP;
+ QFileInfo oldBackupInfo(oldBackup);
+ if (oldBackupInfo.isDir()) {
+ QString newBackup = storage + QStringLiteral("/") + BACKUP_SUBDIR;
+ if (!QFileInfo(newBackup).isDir()) {
+ // Copy tree
+ QDir().mkpath(newBackup);
+ QDirIterator it(oldBackup, QDir::Files, QDirIterator::Subdirectories);
+ while (it.hasNext()) {
+ it.next();
+ QString rel = QDir(oldBackup).relativeFilePath(it.filePath());
+ QString dst = newBackup + QStringLiteral("/") + rel;
+ QDir().mkpath(QFileInfo(dst).absolutePath());
+ QFile::copy(it.filePath(), dst);
+ }
+ }
+ // Remove old backup tree
+ QDir(oldBackup).removeRecursively();
+ }
+}
+
+// ============================================================================
+// Third-party conflict detection
+// ============================================================================
+
+void RootBuilderNative::checkThirdPartyRootBuilder() const
+{
+ // Locate the plugins directory (where .py/.so plugins live, next to the binary)
+ QString pluginsDir = QCoreApplication::applicationDirPath() + QStringLiteral("/plugins");
+ if (!QDir(pluginsDir).exists())
+ return;
+
+ QString disabledDir =
+ QCoreApplication::applicationDirPath() + QStringLiteral("/DisabledPlugins");
+
+ QDir dir(pluginsDir);
+ const auto entries = dir.entryInfoList(QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot);
+
+ for (const auto& entry : entries) {
+ bool conflict = false;
+ QString entryName = entry.fileName();
+
+ if (entry.isDir() && entryName.compare(QStringLiteral("rootbuilder"),
+ Qt::CaseInsensitive) == 0) {
+ conflict = true;
+ } else if (entry.isFile() &&
+ entryName.toLower().startsWith(QStringLiteral("rootbuilder")) &&
+ entryName.toLower().endsWith(QStringLiteral(".py"))) {
+ // Don't move our own bundled rootbuilder.py
+ if (entryName == QStringLiteral("rootbuilder.py"))
+ continue;
+ conflict = true;
+ }
+
+ if (conflict) {
+ QDir().mkpath(disabledDir);
+ QString dst = disabledDir + QStringLiteral("/") + entryName;
+ if (QFile::rename(entry.absoluteFilePath(), dst)) {
+ qInfo("Root Builder: moved incompatible third-party plugin "
+ "'%s' to DisabledPlugins/.",
+ qUtf8Printable(entryName));
+ } else {
+ qWarning("Root Builder: failed to move third-party plugin "
+ "'%s' to DisabledPlugins/.",
+ qUtf8Printable(entryName));
+ }
+ }
+ }
+}
+
+// ============================================================================
+// File helpers
+// ============================================================================
+
+QString RootBuilderNative::findRootDir(const QString& modPath)
+{
+ QDir dir(modPath);
+ const auto entries =
+ dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot);
+ for (const auto& entry : entries) {
+ if (entry.fileName().compare(QStringLiteral("root"),
+ Qt::CaseInsensitive) == 0) {
+ return entry.absoluteFilePath();
+ }
+ }
+ return QString();
+}
+
+void RootBuilderNative::ensureReadable(const QString& path)
+{
+ QFileInfo fi(path);
+ if (fi.exists() && !fi.isReadable()) {
+ QFile::setPermissions(path, fi.permissions() | QFileDevice::ReadOwner);
+ }
+}
+
+void RootBuilderNative::ensureWritable(const QString& path)
+{
+ QFileInfo fi(path);
+ if (fi.exists()) {
+ QFile::setPermissions(path, fi.permissions() | QFileDevice::WriteOwner);
+ }
+}
+
+void RootBuilderNative::reflinkCopy(const QString& src, const QString& dst)
+{
+ ensureReadable(src);
+
+ // Try cp --reflink=auto first
+ QProcess proc;
+ proc.setProgram(QStringLiteral("cp"));
+ proc.setArguments({QStringLiteral("--reflink=auto"), QStringLiteral("-f"),
+ QStringLiteral("--"), src, dst});
+ proc.start();
+ if (proc.waitForFinished(10000) && proc.exitCode() == 0)
+ return;
+
+ // Fallback to QFile::copy (remove dst first since QFile::copy won't overwrite)
+ QFile::remove(dst);
+ if (QFile::copy(src, dst))
+ return;
+
+ qWarning("Root Builder: failed to copy %s -> %s",
+ qUtf8Printable(src), qUtf8Printable(dst));
+}
+
+bool RootBuilderNative::forceRemove(const QString& path)
+{
+ if (QFile::remove(path))
+ return true;
+
+ // Fix permissions and retry
+ ensureWritable(QFileInfo(path).absolutePath());
+ ensureWritable(path);
+ if (QFile::remove(path))
+ return true;
+
+ qWarning("Root Builder: could not remove %s", qUtf8Printable(path));
+ return false;
+}
+
+void RootBuilderNative::cleanupEmptyDirs(const QString& baseDir,
+ const QStringList& paths)
+{
+ std::set<QString> dirsToCheck;
+ for (const auto& path : paths) {
+ QString parent = QFileInfo(path).absolutePath();
+ while (!parent.isEmpty() && parent != baseDir) {
+ // Check samefile
+ QFileInfo parentInfo(parent);
+ QFileInfo baseInfo(baseDir);
+ if (parentInfo.absoluteFilePath() == baseInfo.absoluteFilePath())
+ break;
+ dirsToCheck.insert(parent);
+ parent = QFileInfo(parent).absolutePath();
+ }
+ }
+
+ // Sort by length descending (deepest first)
+ QStringList sorted(dirsToCheck.begin(), dirsToCheck.end());
+ std::sort(sorted.begin(), sorted.end(),
+ [](const QString& a, const QString& b) {
+ return a.length() > b.length();
+ });
+
+ for (const auto& d : sorted) {
+ QDir dir(d);
+ if (dir.exists() && dir.isEmpty())
+ dir.rmdir(QStringLiteral("."));
+ }
+}
+
+// ============================================================================
+// Build
+// ============================================================================
+
+int RootBuilderNative::build() const
+{
+ auto* game = m_organizer->managedGame();
+ if (!game)
+ return 0;
+
+ QString gameDir = game->gameDirectory().absolutePath();
+ QString storage = storageDir();
+ auto* modList = m_organizer->modList();
+ QString mode = loadSettings().value(QStringLiteral("mode"))
+ .toString(QStringLiteral("copy"));
+
+ // Clear any previous deployment first
+ QJsonObject existingManifest = loadManifest();
+ if (!existingManifest.isEmpty())
+ const_cast<RootBuilderNative*>(this)->clear();
+
+ QJsonArray deployed;
+ QJsonObject backups;
+ QSet<QString> deployedSet;
+
+ QStringList mods = modList->allModsByProfilePriority();
+ for (const auto& modName : mods) {
+ if (!(modList->state(modName) & MOBase::IModList::STATE_ACTIVE))
+ continue;
+
+ auto* mod = modList->getMod(modName);
+ if (!mod)
+ continue;
+ if (mod->isSeparator() || mod->isBackup() || mod->isForeign())
+ continue;
+
+ QString modPath = mod->absolutePath();
+ QString rootDir = findRootDir(modPath);
+ if (rootDir.isEmpty())
+ continue;
+
+ QDirIterator it(rootDir, QDir::Files, QDirIterator::Subdirectories);
+ while (it.hasNext()) {
+ it.next();
+ QString srcFile = it.filePath();
+ QString rel = QDir(rootDir).relativeFilePath(srcFile);
+ QString dst = gameDir + QStringLiteral("/") + rel;
+
+ // Backup existing file if not already deployed by us
+ if (QFile::exists(dst) && !deployedSet.contains(dst)) {
+ QString bak =
+ backupDir() + QStringLiteral("/") + rel;
+ QDir().mkpath(QFileInfo(bak).absolutePath());
+ ensureWritable(dst);
+ QFile::copy(dst, bak);
+ backups[dst] = bak;
+ }
+
+ QDir().mkpath(QFileInfo(dst).absolutePath());
+
+ // Remove existing file/symlink
+ if (QFileInfo::exists(dst) || QFileInfo(dst).isSymLink())
+ forceRemove(dst);
+
+ // In link mode, .exe and .dll must be copied (Wine resolves
+ // symlinked exe paths to the target, breaking sibling lookups)
+ QString ext = QFileInfo(srcFile).suffix().toLower();
+ if (mode == QStringLiteral("link") && ext != QStringLiteral("exe") &&
+ ext != QStringLiteral("dll")) {
+ QFile::link(srcFile, dst);
+ } else {
+ reflinkCopy(srcFile, dst);
+ }
+
+ if (!deployedSet.contains(dst)) {
+ deployed.append(dst);
+ deployedSet.insert(dst);
+ }
+ }
+ }
+
+ QJsonObject manifest;
+ manifest[QStringLiteral("deployed")] = deployed;
+ manifest[QStringLiteral("backups")] = backups;
+ saveManifest(manifest);
+
+ return deployed.count();
+}
+
+// ============================================================================
+// Clear
+// ============================================================================
+
+int RootBuilderNative::clear() const
+{
+ auto* game = m_organizer->managedGame();
+ if (!game)
+ return 0;
+
+ QString gameDir = game->gameDirectory().absolutePath();
+ QString storage = storageDir();
+
+ QJsonObject manifest = loadManifest();
+ if (manifest.isEmpty())
+ return 0;
+
+ int count = 0;
+ QJsonArray deployedArr = manifest.value(QStringLiteral("deployed")).toArray();
+ QJsonObject backupsObj = manifest.value(QStringLiteral("backups")).toObject();
+
+ QStringList failed;
+ QStringList cleared;
+
+ // Remove deployed files
+ for (const auto& val : deployedArr) {
+ QString path = val.toString();
+ if (QFileInfo::exists(path) || QFileInfo(path).isSymLink()) {
+ if (forceRemove(path)) {
+ ++count;
+ cleared.append(path);
+ } else {
+ failed.append(path);
+ }
+ } else {
+ cleared.append(path);
+ }
+ }
+
+ // Restore backups
+ for (auto it = backupsObj.begin(); it != backupsObj.end(); ++it) {
+ QString dst = it.key();
+ QString bak = it.value().toString();
+ if (QFile::exists(bak)) {
+ QDir().mkpath(QFileInfo(dst).absolutePath());
+ ensureWritable(QFileInfo(dst).absolutePath());
+ if (QFileInfo::exists(dst) || QFileInfo(dst).isSymLink())
+ forceRemove(dst);
+ if (!QFile::rename(bak, dst)) {
+ qWarning("Root Builder: could not restore backup %s -> %s",
+ qUtf8Printable(bak), qUtf8Printable(dst));
+ }
+ }
+ }
+
+ // Clean up backup dir
+ QString bDir = backupDir();
+ if (QFileInfo(bDir).isDir())
+ QDir(bDir).removeRecursively();
+
+ if (!failed.isEmpty()) {
+ // Update manifest to only contain files we couldn't remove
+ QJsonArray failedArr;
+ for (const auto& f : failed)
+ failedArr.append(f);
+
+ QJsonObject retryManifest;
+ retryManifest[QStringLiteral("deployed")] = failedArr;
+ retryManifest[QStringLiteral("backups")] = QJsonObject();
+ saveManifest(retryManifest);
+
+ qWarning("Root Builder: %d file(s) could not be removed. "
+ "They will be retried on next clear.",
+ static_cast<int>(failed.size()));
+ } else {
+ removeManifest();
+ }
+
+ cleanupEmptyDirs(gameDir, cleared);
+ return count;
+}
diff --git a/libs/rootbuilder_native/src/rootbuilder.h b/libs/rootbuilder_native/src/rootbuilder.h
new file mode 100644
index 0000000..121bcc1
--- /dev/null
+++ b/libs/rootbuilder_native/src/rootbuilder.h
@@ -0,0 +1,79 @@
+#ifndef ROOTBUILDER_NATIVE_H
+#define ROOTBUILDER_NATIVE_H
+
+#include <uibase/iplugintool.h>
+
+#include <QJsonObject>
+#include <QString>
+
+class RootBuilderNative : public MOBase::IPluginTool
+{
+ Q_OBJECT
+ Q_INTERFACES(MOBase::IPlugin MOBase::IPluginTool)
+ Q_PLUGIN_METADATA(IID "com.tannin.ModOrganizer.PluginTool/1.0")
+
+public:
+ RootBuilderNative();
+
+ // IPlugin
+ bool init(MOBase::IOrganizer* organizer) override;
+ QString name() const override;
+ QString localizedName() const override;
+ QString author() const override;
+ QString description() const override;
+ MOBase::VersionInfo version() const override;
+ QList<MOBase::PluginSetting> settings() const override;
+ bool enabledByDefault() const override;
+
+ // IPluginTool
+ QString displayName() const override;
+ QString tooltip() const override;
+ QIcon icon() const override;
+
+public slots:
+ void display() const override;
+
+private:
+ // Storage paths
+ QString storageDir() const;
+ QString backupDir() const;
+ QString manifestPath() const;
+ QString settingsPath() const;
+
+ // Settings (own JSON, not pluginSetting)
+ QJsonObject loadSettings() const;
+ void saveSettings(const QJsonObject& settings) const;
+ bool isAutoEnabled() const;
+
+ // Manifest
+ QJsonObject loadManifest() const;
+ void saveManifest(const QJsonObject& manifest) const;
+ void removeManifest() const;
+
+ // Legacy migration
+ void migrateLegacy() const;
+
+ // Third-party conflict detection
+ void checkThirdPartyRootBuilder() const;
+
+ // Build / Clear
+ int build() const;
+ int clear() const;
+
+ // File operations
+ static QString findRootDir(const QString& modPath);
+ static void reflinkCopy(const QString& src, const QString& dst);
+ static void ensureReadable(const QString& path);
+ static void ensureWritable(const QString& path);
+ static bool forceRemove(const QString& path);
+ static void cleanupEmptyDirs(const QString& baseDir,
+ const QStringList& paths);
+
+ // Hooks
+ bool onAboutToRun(const QString& executable);
+ void onFinishedRun(const QString& executable, unsigned int exitCode);
+
+ MOBase::IOrganizer* m_organizer = nullptr;
+};
+
+#endif // ROOTBUILDER_NATIVE_H