aboutsummaryrefslogtreecommitdiff
path: root/libs
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-04-10 05:03:45 -0500
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-04-10 05:03:45 -0500
commit01abc6994d9a477904af3fdf326ee2fc7163b3e1 (patch)
tree12a33d5712002e3ebf180f4666c5a45ac688bf79 /libs
parent0b726b03d0d5c018004af438c8080f8d5ea7d432 (diff)
Add BethINI Pie tool plugin
Native IPluginTool that integrates BethINI Pie (performance INI editor) into the Tools menu. Auto-downloads the latest Linux build from GitHub releases, checks for updates via commit SHA, and pre-configures it for the current game and profile INI directory. Supports: Skyrim SE, Fallout 4, Fallout New Vegas, Starfield. Includes post-extraction fixes for PyInstaller bundle quirks: - Patches tm.tcl hardcoded Tcl module path - Symlinks apps/icons/fonts from _internal/ to executable directory - Sets TCL_LIBRARY/TK_LIBRARY env vars on launch Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'libs')
-rw-r--r--libs/tool_bethinipie/CMakeLists.txt5
-rw-r--r--libs/tool_bethinipie/src/CMakeLists.txt12
-rw-r--r--libs/tool_bethinipie/src/bethinipie.cpp457
-rw-r--r--libs/tool_bethinipie/src/bethinipie.h64
-rw-r--r--libs/tool_bethinipie/src/bethinipie.qrc5
-rw-r--r--libs/tool_bethinipie/src/resources/icon.pngbin0 -> 118192 bytes
6 files changed, 543 insertions, 0 deletions
diff --git a/libs/tool_bethinipie/CMakeLists.txt b/libs/tool_bethinipie/CMakeLists.txt
new file mode 100644
index 0000000..f66a14d
--- /dev/null
+++ b/libs/tool_bethinipie/CMakeLists.txt
@@ -0,0 +1,5 @@
+cmake_minimum_required(VERSION 3.16)
+
+project(tool_bethinipie)
+
+add_subdirectory(src)
diff --git a/libs/tool_bethinipie/src/CMakeLists.txt b/libs/tool_bethinipie/src/CMakeLists.txt
new file mode 100644
index 0000000..df2b046
--- /dev/null
+++ b/libs/tool_bethinipie/src/CMakeLists.txt
@@ -0,0 +1,12 @@
+cmake_minimum_required(VERSION 3.16)
+
+file(GLOB bethinipie_SOURCES CONFIGURE_DEPENDS
+ ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/*.h
+ ${CMAKE_CURRENT_SOURCE_DIR}/*.qrc
+)
+
+add_library(tool_bethinipie SHARED ${bethinipie_SOURCES})
+mo2_configure_plugin(tool_bethinipie NO_SOURCES WARNINGS 4)
+target_link_libraries(tool_bethinipie PRIVATE mo2::uibase)
+mo2_install_plugin(tool_bethinipie)
diff --git a/libs/tool_bethinipie/src/bethinipie.cpp b/libs/tool_bethinipie/src/bethinipie.cpp
new file mode 100644
index 0000000..152e809
--- /dev/null
+++ b/libs/tool_bethinipie/src/bethinipie.cpp
@@ -0,0 +1,457 @@
+#include "bethinipie.h"
+
+#include <QCoreApplication>
+#include <QDesktopServices>
+#include <QDir>
+#include <QEventLoop>
+#include <QFile>
+#include <QFileInfo>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QMessageBox>
+#include <QNetworkAccessManager>
+#include <QNetworkReply>
+#include <QNetworkRequest>
+#include <QProcess>
+#include <QProgressDialog>
+#include <QStandardPaths>
+#include <QTextStream>
+#include <QTimer>
+
+#include <uibase/imoinfo.h>
+#include <uibase/iplugingame.h>
+#include <uibase/iprofile.h>
+#include <uibase/log.h>
+
+using namespace MOBase;
+
+static const char* GITHUB_REPO = "SulfurNitride/Fluorine-Bethini-Pie-Performance-INI-Editor";
+static const char* RELEASE_TAG = "latest";
+static const char* ASSET_NAME = "BethiniPie-linux.tar.gz";
+
+// Maps Fluorine gameName() to BethIni Pie's Bethini.json INI directory key
+static const QMap<QString, QString> s_GameIniKeys = {
+ {"Skyrim Special Edition", "sSkyrim Special EditionINIPath"},
+ {"Fallout 4", "sFallout 4INIPath"},
+ {"Fallout New Vegas", "sFallout New VegasINIPath"},
+ {"Starfield", "sStarfieldINIPath"},
+};
+
+BethiniPie::BethiniPie() : m_MOInfo(nullptr), m_Network(nullptr) {}
+
+bool BethiniPie::init(IOrganizer* moInfo)
+{
+ m_MOInfo = moInfo;
+ return true;
+}
+
+QString BethiniPie::name() const
+{
+ return "BethINI Pie";
+}
+
+QString BethiniPie::localizedName() const
+{
+ return tr("BethINI Pie");
+}
+
+QString BethiniPie::author() const
+{
+ return "Fluorine";
+}
+
+QString BethiniPie::description() const
+{
+ return tr("Performance INI editor for Bethesda games");
+}
+
+VersionInfo BethiniPie::version() const
+{
+ return VersionInfo(1, 0, 0, VersionInfo::RELEASE_FINAL);
+}
+
+QList<PluginSetting> BethiniPie::settings() const
+{
+ return {};
+}
+
+QString BethiniPie::displayName() const
+{
+ return tr("BethINI Pie");
+}
+
+QString BethiniPie::tooltip() const
+{
+ return tr("Open BethINI Pie to optimize INI settings for the current game");
+}
+
+QIcon BethiniPie::icon() const
+{
+ return QIcon(":/bethinipie/icon");
+}
+
+QString BethiniPie::toolDir() const
+{
+ QString dataDir =
+ QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation);
+ return dataDir + "/fluorine/tools/bethini-pie";
+}
+
+QString BethiniPie::executablePath() const
+{
+ return toolDir() + "/BethiniPie/Bethini";
+}
+
+QString BethiniPie::localSha() const
+{
+ QFile f(toolDir() + "/.sha");
+ if (f.open(QIODevice::ReadOnly | QIODevice::Text)) {
+ return QString::fromUtf8(f.readAll()).trimmed();
+ }
+ return {};
+}
+
+void BethiniPie::saveLocalSha(const QString& sha) const
+{
+ QDir().mkpath(toolDir());
+ QFile f(toolDir() + "/.sha");
+ if (f.open(QIODevice::WriteOnly | QIODevice::Text)) {
+ f.write(sha.toUtf8());
+ }
+}
+
+QString BethiniPie::bethiniGameName() const
+{
+ if (!m_MOInfo || !m_MOInfo->managedGame())
+ return {};
+ QString game = m_MOInfo->managedGame()->gameName();
+ if (s_GameIniKeys.contains(game))
+ return game;
+ return {};
+}
+
+QString BethiniPie::iniDirectoryKey() const
+{
+ QString game = bethiniGameName();
+ return s_GameIniKeys.value(game);
+}
+
+void BethiniPie::writeBethiniConfig(const QString& bethiniDir,
+ const QString& iniPath) const
+{
+ QString game = bethiniGameName();
+ QString iniKey = iniDirectoryKey();
+ if (game.isEmpty() || iniKey.isEmpty())
+ return;
+
+ // Write a Bethini.ini that pre-selects the game and points at the INI directory
+ QString configPath = bethiniDir + "/Bethini.ini";
+ QFile f(configPath);
+ if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) {
+ log::error("BethINI Pie: failed to write config: {}", configPath);
+ return;
+ }
+
+ QTextStream out(&f);
+ out << "[General]\n";
+ out << "sAppName=" << game << "\n";
+ out << "bAlwaysSelectGame=0\n";
+ out << "\n";
+ out << "[Directories]\n";
+ out << iniKey << "=" << iniPath << "\n";
+
+ // Starfield has a second directory key for base game INIs
+ if (game == "Starfield" && m_MOInfo->managedGame()) {
+ QString gameDir = m_MOInfo->managedGame()->gameDirectory().absolutePath();
+ out << "sStarfieldPath=" << gameDir << "\n";
+ }
+
+ out << "\n";
+ f.close();
+
+ log::info("BethINI Pie: wrote config for {} with INI path: {}", game, iniPath);
+}
+
+bool BethiniPie::ensureUpToDate() const
+{
+ if (!m_Network)
+ m_Network = new QNetworkAccessManager();
+
+ // Query the latest release from GitHub API
+ QString apiUrl = QString("https://api.github.com/repos/%1/releases/tags/%2")
+ .arg(GITHUB_REPO, RELEASE_TAG);
+
+ QNetworkRequest req{QUrl(apiUrl)};
+ req.setHeader(QNetworkRequest::UserAgentHeader, "Fluorine-Manager");
+ req.setRawHeader("Accept", "application/vnd.github+json");
+
+ QEventLoop loop;
+ QNetworkReply* reply = m_Network->get(req);
+ QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
+
+ // Timeout after 10 seconds
+ QTimer timeout;
+ timeout.setSingleShot(true);
+ QObject::connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit);
+ timeout.start(10000);
+ loop.exec();
+
+ if (!reply->isFinished() || reply->error() != QNetworkReply::NoError) {
+ // Network error -- if we have a cached version, use it
+ QString err =
+ reply->isFinished() ? reply->errorString() : "request timed out";
+ log::warn("BethINI Pie: failed to check for updates: {}", err);
+ reply->deleteLater();
+
+ if (QFile::exists(executablePath())) {
+ log::info("BethINI Pie: using cached version");
+ return true;
+ }
+ return false;
+ }
+
+ QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
+ reply->deleteLater();
+ QJsonObject release = doc.object();
+
+ // Extract the commit SHA from the release body
+ // The workflow puts "**Commit:** `<sha>`" in the body
+ QString body = release["body"].toString();
+ QString remoteSha;
+ int commitIdx = body.indexOf("`", body.indexOf("**Commit:**"));
+ if (commitIdx >= 0) {
+ int endIdx = body.indexOf("`", commitIdx + 1);
+ remoteSha = body.mid(commitIdx + 1, endIdx - commitIdx - 1).trimmed();
+ }
+
+ if (remoteSha.isEmpty()) {
+ log::warn("BethINI Pie: could not parse commit SHA from release");
+ if (QFile::exists(executablePath()))
+ return true;
+ // Try to download anyway -- fall through
+ }
+
+ // Check if we're up to date
+ QString currentSha = localSha();
+ if (!remoteSha.isEmpty() && remoteSha == currentSha &&
+ QFile::exists(executablePath())) {
+ log::info("BethINI Pie: up to date ({})", remoteSha.left(8));
+ return true;
+ }
+
+ // Find the download URL for the Linux tarball
+ QString downloadUrl;
+ QJsonArray assets = release["assets"].toArray();
+ for (const QJsonValue& asset : assets) {
+ if (asset.toObject()["name"].toString() == ASSET_NAME) {
+ downloadUrl = asset.toObject()["browser_download_url"].toString();
+ break;
+ }
+ }
+
+ if (downloadUrl.isEmpty()) {
+ log::error("BethINI Pie: no Linux asset found in release");
+ return QFile::exists(executablePath());
+ }
+
+ // Download with progress dialog
+ log::info("BethINI Pie: downloading update from {}", downloadUrl);
+
+ QProgressDialog progress(tr("Downloading BethINI Pie..."), tr("Cancel"), 0, 100,
+ parentWidget());
+ progress.setWindowModality(Qt::WindowModal);
+ progress.setMinimumDuration(0);
+ progress.setValue(0);
+
+ QNetworkRequest dlReq{QUrl(downloadUrl)};
+ dlReq.setHeader(QNetworkRequest::UserAgentHeader, "Fluorine-Manager");
+ dlReq.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
+ QNetworkRequest::NoLessSafeRedirectPolicy);
+
+ QNetworkReply* dlReply = m_Network->get(dlReq);
+
+ QObject::connect(dlReply, &QNetworkReply::downloadProgress,
+ [&progress](qint64 received, qint64 total) {
+ if (total > 0) {
+ progress.setMaximum(static_cast<int>(total));
+ progress.setValue(static_cast<int>(received));
+ }
+ });
+
+ QEventLoop dlLoop;
+ QObject::connect(dlReply, &QNetworkReply::finished, &dlLoop, &QEventLoop::quit);
+ QObject::connect(&progress, &QProgressDialog::canceled, dlReply, &QNetworkReply::abort);
+ dlLoop.exec();
+
+ if (dlReply->error() != QNetworkReply::NoError) {
+ log::error("BethINI Pie: download failed: {}", dlReply->errorString());
+ dlReply->deleteLater();
+ progress.close();
+ return QFile::exists(executablePath());
+ }
+
+ QByteArray data = dlReply->readAll();
+ dlReply->deleteLater();
+ progress.close();
+
+ // Save tarball to temp file and extract
+ QString dir = toolDir();
+ QDir().mkpath(dir);
+
+ QString tarPath = dir + "/BethiniPie-linux.tar.gz";
+ {
+ QFile tarFile(tarPath);
+ if (!tarFile.open(QIODevice::WriteOnly)) {
+ log::error("BethINI Pie: failed to write tarball");
+ return false;
+ }
+ tarFile.write(data);
+ }
+
+ // Remove old installation
+ QDir oldDir(dir + "/BethiniPie");
+ if (oldDir.exists())
+ oldDir.removeRecursively();
+
+ // Extract
+ QProcess tar;
+ tar.setWorkingDirectory(dir);
+ tar.start("tar", {"xzf", tarPath});
+ tar.waitForFinished(30000);
+
+ // Clean up tarball
+ QFile::remove(tarPath);
+
+ if (tar.exitCode() != 0) {
+ log::error("BethINI Pie: extraction failed: {}", tar.readAllStandardError());
+ return false;
+ }
+
+ // Make executable
+ QFile::setPermissions(executablePath(),
+ QFileDevice::ReadOwner | QFileDevice::WriteOwner |
+ QFileDevice::ExeOwner | QFileDevice::ReadGroup |
+ QFileDevice::ExeGroup);
+
+ // Fix Tcl module path -- PyInstaller bundles hardcode /usr/share/tcltk/... in tm.tcl
+ // but the actual .tm files are in _internal/_tcl_data/tcl8/
+ {
+ QString tmTclPath = dir + "/BethiniPie/_internal/_tcl_data/tm.tcl";
+ QFile tmTcl(tmTclPath);
+ if (tmTcl.open(QIODevice::ReadWrite | QIODevice::Text)) {
+ QString content = QString::fromUtf8(tmTcl.readAll());
+ // Replace the hardcoded system path with a relative reference that Tcl
+ // will resolve at runtime via [info library]
+ content.replace(
+ "variable paths {/usr/share/tcltk/tcl8.6/tcl8}",
+ "variable paths [list [file join [info library] tcl8]]");
+ tmTcl.seek(0);
+ tmTcl.resize(0);
+ tmTcl.write(content.toUtf8());
+ tmTcl.close();
+ log::info("BethINI Pie: patched tm.tcl module path");
+ }
+ }
+
+ // PyInstaller puts datas into _internal/ but BethIni Pie expects apps/, icons/,
+ // fonts/ in the executable's parent directory. Create symlinks.
+ {
+ QString bethiniDir = dir + "/BethiniPie";
+ QString internalDir = bethiniDir + "/_internal";
+ for (const QString& subdir : {"apps", "icons", "fonts"}) {
+ QString target = internalDir + "/" + subdir;
+ QString link = bethiniDir + "/" + subdir;
+ if (QFileInfo::exists(target) && !QFileInfo::exists(link)) {
+ QFile::link(target, link);
+ log::info("BethINI Pie: symlinked {} -> {}", link, target);
+ }
+ }
+ }
+
+ // Save the SHA
+ if (!remoteSha.isEmpty())
+ saveLocalSha(remoteSha);
+
+ log::info("BethINI Pie: updated to {}", remoteSha.left(8));
+ return true;
+}
+
+void BethiniPie::display() const
+{
+ if (!m_MOInfo) {
+ QMessageBox::critical(parentWidget(), tr("Error"),
+ tr("Plugin not initialized."));
+ return;
+ }
+
+ // Check if the current game is supported
+ QString game = bethiniGameName();
+ if (game.isEmpty()) {
+ QString currentGame =
+ m_MOInfo->managedGame() ? m_MOInfo->managedGame()->gameName() : "unknown";
+ QMessageBox::warning(
+ parentWidget(), tr("Unsupported Game"),
+ tr("BethINI Pie does not support %1.\n\nSupported games: Skyrim Special "
+ "Edition, Fallout 4, Fallout New Vegas, Starfield.")
+ .arg(currentGame));
+ return;
+ }
+
+ // Download / update BethINI Pie
+ if (!ensureUpToDate()) {
+ QMessageBox::critical(
+ parentWidget(), tr("Download Failed"),
+ tr("Failed to download BethINI Pie. Check your internet connection and "
+ "try again."));
+ return;
+ }
+
+ // Determine the INI directory to point BethINI Pie at
+ // Use the first INI file's directory from the current profile
+ QStringList iniFiles = m_MOInfo->managedGame()->iniFiles();
+ QString iniPath;
+ if (!iniFiles.isEmpty()) {
+ QString firstIni = m_MOInfo->profile()->absoluteIniFilePath(iniFiles[0]);
+ iniPath = QFileInfo(firstIni).absolutePath();
+ }
+
+ if (iniPath.isEmpty()) {
+ QMessageBox::warning(parentWidget(), tr("No INI Files"),
+ tr("Could not determine INI file location for the "
+ "current profile."));
+ return;
+ }
+
+ // Write the Bethini.ini config
+ QString bethiniDir = toolDir() + "/BethiniPie";
+ writeBethiniConfig(bethiniDir, iniPath);
+
+ // Launch BethINI Pie
+ QString exe = executablePath();
+ log::info("BethINI Pie: launching {} for {} (INIs at {})", exe, game, iniPath);
+
+ QProcess* proc = new QProcess();
+ proc->setWorkingDirectory(bethiniDir);
+ proc->setProgram(exe);
+
+ // Set TCL/TK library paths so the bundled Tcl finds its data files
+ QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
+ env.insert("TCL_LIBRARY", bethiniDir + "/_internal/_tcl_data");
+ env.insert("TK_LIBRARY", bethiniDir + "/_internal/_tk_data");
+ proc->setProcessEnvironment(env);
+
+ // Clean up QProcess when it finishes
+ QObject::connect(proc, qOverload<int, QProcess::ExitStatus>(&QProcess::finished),
+ proc, &QProcess::deleteLater);
+
+ proc->start();
+
+ if (!proc->waitForStarted(5000)) {
+ log::error("BethINI Pie: failed to start: {}", proc->errorString());
+ QMessageBox::critical(parentWidget(), tr("Launch Failed"),
+ tr("Failed to launch BethINI Pie:\n%1")
+ .arg(proc->errorString()));
+ delete proc;
+ }
+}
diff --git a/libs/tool_bethinipie/src/bethinipie.h b/libs/tool_bethinipie/src/bethinipie.h
new file mode 100644
index 0000000..b52d5a9
--- /dev/null
+++ b/libs/tool_bethinipie/src/bethinipie.h
@@ -0,0 +1,64 @@
+#ifndef BETHINIPIE_H
+#define BETHINIPIE_H
+
+#include <uibase/iplugintool.h>
+
+class QNetworkAccessManager;
+
+class BethiniPie : public MOBase::IPluginTool
+{
+ Q_OBJECT
+ Q_INTERFACES(MOBase::IPlugin MOBase::IPluginTool)
+ Q_PLUGIN_METADATA(IID "com.fluorine.BethiniPie")
+
+public:
+ BethiniPie();
+
+ bool init(MOBase::IOrganizer* moInfo) 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;
+
+ QString displayName() const override;
+ QString tooltip() const override;
+ QIcon icon() const override;
+
+public slots:
+ void display() const override;
+
+private:
+ /// Returns ~/.local/share/fluorine/tools/bethini-pie/
+ QString toolDir() const;
+
+ /// Returns the path to the BethiniPie executable inside toolDir()
+ QString executablePath() const;
+
+ /// Returns the stored SHA from .sha file, or empty string
+ QString localSha() const;
+
+ /// Writes the SHA to .sha file
+ void saveLocalSha(const QString& sha) const;
+
+ /// Maps Fluorine game name to BethIni Pie app name (they match, but provides
+ /// a validation point)
+ QString bethiniGameName() const;
+
+ /// Returns the BethIni.ini directory key for the current game
+ /// e.g. "sSkyrim Special EditionINIPath"
+ QString iniDirectoryKey() const;
+
+ /// Writes/updates the Bethini.ini config pointing at the profile's INI directory
+ void writeBethiniConfig(const QString& bethiniDir,
+ const QString& iniPath) const;
+
+ /// Checks for update and downloads if needed. Returns true if ready to launch.
+ bool ensureUpToDate() const;
+
+ const MOBase::IOrganizer* m_MOInfo;
+ mutable QNetworkAccessManager* m_Network;
+};
+
+#endif // BETHINIPIE_H
diff --git a/libs/tool_bethinipie/src/bethinipie.qrc b/libs/tool_bethinipie/src/bethinipie.qrc
new file mode 100644
index 0000000..06273a1
--- /dev/null
+++ b/libs/tool_bethinipie/src/bethinipie.qrc
@@ -0,0 +1,5 @@
+<RCC>
+ <qresource prefix="/bethinipie">
+ <file alias="icon">resources/icon.png</file>
+ </qresource>
+</RCC>
diff --git a/libs/tool_bethinipie/src/resources/icon.png b/libs/tool_bethinipie/src/resources/icon.png
new file mode 100644
index 0000000..2e10e17
--- /dev/null
+++ b/libs/tool_bethinipie/src/resources/icon.png
Binary files differ