From aba2dc0df5c50c244904f2b1bb458334a851d426 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Thu, 30 Apr 2026 18:32:24 -0500 Subject: 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) --- src/src/fuseconnector.cpp | 122 ++++++++++++++++++++++++++++++++++++++++------ src/src/fuseconnector.h | 5 ++ 2 files changed, 113 insertions(+), 14 deletions(-) (limited to 'src') 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 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& deployed, + std::vector& dirs, std::map& backups) { namespace fs = std::filesystem; @@ -1127,8 +1172,6 @@ static void loadRootManifest(const std::string& storageDir, try { std::string const content((std::istreambuf_iterator(in)), std::istreambuf_iterator()); - // 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& deployed, + const std::vector& dirs, const std::map& 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 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 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::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 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 m_externalDirs; // File-level mappings targeting the data directory (e.g. plugins.txt). // Injected into the VFS tree after building. (relPath, absRealPath) std::vector> m_extraVfsFiles; @@ -107,6 +111,7 @@ private: bool m_rootBuilderEnabled = false; std::string m_rootStorageDir; std::vector m_rootDeployedFiles; + std::vector m_rootDeployedDirs; // dirs we created in game root std::map m_rootBackups; // dst -> backup path }; -- cgit v1.3.1