Skip to content

Commit 223f4b2

Browse files
committed
Updated and Reworked UE4SS Game Install including multi-mod install flow. Additional supported Games.
1 parent d236f48 commit 223f4b2

18 files changed

Lines changed: 6452 additions & 434 deletions

games/game_batmanlegacy.py

Lines changed: 463 additions & 0 deletions
Large diffs are not rendered by default.

games/game_crimeboss.py

Lines changed: 233 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,20 @@
44
from enum import IntEnum, auto
55
from functools import cached_property
66
from pathlib import Path
7+
from typing import TypedDict
78

8-
from PyQt6.QtCore import QDir, QFileInfo
9-
from PyQt6.QtWidgets import QMainWindow, QTabWidget, QWidget
9+
from PyQt6.QtCore import QDir, QFileInfo, Qt
10+
from PyQt6.QtWidgets import (
11+
QDialog,
12+
QDialogButtonBox,
13+
QLabel,
14+
QListWidget,
15+
QListWidgetItem,
16+
QMainWindow,
17+
QTabWidget,
18+
QVBoxLayout,
19+
QWidget,
20+
)
1021

1122
import mobase
1223

@@ -67,12 +78,21 @@ def getContentsFor(self, filetree: mobase.IFileTree) -> list[int]:
6778
return list(self.contents)
6879

6980

81+
class ModDetectionCandidate(TypedDict):
82+
tree: mobase.IFileTree | mobase.FileTreeEntry
83+
name: str
84+
display: str
85+
destination: str
86+
installtype: str
87+
88+
7089
class CrimeBossModDataChecker(mobase.ModDataChecker):
7190
def __init__(self, organizer: mobase.IOrganizer):
7291
super().__init__()
7392
self.organizer: mobase.IOrganizer = organizer
7493
self.organizer.modList().onModInstalled(self.fixInstalledMod)
7594
self.needsNameFix = False
95+
self.modDetectionCandidates: list[ModDetectionCandidate] = []
7696

7797
def moveOverwriteMerge(self, source: str, destination: str):
7898
if not os.path.exists(destination):
@@ -87,6 +107,16 @@ def moveOverwriteMerge(self, source: str, destination: str):
87107
self.moveOverwriteMerge(s_item, d_item)
88108
os.rmdir(source)
89109

