diff options
| -rwxr-xr-x | docker/build-inner.sh | 10 | ||||
| -rw-r--r-- | libs/basic_games/games/baldursgate3/bg3_file_mapper.py | 7 | ||||
| -rw-r--r-- | src/src/fuseconnector.cpp | 122 | ||||
| -rw-r--r-- | src/src/fuseconnector.h | 5 |
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 }; |
