aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-04-30 18:32:24 -0500
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-04-30 18:32:24 -0500
commitaba2dc0df5c50c244904f2b1bb458334a851d426 (patch)
treebfef0b3de1cad72522ace9f0aa447def18670c00
parent6243e1a79550e9cff9837f5449f3b9150127b8e8 (diff)
Clean up empty dirs left by VFS, suppress noisy Qt debug
Fixes #64: VFS now tracks the directories it creates outside the data dir (external mappings + RootBuilder game-root deploys) and removes them on unmount, but only if still empty. Pre-existing user dirs and the game root itself are never touched. RootBuilder manifest gains a "dirs" field so the cleanup survives a crashed session. Also drops a Path-repr qDebug in bg3_file_mapper that crashed Qt's logger on surrogate-escaped paths, and defaults QT_LOGGING_RULES to default.debug=false in the launcher and AppRun (still overridable) so the same class of plugin bug stays muted in shipped builds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
-rwxr-xr-xdocker/build-inner.sh10
-rw-r--r--libs/basic_games/games/baldursgate3/bg3_file_mapper.py7
-rw-r--r--src/src/fuseconnector.cpp122
-rw-r--r--src/src/fuseconnector.h5
4 files changed, 124 insertions, 20 deletions
diff --git a/docker/build-inner.sh b/docker/build-inner.sh
index 957d53e..fb5497b 100755
--- a/docker/build-inner.sh
+++ b/docker/build-inner.sh
@@ -376,6 +376,12 @@ export FLUORINE_ORIG_QT_PLUGIN_PATH="${QT_PLUGIN_PATH:-}"
# Clear it for our process; game launches restore via FLUORINE_ORIG_LD_PRELOAD.
unset LD_PRELOAD
+# Suppress Qt debug logging by default. Some plugins (e.g. BG3 file mapper)
+# qDebug() Path objects whose surrogate-escaped bytes crash Qt's logger with
+# "unicode category" errors. User can re-enable with QT_LOGGING_RULES override.
+: "${QT_LOGGING_RULES:=default.debug=false}"
+export QT_LOGGING_RULES
+
# ── Sync entire app to ~/.local/share/fluorine/bin/ ──
# This gives instances a stable symlink target that won't break if the user
# moves or deletes the original tarball extraction directory.
@@ -710,6 +716,10 @@ export FLUORINE_ORIG_QT_PLUGIN_PATH="${QT_PLUGIN_PATH:-}"
# Steam injects 32-bit gameoverlayrenderer.so via LD_PRELOAD — clear it.
unset LD_PRELOAD
+# Suppress Qt debug logging by default (see comment in main launcher).
+: "${QT_LOGGING_RULES:=default.debug=false}"
+export QT_LOGGING_RULES
+
export PATH="${BIN}:${PATH}"
# Replace (not append) LD_LIBRARY_PATH — Steam game mode injects its runtime
# libs which break Python/Qt. RPATH handles the binary's own deps.
diff --git a/libs/basic_games/games/baldursgate3/bg3_file_mapper.py b/libs/basic_games/games/baldursgate3/bg3_file_mapper.py
index e2c54e9..cb6dd21 100644
--- a/libs/basic_games/games/baldursgate3/bg3_file_mapper.py
+++ b/libs/basic_games/games/baldursgate3/bg3_file_mapper.py
@@ -4,7 +4,7 @@ import platform
from pathlib import Path
from typing import Callable, Optional
-from PyQt6.QtCore import QDir, QLoggingCategory, qDebug, qInfo, qWarning
+from PyQt6.QtCore import QDir, qDebug, qInfo, qWarning
from PyQt6.QtWidgets import QApplication
import mobase
@@ -71,11 +71,6 @@ class BG3FileMapper(mobase.IPluginFileMapper):
progress.setValue(len(active_mods) + 1)
QApplication.processEvents()
progress.close()
- cat = QLoggingCategory.defaultCategory()
- if cat is not None and cat.isDebugEnabled():
- qDebug(
- f"resolved mappings: { {m.source: m.destination for m in self.current_mappings} }"
- )
return self.current_mappings
def map_files(
diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp
index ac2b29a..3685560 100644
--- a/src/src/fuseconnector.cpp
+++ b/src/src/fuseconnector.cpp
@@ -791,12 +791,37 @@ void FuseConnector::deployExternalMappings(const MappingType& mapping,
// (running through Proton) can see the files.
std::error_code ec;
+ // Helper: create a directory (and all missing parents) and record each
+ // segment we actually created so cleanup can remove it later.
+ auto createTrackedDirs = [&](const fs::path& dirPath) {
+ std::vector<fs::path> toCreate;
+ for (fs::path p = dirPath; !p.empty() && !fs::exists(p, ec);
+ p = p.parent_path()) {
+ toCreate.push_back(p);
+ if (p == p.root_path()) {
+ break;
+ }
+ }
+ // Build top-down so nested dirs succeed.
+ for (auto it = toCreate.rbegin(); it != toCreate.rend(); ++it) {
+ if (fs::create_directory(*it, ec) && !ec) {
+ m_externalDirs.push_back(it->string());
+ }
+ }
+ };
+
if (map.isDirectory) {
const fs::path srcPath(src.toStdString());
if (!fs::exists(srcPath, ec)) {
continue;
}
+ // Pre-create the createTarget root so an empty source still leaves
+ // the target tracked for removal.
+ if (map.createTarget) {
+ createTrackedDirs(fs::path(dst.toStdString()));
+ }
+
for (auto it = fs::recursive_directory_iterator(
srcPath, fs::directory_options::skip_permission_denied);
it != fs::recursive_directory_iterator(); ++it) {
@@ -808,9 +833,9 @@ void FuseConnector::deployExternalMappings(const MappingType& mapping,
const fs::path destPath = fs::path(dst.toStdString()) / rel;
if (entry.is_directory(ec)) {
- fs::create_directories(destPath, ec);
+ createTrackedDirs(destPath);
} else if (entry.is_regular_file(ec) || entry.is_symlink(ec)) {
- fs::create_directories(destPath.parent_path(), ec);
+ createTrackedDirs(destPath.parent_path());
if (fs::exists(destPath, ec) && !fs::is_symlink(destPath, ec)) {
// Never overwrite real game files — only replace our own symlinks.
continue;
@@ -832,7 +857,7 @@ void FuseConnector::deployExternalMappings(const MappingType& mapping,
} else {
// Single file symlink.
const fs::path destPath(dst.toStdString());
- fs::create_directories(destPath.parent_path(), ec);
+ createTrackedDirs(destPath.parent_path());
if (fs::exists(destPath, ec) && !fs::is_symlink(destPath, ec)) {
continue;
}
@@ -861,7 +886,7 @@ void FuseConnector::deployExternalMappings(const MappingType& mapping,
void FuseConnector::cleanupExternalMappings()
{
- if (m_externalSymlinks.empty()) {
+ if (m_externalSymlinks.empty() && m_externalDirs.empty()) {
return;
}
@@ -872,8 +897,27 @@ void FuseConnector::cleanupExternalMappings()
}
}
- log::debug("Cleaned up {} external symlinks", m_externalSymlinks.size());
+ // Remove created dirs deepest-first so children are gone before parents.
+ // Only removes if empty — any user-placed file inside causes the dir (and
+ // its ancestors) to stay, which is the safe behavior.
+ std::sort(m_externalDirs.begin(), m_externalDirs.end(),
+ [](const std::string& a, const std::string& b) {
+ return a.size() > b.size();
+ });
+ std::size_t removedDirs = 0;
+ for (const auto& path : m_externalDirs) {
+ if (fs::is_directory(path, ec) && fs::is_empty(path, ec)) {
+ fs::remove(path, ec);
+ if (!ec) {
+ ++removedDirs;
+ }
+ }
+ }
+
+ log::debug("Cleaned up {} external symlinks, {} dirs",
+ m_externalSymlinks.size(), removedDirs);
m_externalSymlinks.clear();
+ m_externalDirs.clear();
}
void FuseConnector::updateParams(MOBase::log::Levels /*logLevel*/,
@@ -1117,6 +1161,7 @@ static bool reflinkCopy(const std::string& src, const std::string& dst)
static void loadRootManifest(const std::string& storageDir,
std::vector<std::string>& deployed,
+ std::vector<std::string>& dirs,
std::map<std::string, std::string>& backups)
{
namespace fs = std::filesystem;
@@ -1127,8 +1172,6 @@ static void loadRootManifest(const std::string& storageDir,
try {
std::string const content((std::istreambuf_iterator<char>(in)),
std::istreambuf_iterator<char>());
- // Simple JSON parsing — the manifest is { "deployed": [...], "backups": {...} }
- // Use Qt's JSON for simplicity
QJsonDocument const doc = QJsonDocument::fromJson(
QByteArray::fromStdString(content));
if (doc.isNull()) return;
@@ -1137,6 +1180,10 @@ static void loadRootManifest(const std::string& storageDir,
for (const auto& v : obj["deployed"].toArray()) {
deployed.push_back(v.toString().toStdString());
}
+ // "dirs" was added later — older manifests won't have it, which is fine.
+ for (const auto& v : obj["dirs"].toArray()) {
+ dirs.push_back(v.toString().toStdString());
+ }
const auto bk = obj["backups"].toObject();
for (auto it = bk.begin(); it != bk.end(); ++it) {
backups[it.key().toStdString()] = it.value().toString().toStdString();
@@ -1146,6 +1193,7 @@ static void loadRootManifest(const std::string& storageDir,
static void saveRootManifest(const std::string& storageDir,
const std::vector<std::string>& deployed,
+ const std::vector<std::string>& dirs,
const std::map<std::string, std::string>& backups)
{
namespace fs = std::filesystem;
@@ -1157,6 +1205,11 @@ static void saveRootManifest(const std::string& storageDir,
arr.append(QString::fromStdString(f));
}
+ QJsonArray dirArr;
+ for (const auto& d : dirs) {
+ dirArr.append(QString::fromStdString(d));
+ }
+
QJsonObject bk;
for (const auto& [dst, bak] : backups) {
bk[QString::fromStdString(dst)] = QString::fromStdString(bak);
@@ -1164,6 +1217,7 @@ static void saveRootManifest(const std::string& storageDir,
QJsonObject obj;
obj["deployed"] = arr;
+ obj["dirs"] = dirArr;
obj["backups"] = bk;
const auto manifestPath = fs::path(storageDir) / "manifest.json";
@@ -1187,11 +1241,33 @@ void FuseConnector::deployRootFiles(
clearRootFiles();
m_rootDeployedFiles.clear();
+ m_rootDeployedDirs.clear();
m_rootBackups.clear();
+ const fs::path gameRoot(m_gameDir);
const std::string backupDir = (fs::path(m_rootStorageDir) / "backup").string();
std::set<std::string> deployedSet;
+ // Create dst.parent_path() one segment at a time, recording each segment
+ // we actually had to create (so it can be removed on cleanup if empty).
+ // Stops walking up once it hits an existing dir or the gameRoot itself —
+ // we never want to remove the game root or any pre-existing user dir.
+ auto trackedCreateParents = [&](const fs::path& filePath) {
+ std::error_code ec2;
+ std::vector<fs::path> toCreate;
+ for (fs::path p = filePath.parent_path();
+ !p.empty() && p != gameRoot && !fs::exists(p, ec2);
+ p = p.parent_path()) {
+ toCreate.push_back(p);
+ if (p == p.root_path()) break;
+ }
+ for (auto it = toCreate.rbegin(); it != toCreate.rend(); ++it) {
+ if (fs::create_directory(*it, ec2) && !ec2) {
+ m_rootDeployedDirs.push_back(it->string());
+ }
+ }
+ };
+
for (const auto& [modName, modPath] : mods) {
const auto rootDir = findRootDir(modPath);
if (rootDir.empty()) continue;
@@ -1218,7 +1294,7 @@ void FuseConnector::deployRootFiles(
if (fs::exists(dst, ec) || fs::is_symlink(dst, ec)) {
fs::remove(dst, ec);
}
- fs::create_directories(fs::path(dst).parent_path(), ec);
+ trackedCreateParents(fs::path(dst));
if (!reflinkCopy(entry.path().string(), dst)) {
std::fprintf(stderr, "[RootBuilder] failed to copy '%s' -> '%s'\n",
@@ -1231,7 +1307,8 @@ void FuseConnector::deployRootFiles(
}
}
- saveRootManifest(m_rootStorageDir, m_rootDeployedFiles, m_rootBackups);
+ saveRootManifest(m_rootStorageDir, m_rootDeployedFiles, m_rootDeployedDirs,
+ m_rootBackups);
const auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - t0).count();
@@ -1248,11 +1325,12 @@ void FuseConnector::clearRootFiles()
std::error_code ec;
// Load manifest if we don't have in-memory state
- if (m_rootDeployedFiles.empty()) {
- loadRootManifest(m_rootStorageDir, m_rootDeployedFiles, m_rootBackups);
+ if (m_rootDeployedFiles.empty() && m_rootDeployedDirs.empty()) {
+ loadRootManifest(m_rootStorageDir, m_rootDeployedFiles, m_rootDeployedDirs,
+ m_rootBackups);
}
- if (m_rootDeployedFiles.empty()) return;
+ if (m_rootDeployedFiles.empty() && m_rootDeployedDirs.empty()) return;
int removed = 0;
for (const auto& dst : m_rootDeployedFiles) {
@@ -1270,14 +1348,30 @@ void FuseConnector::clearRootFiles()
}
}
+ // Remove dirs we created in game root, deepest-first, only if empty.
+ // A non-empty dir means the user (or game) put something there — leave it.
+ std::sort(m_rootDeployedDirs.begin(), m_rootDeployedDirs.end(),
+ [](const std::string& a, const std::string& b) {
+ return a.size() > b.size();
+ });
+ std::size_t removedDirs = 0;
+ for (const auto& d : m_rootDeployedDirs) {
+ if (fs::is_directory(d, ec) && fs::is_empty(d, ec)) {
+ fs::remove(d, ec);
+ if (!ec) ++removedDirs;
+ }
+ }
+
// Clean up backup directory and manifest
const auto backupDir = fs::path(m_rootStorageDir) / "backup";
fs::remove_all(backupDir, ec);
fs::remove(fs::path(m_rootStorageDir) / "manifest.json", ec);
- std::fprintf(stderr, "[RootBuilder] cleared %d deployed files, restored %zu backups\n",
- removed, m_rootBackups.size());
+ std::fprintf(stderr,
+ "[RootBuilder] cleared %d deployed files, %zu dirs, restored %zu backups\n",
+ removed, removedDirs, m_rootBackups.size());
m_rootDeployedFiles.clear();
+ m_rootDeployedDirs.clear();
m_rootBackups.clear();
}
diff --git a/src/src/fuseconnector.h b/src/src/fuseconnector.h
index fcff0d8..1f6d1fa 100644
--- a/src/src/fuseconnector.h
+++ b/src/src/fuseconnector.h
@@ -90,6 +90,10 @@ private:
// Symlinks created for non-data-dir mappings (e.g. Paks, OBSE, UE4SS).
std::vector<std::string> m_externalSymlinks;
+ // Directories we created for non-data-dir mappings — removed (only if
+ // empty) on cleanup. Stored deepest-first so reverse-iteration unwinds
+ // child dirs before their parents.
+ std::vector<std::string> m_externalDirs;
// File-level mappings targeting the data directory (e.g. plugins.txt).
// Injected into the VFS tree after building. (relPath, absRealPath)
std::vector<std::pair<std::string, std::string>> m_extraVfsFiles;
@@ -107,6 +111,7 @@ private:
bool m_rootBuilderEnabled = false;
std::string m_rootStorageDir;
std::vector<std::string> m_rootDeployedFiles;
+ std::vector<std::string> m_rootDeployedDirs; // dirs we created in game root
std::map<std::string, std::string> m_rootBackups; // dst -> backup path
};