aboutsummaryrefslogtreecommitdiff
path: root/libs/basic_games/games/oblivion_remaster/mod_data_checker.py
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-11 02:37:39 -0600
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-11 02:37:39 -0600
commit7ee008e150bc5bcf76082d726f719ee0fdfda982 (patch)
tree27fb39be241fdb5ac2734c574de678977d1856d0 /libs/basic_games/games/oblivion_remaster/mod_data_checker.py
Fluorine Manager: full Linux port of Mod Organizer 2
Complete native Linux port with FUSE-based virtual filesystem, Proton/umu-run integration, and Flatpak packaging. Key features: - FUSE VFS replacing Windows USVFS (in-process + standalone helper for Flatpak) - Proton/GE-Proton/umu-run launcher with env var forwarding - Flatpak support (sandbox-aware VFS, NXM handler, umu-run) - Wine prefix management UI - Case-insensitive path resolution for Linux filesystems - QSettings-safe INI handling (avoids Bethesda INI corruption) - Portable instance support with auto-generated launcher scripts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'libs/basic_games/games/oblivion_remaster/mod_data_checker.py')
-rw-r--r--libs/basic_games/games/oblivion_remaster/mod_data_checker.py451
1 files changed, 451 insertions, 0 deletions
diff --git a/libs/basic_games/games/oblivion_remaster/mod_data_checker.py b/libs/basic_games/games/oblivion_remaster/mod_data_checker.py
new file mode 100644
index 0000000..51875b2
--- /dev/null
+++ b/libs/basic_games/games/oblivion_remaster/mod_data_checker.py
@@ -0,0 +1,451 @@
+from typing import cast
+
+import mobase
+
+from .constants import PLUGIN_NAME
+
+
+def _parent(entry: mobase.FileTreeEntry):
+ """
+ Same as entry.parent() but always returns a mobase.IFileTree and never None.
+ """
+ return cast(mobase.IFileTree, entry.parent())
+
+
+class OblivionRemasteredModDataChecker(mobase.ModDataChecker):
+ # These directories are generally considered valid, but may require additional checks.
+ # These represent top level directories in the mod.
+ _dirs = ["Data", "Paks", "OBSE", "Movies", "UE4SS", "GameSettings", "Root"]
+ # Directories considered valid for 'Data' though many of these may be obsolete.
+ _data_dirs = [
+ "meshes",
+ "textures",
+ "music",
+ "fonts",
+ "interface",
+ "shaders",
+ "strings",
+ "materials",
+ ]
+ # Data file extensions considered valid. Unclear if BSAs are actually used.
+ _data_extensions = [".esm", ".esp", ".bsa"]
+
+ def __init__(self, organizer: mobase.IOrganizer):
+ super().__init__()
+ self._organizer = organizer
+
+ def dataLooksValid(
+ self, filetree: mobase.IFileTree
+ ) -> mobase.ModDataChecker.CheckReturn:
+ status = mobase.ModDataChecker.INVALID
+ # These represent common mod structures that include UE4SS base files.
+ # These should generally be pruned or moved into a Root Builder path.
+ if filetree.find("ue4ss/UE4SS.dll") is not None:
+ return mobase.ModDataChecker.FIXABLE
+ elif (
+ filetree.find("OblivionRemastered/Binaries/Win64/ue4ss/UE4SS.dll")
+ is not None
+ ):
+ return mobase.ModDataChecker.FIXABLE
+
+ # Crawl the directory tree to check mod structure.
+ for entry in filetree:
+ name = entry.name().casefold()
+ parent = entry.parent()
+ assert parent is not None
+ # These are top-level file entries.
+ if parent.parent() is None:
+ if isinstance(entry, mobase.IFileTree):
+ # Look for valid top level directories.
+ if name in [dirname.lower() for dirname in self._dirs]:
+ if name == "ue4ss":
+ """
+ The UE4SS mod directory should contain either mod directories with
+ a 'scripts/main.lua' file, or 'shared' library files. Certain common
+ 'preset settings' files are also acceptable.
+ """
+ mods = entry.find("Mods")
+ if isinstance(mods, mobase.IFileTree):
+ """
+ UE4SS intrinsically maps to the 'Mods' directory, so if this directory
+ is present, it should be relocated.
+ """
+ for sub_entry in mods:
+ if isinstance(sub_entry, mobase.IFileTree):
+ if sub_entry.find("scripts/main.lua"):
+ status = mobase.ModDataChecker.FIXABLE
+ break
+ if sub_entry.name().casefold() in [
+ "shared",
+ "npcappearancemanager",
+ "naturalbodymorph",
+ ]:
+ status = mobase.ModDataChecker.FIXABLE
+ break
+ else:
+ for sub_entry in entry:
+ if isinstance(sub_entry, mobase.IFileTree):
+ # Files are present in the correct directory. Mark valid.
+ if sub_entry.find("scripts/main.lua"):
+ status = mobase.ModDataChecker.VALID
+ break
+ if sub_entry.name().casefold() in [
+ "shared",
+ "npcappearancemanager",
+ "naturalbodymorph",
+ ]:
+ status = mobase.ModDataChecker.VALID
+ break
+ else:
+ # All other base directories are considered valid
+ status = mobase.ModDataChecker.VALID
+ # No need to continue checks if the directory looks valid
+ if status == mobase.ModDataChecker.VALID:
+ break
+ elif name in [dirname.lower() for dirname in self._data_dirs]:
+ # Found a 'Data' subdirectory. Should be moved into 'Data'.
+ status = mobase.ModDataChecker.FIXABLE
+ else:
+ # Parse other directories for potential mod files.
+ for sub_entry in entry:
+ if sub_entry.isFile():
+ sub_name = sub_entry.name().casefold()
+ if sub_name.endswith(".exe"):
+ # Trying to handle EXE files is problematic, let the user figure it out
+ return mobase.ModDataChecker.INVALID
+ if sub_name.endswith((".pak", ".bk2")):
+ # Found Pak files or movie files, should be fixable
+ status = mobase.ModDataChecker.FIXABLE
+ elif sub_name.endswith(tuple(self._data_extensions)):
+ # Found plugins or BSA files, should be fixable
+ status = mobase.ModDataChecker.FIXABLE
+ else:
+ if name == "Paks":
+ # Found Paks directory as subdirectory, should be fixable
+ status = mobase.ModDataChecker.FIXABLE
+ # Iterate into subdirectories so we can check the entire archive
+ new_status = self.dataLooksValid(entry)
+ if new_status != mobase.ModDataChecker.INVALID:
+ status = new_status
+ if status == mobase.ModDataChecker.VALID:
+ break
+ else:
+ if name.endswith(".exe"):
+ return mobase.ModDataChecker.INVALID
+ if name.endswith(tuple(self._data_extensions + [".pak", ".bk2"])):
+ status = mobase.ModDataChecker.FIXABLE
+ else:
+ # Section is for parsing subdirectories
+ if isinstance(entry, mobase.IFileTree):
+ if name in [dir_name.lower() for dir_name in self._dirs]:
+ status = mobase.ModDataChecker.FIXABLE
+ if name in [dir_name.lower() for dir_name in self._data_dirs]:
+ status = mobase.ModDataChecker.FIXABLE
+ else:
+ new_status = self.dataLooksValid(entry)
+ if new_status != mobase.ModDataChecker.INVALID:
+ status = new_status
+ else:
+ if name.endswith(".exe"):
+ return mobase.ModDataChecker.INVALID
+ if name.endswith(
+ tuple(self._data_extensions + [".pak", ".lua", ".bk2"])
+ ):
+ status = mobase.ModDataChecker.FIXABLE
+ if status == mobase.ModDataChecker.VALID:
+ break
+ return status
+
+ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree:
+ """
+ Main fixer function. Iterates files, using 'parse_directory' for subdirectory search and fixing.
+ """
+
+ # UE4SS is packaged with many mods (or standalone) and should be processed into Root if found.
+ # This can avoid a lot of unnecessary iterations.
+ ue4ss_dll = filetree.find("ue4ss/UE4SS.dll")
+ if ue4ss_dll is None:
+ ue4ss_dll = filetree.find(
+ "OblivionRemastered/Binaries/Win64/ue4ss/UE4SS.dll"
+ )
+ if ue4ss_dll is not None and (ue4ss_folder := ue4ss_dll.parent()) is not None:
+ entries: list[mobase.FileTreeEntry] = []
+ for entry in _parent(ue4ss_folder):
+ entries.append(entry)
+ for entry in entries:
+ filetree.move(
+ entry,
+ "Root/OblivionRemastered/Binaries/Win64/",
+ mobase.IFileTree.MERGE,
+ )
+
+ # Similar to the above, many mods pack files relative to the root game directory. Some common paths can be
+ # automatically moved into the appropriate directory structures to avoid needless iteration.
+ exe_dir = filetree.find(r"OblivionRemastered\Binaries\Win64")
+ if isinstance(exe_dir, mobase.IFileTree):
+ gamesettings_dir = exe_dir.find("GameSettings")
+ if isinstance(gamesettings_dir, mobase.IFileTree):
+ gamesettings_main = self.get_dir(filetree, "GameSettings")
+ gamesettings_main.merge(gamesettings_dir, True)
+ self.detach_parents(gamesettings_dir)
+ obse_dir = exe_dir.find("OBSE")
+ if isinstance(obse_dir, mobase.IFileTree):
+ obse_main = self.get_dir(filetree, "OBSE")
+ obse_main.merge(obse_dir, True)
+ self.detach_parents(obse_dir)
+ ue4ss_mod_dir = exe_dir.find("ue4ss/Mods")
+ if isinstance(ue4ss_mod_dir, mobase.IFileTree):
+ if self._organizer.pluginSetting(PLUGIN_NAME, "ue4ss_use_root_builder"):
+ ue4ss_main = self.get_dir(
+ filetree, "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods"
+ )
+ else:
+ ue4ss_main = self.get_dir(filetree, "UE4SS")
+ ue4ss_main.merge(ue4ss_mod_dir, True)
+ self.detach_parents(ue4ss_mod_dir)
+ if len(exe_dir):
+ root_exe_dir = self.get_dir(
+ filetree, "Root/OblivionRemastered/Binaries"
+ )
+ parent = exe_dir.parent()
+ exe_dir.moveTo(root_exe_dir)
+ if parent:
+ self.detach_parents(parent)
+ else:
+ self.detach_parents(exe_dir)
+
+ # Start the main directory iteration code
+ directories: list[mobase.IFileTree] = []
+ for entry in filetree:
+ if isinstance(entry, mobase.IFileTree):
+ directories.append(entry)
+ for directory in directories:
+ if directory.name().casefold() in [
+ dirname.lower() for dirname in self._data_dirs
+ ]:
+ # Move detected 'Data' directories into 'Data'
+ data_dir = self.get_dir(filetree, "Data")
+ directory.moveTo(data_dir)
+ elif directory.name().casefold() == "ue4ss":
+ # Validate and correct UE4SS mod files into 'UE4SS'
+ # Alternately, can use Root Builder path per user request
+ mods = directory.find("Mods")
+ if isinstance(mods, mobase.IFileTree):
+ for sub_entry in mods:
+ if isinstance(sub_entry, mobase.IFileTree):
+ if (
+ sub_entry.find("scripts/main.lua")
+ or sub_entry.name().casefold() == "shared"
+ ):
+ if self._organizer.pluginSetting(
+ PLUGIN_NAME, "ue4ss_use_root_builder"
+ ):
+ ue4ss_main = self.get_dir(
+ filetree,
+ "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods",
+ )
+ sub_entry.moveTo(ue4ss_main)
+ self.detach_parents(directory)
+ else:
+ parent = _parent(sub_entry)
+ sub_entry.moveTo(directory)
+ self.detach_parents(parent)
+ elif directory.name().casefold() not in [
+ dirname.lower() for dirname in self._dirs
+ ]:
+ # For non-valid directories, iterate into the directory
+ filetree = self.parse_directory(filetree, directory)
+ # Parsing top-level files
+ entries: list[mobase.FileTreeEntry] = []
+ # As we are changing the filetree, cache the current directory list and iterate on that
+ for entry in filetree:
+ entries.append(entry)
+ for entry in entries:
+ if entry.parent() == filetree and entry.isFile():
+ name = entry.name().casefold()
+ if name.endswith(".pak"):
+ # Move all pak|ucas|utoc files into "Paks\~mods"
+ paks_dir = self.get_dir(filetree, "Paks/~mods")
+ pak_files: list[mobase.FileTreeEntry] = []
+ for file in _parent(entry):
+ if file.isFile():
+ if (
+ file.name()
+ .casefold()
+ .endswith((".pak", ".ucas", ".utoc"))
+ ):
+ pak_files.append(file)
+ for pak_file in pak_files:
+ pak_file.moveTo(paks_dir)
+ elif name.endswith(".bk2"):
+ # Top-level bk2 files should be moved to "Movies\Modern"
+ movies_dir = self.get_dir(filetree, "Movies/Modern")
+ movie_files: list[mobase.FileTreeEntry] = []
+ for file in _parent(entry):
+ if file.isFile():
+ if file.name().casefold().endswith(".bk2"):
+ movie_files.append(file)
+ for movie_file in movie_files:
+ movie_file.moveTo(movies_dir)
+ elif name.endswith(tuple(self._data_extensions)):
+ # Files matching Data file extensions should be moved to "Data"
+ data_dir = self.get_dir(filetree, "Data")
+ data_files: list[mobase.FileTreeEntry] = []
+ for file in _parent(entry):
+ data_files.append(file)
+ for data_file in data_files:
+ data_file.moveTo(data_dir)
+ return filetree
+
+ def parse_directory(
+ self, main_filetree: mobase.IFileTree, next_dir: mobase.IFileTree
+ ) -> mobase.IFileTree:
+ """
+ Subdirectory iterator for fix(). Allows parsing all subdirectory files.
+
+ :param main_filetree: The main mod filetree that should be returned.
+ :param next_dir: The currently processed subdirectory of main_filetree.
+ :returns: The updated main_filetree.
+ """
+ directories: list[mobase.IFileTree] = []
+ for entry in next_dir:
+ if isinstance(entry, mobase.IFileTree):
+ directories.append(entry)
+ for directory in directories:
+ name = directory.name().casefold()
+ stop = False
+ for dir_name in self._dirs:
+ if name == dir_name.lower():
+ main_dir = self.get_dir(main_filetree, dir_name)
+ if name == "ue4ss":
+ # UE4SS directories should presumably map to 'UE4SS' but check for a 'Mods' directory and move that instead.
+ if self._organizer.pluginSetting(
+ PLUGIN_NAME, "ue4ss_use_root_builder"
+ ):
+ ue4ss_dir = self.get_dir(
+ main_filetree,
+ "Root/OblivionRemastered/Binaries/Win64/ue4ss",
+ )
+ ue4ss_dir.merge(directory)
+ else:
+ mod_dir = directory.find("Mods")
+ if isinstance(mod_dir, mobase.IFileTree):
+ main_dir.merge(mod_dir)
+ else:
+ main_dir.merge(directory)
+ else:
+ main_dir.merge(directory)
+ self.detach_parents(directory)
+ stop = True
+ break
+ if stop:
+ continue
+ if name in ["~mods", "logicmods"]:
+ # These directories should represent Paks mods and should be moved into that directory.
+ paks_dir = self.get_dir(main_filetree, "Paks")
+ directory.moveTo(paks_dir)
+ continue
+ elif name in [dirname.lower() for dirname in self._data_dirs]:
+ # These directories are typically associated with Data and should be moved into that directory.
+ data_dir = self.get_dir(main_filetree, "Data")
+ data_dir.merge(directory)
+ self.detach_parents(directory)
+ continue
+ main_filetree = self.parse_directory(main_filetree, directory)
+ for entry in next_dir:
+ if entry.isFile():
+ name = entry.name().casefold()
+ if name.endswith(tuple(self._data_extensions)):
+ # Files matching Data extensions should be moved into 'Data'
+ data_dir = self.get_dir(main_filetree, "Data")
+ data_dir.merge(next_dir)
+ self.detach_parents(next_dir)
+ elif name.endswith(".pak"):
+ # Loose pak files most likely should be installed to 'Paks\~mods' but check the parent directory
+ paks_dir = self.get_dir(main_filetree, "Paks")
+ if next_dir.name().casefold() == "paks":
+ paks_dir.merge(next_dir)
+ self.detach_parents(next_dir)
+ return main_filetree
+ elif next_dir.name().casefold() in ["~mods", "logicmods"]:
+ next_dir.moveTo(paks_dir)
+ return main_filetree
+ else:
+ parent = _parent(next_dir)
+ main_filetree.move(
+ next_dir, "Paks/~mods/", mobase.IFileTree.MERGE
+ )
+
+ self.detach_parents(parent)
+ return main_filetree
+ elif name.endswith(".lua"):
+ # LUA files are generally associated with UE4SS mods. Most will probably be found before this point.
+ if next_dir.parent() and next_dir.parent() != main_filetree:
+ parent = _parent(next_dir).parent()
+ if self._organizer.pluginSetting(
+ PLUGIN_NAME, "ue4ss_use_root_builder"
+ ):
+ ue4ss_main = self.get_dir(
+ main_filetree,
+ "Root/OblivionRemastered/Binaries/Win64/ue4ss/Mods",
+ )
+ _parent(next_dir).moveTo(ue4ss_main)
+ else:
+ if main_filetree.find("UE4SS") is None:
+ main_filetree.addDirectory("UE4SS")
+ main_filetree.move(
+ _parent(next_dir),
+ "UE4SS/",
+ mobase.IFileTree.MERGE,
+ )
+ if parent is not None:
+ self.detach_parents(parent)
+ return main_filetree
+ elif name.endswith(".bk2"):
+ movies_dir = self.get_dir(main_filetree, "Movies/Modern")
+ movies_dir.merge(next_dir)
+ self.detach_parents(next_dir)
+
+ return main_filetree
+
+ def detach_parents(self, directory: mobase.IFileTree) -> None:
+ """
+ Attempts to clean up empty archive directories after files have been moved.
+ Find the top-most directory of an empty file tree where only the child directory is contained.
+ Remove that filetree.
+
+ :param directory: The directory tree to be pruned (starting with the lowest element).
+ """
+
+ if (
+ directory.parent() is not None
+ and (parent := directory.parent()) is not None
+ and len(parent) == 1
+ ):
+ parent = parent if parent.parent() is not None else directory
+ while (
+ parent
+ and (p_parent := parent.parent()) is not None
+ and (pp_parent := p_parent.parent()) is not None
+ and len(pp_parent) == 1
+ ):
+ parent = parent.parent()
+
+ assert parent is not None
+ parent.detach()
+ else:
+ directory.detach()
+
+ def get_dir(self, filetree: mobase.IFileTree, directory: str) -> mobase.IFileTree:
+ """
+ Simple helper function that finds or creates a directory within a given filetree.
+
+ :param filetree: The filetree in which to file or create the given directory
+ :param directory: The directory name to return
+ :return: The filetree of the created or found directory.
+ """
+
+ tree_dir = filetree.find(directory)
+ if not isinstance(tree_dir, mobase.IFileTree):
+ tree_dir = filetree.addDirectory(directory)
+ return tree_dir