110+
def sanitizeFolderName(self, name: str) -> str:
111+
invalid_chars = '+&<>:"|?*\\/'
112+
for char in invalid_chars:
113+
name = name.replace(char, "")
114+
name = "".join(c for c in name if ord(c) >= 32)
115+
name = name.rstrip(". ")
116+
if not name:
117+
name = "FOLDERNAME"
118+
return name
119+
90120
def fixInstalledMod(self, mod: mobase.IModInterface):
91121
if not self.needsNameFix:
92122
return
@@ -133,6 +163,175 @@ def dataLooksValid(
133163
return mobase.ModDataChecker.VALID
134164
return mobase.ModDataChecker.FIXABLE
135165

166+
def moveTreeContent(
167+
self,
168+
installtype: str,
169+
entry: mobase.IFileTree | mobase.FileTreeEntry,
170+
targettree: mobase.IFileTree,
171+
destination: str,
172+
) -> None:
173+
if installtype == "virtual":
174+
targettree.move(entry, destination, mobase.IFileTree.MERGE)
175+
return None
176+
if installtype == "os" and isinstance(entry, mobase.IFileTree):
177+
for element in entry:
178+
mod_file = element.name()
179+
mod_name = entry.name()
180+
mod_path = os.path.join(self.organizer.modsPath(), mod_name)
181+
inside_mods = os.path.join(mod_path, destination)
182+
os.makedirs(inside_mods, exist_ok=True)
183+
src = os.path.join(mod_path, mod_file)
184+
dst = os.path.join(mod_path, destination, mod_file)
185+
shutil.move(src, dst)
186+
return None
187+
188+
def addModDetectionCandidate(
189+
self,
190+
tree: mobase.IFileTree | mobase.FileTreeEntry,
191+
name: str,
192+
category: str,
193+
destination: str,
194+
installtype: str = "virtual",
195+
) -> None:
196+
self.modDetectionCandidates.append(
197+
{
198+
"tree": tree,
199+
"name": tree.name(),
200+
"display": f"{name} ({category})",
201+
"destination": destination,
202+
"installtype": installtype,
203+
}
204+
)
205+
206+
def showModDetectionDialog(self) -> set[int] | None:
207+
if not self.modDetectionCandidates:
208+
return set()
209+
210+
dialog = QDialog()
211+
dialog.setWindowTitle("Found Mods")
212+
213+
layout = QVBoxLayout(dialog)
214+
layout.addWidget(QLabel("Select the mods to install:"))
215+
216+
listWidget = QListWidget()
217+
listWidget.setSelectionMode(QListWidget.SelectionMode.NoSelection)
218+
for candidate in self.modDetectionCandidates:
219+
item = QListWidgetItem(candidate["display"])
220+
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
221+
item.setCheckState(Qt.CheckState.Checked)
222+
listWidget.addItem(item)
223+
224+
layout.addWidget(listWidget)
225+
226+
buttonBox = QDialogButtonBox(
227+
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
228+
)
229+
buttonBox.accepted.connect(lambda: dialog.accept()) # type: ignore
230+
buttonBox.rejected.connect(lambda: dialog.reject()) # type: ignore
231+
layout.addWidget(buttonBox)
232+
233+
if dialog.exec() != QDialog.DialogCode.Accepted:
234+
return None
235+
236+
selectedIndexes: set[int] = set()
237+
for index in range(listWidget.count()):
238+
item = listWidget.item(index)
239+
if (
240+
isinstance(item, QListWidgetItem)
241+
and item.checkState() == Qt.CheckState.Checked
242+
):
243+
selectedIndexes.add(index)
244+
245+
return selectedIndexes
246+
247+
def collectModCandidates(
248+
self,
249+
tree: mobase.IFileTree | mobase.FileTreeEntry,
250+
installtype: str = "virtual",
251+
) -> bool:
252+
if not isinstance(tree, mobase.IFileTree) or not tree.name():
253+
return False
254+
255+
sanitized_name = self.sanitizeFolderName(tree.name())
256+
category = None
257+
entryext = "None"
258+
259+
if installtype == "os" and isinstance(tree, mobase.IFileTree):
260+
for entry in tree:
261+
entryext = os.path.splitext(entry.name())[1].removeprefix(".")
262+
else:
263+
entryext = tree.suffix().casefold()
264+
265+
if (
266+
tree.exists("UE4SS.dll", mobase.IFileTree.FILE)
267+
or tree.exists("Scripts", mobase.IFileTree.DIRECTORY)
268+
or tree.exists("dlls", mobase.IFileTree.DIRECTORY)
269+
or tree.exists("Binaries", mobase.IFileTree.DIRECTORY)
270+
):
271+
category = "UE4SS"
272+
elif tree.exists("Content/Paks", mobase.IFileTree.DIRECTORY):
273+
category = "Paks"
274+
elif tree.exists("Content/Movies", mobase.IFileTree.DIRECTORY):
275+
category = "Movie"
276+
elif tree.exists("Content", mobase.IFileTree.DIRECTORY):
277+
category = "Native"
278+
else:
279+
for entry in tree:
280+
if entry.isFile():
281+
suffix = entry.suffix().casefold()
282+
if suffix in {"pak", "utoc", "ucas"}:
283+
category = "Paks"
284+
break
285+
if suffix == "bk2":
286+
category = "Movie"
287+
break
288+
if suffix in {"dll", "lua"}:
289+
category = "UE4SS"
290+
break
291+
292+
if category is None:
293+
match entryext:
294+
case "pak" | "utoc" | "ucas":
295+
category = "Paks"
296+
case "bk2":
297+
category = "Movie"
298+
case _:
299+
pass
300+
301+
if category is None:
302+
return False
303+
304+
if category == "UE4SS":
305+
destination = (
306+
getattr(self.organizer.managedGame(), "GameDataUE4SSMods", "") + "/"
307+
)
308+
elif category == "Paks":
309+
destination = (
310+
getattr(self.organizer.managedGame(), "GameDataPakMods", "") + "/"
311+
)
312+
elif category == "Movie":
313+
destination = (
314+
getattr(self.organizer.managedGame(), "GameDataMovies", "") + "/"
315+
)
316+
else:
317+
destination = (
318+
getattr(self.organizer.managedGame(), "GameDataNativeMods", "") + "/"
319+
)
320+
321+
self.addModDetectionCandidate(
322+
tree,
323+
sanitized_name,
324+
category,
325+
destination,
326+
installtype,
327+
)
328+
return True
329+
330+
def walkEntry(self, path: str, entry: mobase.FileTreeEntry):
331+
if entry.isDir() and isinstance(entry, mobase.IFileTree):
332+
self.collectModCandidates(entry)
333+
return mobase.IFileTree.WalkReturn.CONTINUE
334+
136335
def fileExistsInNextSubDir(self, filetree: mobase.IFileTree, name: str):
137336
for branch in filetree:
138337
if isinstance(branch, mobase.IFileTree):
@@ -152,6 +351,34 @@ def allMoveTo(self, filetree: mobase.IFileTree, toMoveTo: str):
152351
return retVal
153352

154353
def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree | None:
354+
self.modDetectionCandidates = []
355+
newtree = filetree.createOrphanTree("Fixed Tree")
356+
357+
if filetree.name() != "":
358+
self.collectModCandidates(filetree, installtype="os")
359+
else:
360+
self.collectModCandidates(filetree)
361+
filetree.walk(self.walkEntry, "/")
362+
363+
if len(self.modDetectionCandidates) == 1:
364+
selectedIndexes = {0}
365+
else:
366+
selectedIndexes = self.showModDetectionDialog()
367+
if selectedIndexes is None:
368+
return None
369+
370+
for index in selectedIndexes:
371+
candidate = self.modDetectionCandidates[index]
372+
self.moveTreeContent(
373+
candidate["installtype"],
374+
candidate["tree"],
375+
newtree,
376+
candidate["destination"],
377+
)
378+
379+
if len(newtree) > 0:
380+
return newtree
381+
155382
GameDataUE4SSMods = (
156383
getattr(self.organizer.managedGame(), "GameDataUE4SSMods", "") + "/"
157384
)
@@ -249,7 +476,7 @@ class CrimeBossGame(BasicGame):
249476
GameSteamId = 2933080
250477
GameBinary = "CrimeBoss/Binaries/Win64/CrimeBoss-Win64-Shipping.exe"
251478
GameDataPath = "CrimeBoss"
252-
GameDataUE4SSMods = "Binaries/Win64/Mods"
479+
GameDataUE4SSRoot = "Binaries/Win64"
253480
GameDataNativeMods = "Mods"
254481
GameDataPakMods = "Content/Paks/~Mods"
255482
GameDocumentsDirectory = (
@@ -327,7 +554,9 @@ def paksDirectory(self) -> QDir:
327554
return QDir(self.dataDirectory().absolutePath() + "/" + self.GameDataPakMods)
328555

329556
def ue4ssDirectory(self) -> QDir:
330-
return QDir(self.dataDirectory().absolutePath() + "/" + self.GameDataUE4SSMods)
557+
return QDir(
558+
self.dataDirectory().absolutePath() + "/" + self.GameDataUE4SSRoot + "/Mods"
559+
)
331560

332561
def nativeDirectory(self) -> QDir:
333562
return QDir(self.dataDirectory().absolutePath() + "/" + self.GameDataNativeMods)

0 commit comments

Comments
 (0)