@@ -141,21 +141,21 @@ def fixInstalledMod(self, mod: mobase.IModInterface):
141141 return
142142 self .needsNameFix = False
143143
144- def groupRelatedFiles (
145- self ,
146- entries : list [ mobase . FileTreeEntry ],
147- ) -> list [ list [ mobase . FileTreeEntry ]]:
148- """Group files that belong together (e.g., . pak, .utoc, .ucas with same base name)."""
149- grouped : dict [ str , list [ mobase . FileTreeEntry ]] = {}
150-
151- for entry in entries :
152- # Get base name without extension
153- name_without_ext = os . path . splitext ( entry . name ())[ 0 ]
154- if name_without_ext not in grouped :
155- grouped [ name_without_ext ] = []
156- grouped [ name_without_ext ]. append ( entry )
157-
158- return list ( grouped . values ())
144+ def hasLooseInstallableFiles ( self , filetree : mobase . IFileTree ) -> bool :
145+ """Return True if loose installable files exist at the mod root.
146+
147+ This lets merge installs re-run the fixer when a valid mod already has
148+ Content/Paks/~Mods, but a new . pak/ .utoc/ .ucas/.bk2 was merged at root.
149+ """
150+ for entry in filetree :
151+ if entry . isFile () and entry . suffix (). casefold () in {
152+ "pak" ,
153+ "utoc" ,
154+ "ucas" ,
155+ "bk2" ,
156+ }:
157+ return True
158+ return False
159159
160160 def dataLooksValid (
161161 self , filetree : mobase .IFileTree
@@ -168,6 +168,13 @@ def dataLooksValid(
168168 GameDataNativeMods = getattr (
169169 self .organizer .managedGame (), "GameDataNativeMods" , ""
170170 )
171+
172+ print ("dataLooksValid called" )
173+ print ("hasLooseInstallableFiles =" , self .hasLooseInstallableFiles (filetree ))
174+
175+ if self .hasLooseInstallableFiles (filetree ):
176+ return mobase .ModDataChecker .FIXABLE
177+
171178 if filetree .exists (GameDataPakMods , mobase .IFileTree .DIRECTORY ):
172179 return mobase .ModDataChecker .VALID
173180 if filetree .exists (GameDataMovies , mobase .IFileTree .DIRECTORY ):
@@ -185,17 +192,44 @@ def moveTreeContent(
185192 targettree : mobase .IFileTree ,
186193 destination : str ,
187194 ) -> None :
195+ print ("RUNNING moveTreeContent" )
188196 if installtype == "virtual" :
197+ print ("INSTALLTYPE:" , installtype )
189198 for entry in entries :
190199 targettree .move (entry , destination , mobase .IFileTree .MERGE )
191200 elif installtype == "os" :
192201 entry = entries [0 ]
202+ print ("INSTALLTYPE:" , installtype )
193203 if isinstance (entry , mobase .IFileTree ):
204+ destination_root = (
205+ destination .strip ("/\\ " )
206+ .replace ("\\ " , "/" )
207+ .split ("/" , 1 )[0 ]
208+ .casefold ()
209+ )
194210 for subentry in entry :
195211 mod_file = subentry .name ()
196212 mod_name_val = entry .name ()
197213 mod_path = os .path .join (self .organizer .modsPath (), mod_name_val )
214+
215+ # On merge installs, the existing fixed structure is also
216+ # present in this tree. Do not move it into itself; only move
217+ # the newly-added loose files/folders.
218+ if (
219+ destination_root
220+ and subentry .isDir ()
221+ and mod_file .casefold () == destination_root
222+ ):
223+ print ("SKIPPING already-fixed destination root:" , mod_file )
224+ continue
225+
198226 insideMods = os .path .join (mod_path , destination )
227+ print ("----" )
228+ print ("MOD:" , mod_name_val )
229+ print ("FILE:" , mod_file )
230+ print ("SRC:" , os .path .join (mod_path , mod_file ))
231+ print ("DST:" , os .path .join (mod_path , destination , mod_file ))
232+ print ("TREE:" , [e .name () for e in entry ])
199233 os .makedirs (insideMods , exist_ok = True )
200234 src = os .path .join (mod_path , mod_file )
201235 dst = os .path .join (mod_path , destination , mod_file )
@@ -213,13 +247,13 @@ def addModDetectionCandidate(
213247 destination : str ,
214248 installtype : str ,
215249 ) -> None :
216- tree_name = self .sanitizeFolderName (trees [ 0 ]. name () if trees else "Unknown" )
250+ candidate_name = self .sanitizeFolderName (name )
217251
218252 self .modDetectionCandidates .append (
219253 {
220254 "trees" : trees ,
221- "name" : tree_name ,
222- "display" : f"{ name } ({ category } )" ,
255+ "name" : candidate_name ,
256+ "display" : f"{ candidate_name } ({ category } )" ,
223257 "destination" : destination ,
224258 "installtype" : installtype ,
225259 }
@@ -286,6 +320,9 @@ def setDialogSelection(self, listWidget: QListWidget, select: bool) -> None:
286320 if isinstance (item , QListWidgetItem ):
287321 item .setCheckState (state )
288322
323+ def normalizedPathParts (self , path : str ) -> set [str ]:
324+ return set (path .replace ("\\ " , "/" ).casefold ().split ("/" ))
325+
289326 def collectModCandidates (
290327 self ,
291328 path : str ,
@@ -295,6 +332,7 @@ def collectModCandidates(
295332 category = None
296333 entryext = "None"
297334 basename = "Unknown"
335+
298336 GameDataUE4SSRootDir = getattr (
299337 self .organizer .managedGame (), "GameDataUE4SSRoot" , ""
300338 )
@@ -315,6 +353,14 @@ def collectModCandidates(
315353 entryext = entry .suffix ().casefold ()
316354 basename = os .path .splitext (entry .name ())[0 ]
317355
356+ path_parts = self .normalizedPathParts (entry .path ())
357+
358+ # Native Unreal mods contain their own Content tree. Once we are inside
359+ # that tree, do not create additional candidates for nested files such as
360+ # .pak/.utoc/.ucas.
361+ if "content" in path_parts :
362+ return mobase .IFileTree .WalkReturn .CONTINUE
363+
318364 if isinstance (entry , mobase .IFileTree ) and entry .isDir ():
319365 if entry .exists ("ue4ss.dll" , mobase .IFileTree .FILE ) or entry .exists (
320366 "dsound.dll" , mobase .IFileTree .FILE
@@ -323,15 +369,11 @@ def collectModCandidates(
323369 elif entry .exists (
324370 "Scripts" , mobase .IFileTree .DIRECTORY
325371 ) and not entry .exists ("ue4ss.dll" , mobase .IFileTree .FILE ):
326- disallowedFolders = {"mods" }
327- tree_path = entry .path ()
328- tree_path_lower = tree_path .replace ("\\ " , "/" ).casefold ()
329- if not disallowedFolders & set (tree_path_lower .split ("/" )):
372+ if "mods" not in path_parts :
330373 category = "UE4SS"
331374 elif entry .exists ("Content" , mobase .IFileTree .DIRECTORY ):
332375 category = "Native"
333376
334- # Check single file for correct extensions
335377 match entryext :
336378 case "pak" | "utoc" | "ucas" :
337379 category = "Paks"
@@ -348,7 +390,6 @@ def collectModCandidates(
348390
349391 self .category_groups [basename ].append (entry )
350392
351- # Add grouped entries as candidates
352393 for group_key , entries in self .category_groups .items ():
353394 if group_key not in self .processedBasenames :
354395 self .processedBasenames .add (group_key )
@@ -369,7 +410,6 @@ def collectModCandidates(
369410 destination = "/"
370411
371412 if installtype == "os" :
372- # Single file/entry
373413 self .addModDetectionCandidate (
374414 [entry ],
375415 sanitized_name ,
@@ -380,7 +420,7 @@ def collectModCandidates(
380420 else :
381421 candidate_entries = entries
382422
383- if group_category == "Root" :
423+ if group_category in { "Root" , "Native" } :
384424 candidate_entries : list [mobase .FileTreeEntry ] = []
385425 for root_entry in entries :
386426 if (
@@ -449,15 +489,33 @@ def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree | None:
449489 if not UnZippedInstallation :
450490 filetree = newtree
451491
492+ GameDataNativeMods = getattr (
493+ self .organizer .managedGame (), "GameDataNativeMods" , ""
494+ )
495+ selected_native_indexes = {
496+ index
497+ for index in selectedIndexes
498+ if self .modDetectionCandidates [index ]["destination" ].endswith (
499+ "/FOLDERNAME/"
500+ )
501+ }
502+ multiple_native = len (selected_native_indexes ) > 1
503+
452504 for index in selectedIndexes :
453505 candidate = self .modDetectionCandidates [index ]
454- if "/FOLDERNAME/" in candidate ["destination" ]:
455- self .needsNameFix = True
506+ destination = candidate ["destination" ]
507+
508+ if index in selected_native_indexes :
509+ if multiple_native :
510+ destination = GameDataNativeMods + f"/{ candidate ['name' ]} /"
511+ else :
512+ self .needsNameFix = True
513+
456514 self .moveTreeContent (
457515 candidate ["installtype" ],
458516 candidate ["trees" ],
459517 filetree ,
460- candidate [ " destination" ] ,
518+ destination ,
461519 )
462520
463521 return filetree
0 commit comments