diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-10 05:03:45 -0500 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-10 05:03:45 -0500 |
| commit | 01abc6994d9a477904af3fdf326ee2fc7163b3e1 (patch) | |
| tree | 12a33d5712002e3ebf180f4666c5a45ac688bf79 /libs | |
| parent | 0b726b03d0d5c018004af438c8080f8d5ea7d432 (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.txt | 5 | ||||
| -rw-r--r-- | libs/tool_bethinipie/src/CMakeLists.txt | 12 | ||||
| -rw-r--r-- | libs/tool_bethinipie/src/bethinipie.cpp | 457 | ||||
| -rw-r--r-- | libs/tool_bethinipie/src/bethinipie.h | 64 | ||||
| -rw-r--r-- | libs/tool_bethinipie/src/bethinipie.qrc | 5 | ||||
| -rw-r--r-- | libs/tool_bethinipie/src/resources/icon.png | bin | 0 -> 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 Binary files differnew file mode 100644 index 0000000..2e10e17 --- /dev/null +++ b/libs/tool_bethinipie/src/resources/icon.png |
