@@ -248,6 +248,184 @@ def _resolve(name: str) -> int | None:
248248 return {fid : pos for pos , fid in enumerate (sorted_fids )}
249249
250250
251+ def _resolve_collection_priorities (collection_schema : dict ) -> dict [int , int ]:
252+ """Return file_id → priority position (0 = highest priority, top of modlist.txt).
253+
254+ Prefers the manifest's `loadOrder` array when present (FBLO games like
255+ BG3 ship the curator's exact ordered list — `loadOrder[0]` is the first
256+ mod to load, which is bottom of modlist.txt / position N-1 in this
257+ manager). When `loadOrder` is present, `modRules` before/after constraints
258+ are layered on top as a stable post-pass: rules already satisfied by the
259+ LO are no-ops; only conflicting rules cause reordering, with the LO order
260+ used as the tie-breaker. Falls back to pure topo-sorting `mods` +
261+ `modRules` for collections that don't ship a load order block.
262+ """
263+ schema_mods = collection_schema .get ("mods" , [])
264+ mod_rules = collection_schema .get ("modRules" , [])
265+
266+ lo = collection_schema .get ("loadOrder" )
267+ if not (isinstance (lo , list ) and lo and any (e .get ("fileId" ) for e in lo )):
268+ return _topo_sort_collection (schema_mods , mod_rules )
269+
270+ # Reverse: loadOrder[0] = first to load = lowest priority = bottom of modlist
271+ ordered_fids : list [int ] = []
272+ seen : set [int ] = set ()
273+ for entry in reversed (lo ):
274+ fid = entry .get ("fileId" )
275+ if fid is None :
276+ continue
277+ fid = int (fid )
278+ if fid in seen :
279+ continue
280+ seen .add (fid )
281+ ordered_fids .append (fid )
282+
283+ # Append any mods-array entries missing from loadOrder (FOMOD-only mods
284+ # with no .pak) at the bottom, preserving mods-array order.
285+ for m in schema_mods :
286+ fid = (m .get ("source" ) or {}).get ("fileId" )
287+ if fid is None :
288+ continue
289+ fid = int (fid )
290+ if fid not in seen :
291+ seen .add (fid )
292+ ordered_fids .append (fid )
293+
294+ # Build name → fileId resolver for rule references (rules use logicalFileName)
295+ logical_to_fid : dict [str , int ] = {}
296+ for m in schema_mods :
297+ src = m .get ("source" ) or {}
298+ fid = src .get ("fileId" )
299+ if fid is None :
300+ continue
301+ fid = int (fid )
302+ for n in (
303+ (src .get ("logicalFilename" ) or "" ).strip (),
304+ (m .get ("name" ) or "" ).strip (),
305+ ):
306+ if n and n not in logical_to_fid :
307+ logical_to_fid [n ] = fid
308+
309+ # Layer modRules on top via Kahn's topological sort, using the LO order
310+ # as the tie-breaker. Rules already satisfied by LO are free; conflicting
311+ # rules cause minimal swaps. Cycles are skipped (rules ignored).
312+ base_pos : dict [int , int ] = {fid : pos for pos , fid in enumerate (ordered_fids )}
313+ higher_than : dict [int , set [int ]] = {f : set () for f in ordered_fids }
314+ in_degree : dict [int , int ] = {f : 0 for f in ordered_fids }
315+
316+ for rule in mod_rules :
317+ rtype = rule .get ("type" )
318+ if rtype not in ("before" , "after" ):
319+ continue
320+ ref_name = (rule .get ("reference" ) or {}).get ("logicalFileName" , "" )
321+ src_name = (rule .get ("source" ) or {}).get ("logicalFileName" , "" )
322+ ref_fid = logical_to_fid .get (ref_name )
323+ src_fid = logical_to_fid .get (src_name )
324+ if ref_fid is None or src_fid is None or ref_fid == src_fid :
325+ continue
326+ if ref_fid not in base_pos or src_fid not in base_pos :
327+ continue
328+
329+ # "source after reference" → source loads later → source is higher priority
330+ # "source before reference" → source loads earlier → reference is higher priority
331+ if rtype == "after" :
332+ winner , loser = src_fid , ref_fid
333+ else :
334+ winner , loser = ref_fid , src_fid
335+
336+ if loser not in higher_than [winner ]:
337+ higher_than [winner ].add (loser )
338+ in_degree [loser ] += 1
339+
340+ # Stable topo sort using a min-heap keyed by base LO position. Whenever
341+ # multiple nodes are eligible, the one closest to its base-LO slot wins —
342+ # so rules already satisfied by the LO are no-ops, and only conflicting
343+ # rules cause the minimum reordering needed to satisfy them.
344+ import heapq
345+ heap : list [tuple [int , int ]] = [
346+ (base_pos [f ], f ) for f in ordered_fids if in_degree [f ] == 0
347+ ]
348+ heapq .heapify (heap )
349+ sorted_fids : list [int ] = []
350+ remaining = set (ordered_fids )
351+
352+ while heap :
353+ _ , fid = heapq .heappop (heap )
354+ if fid not in remaining :
355+ continue
356+ remaining .discard (fid )
357+ sorted_fids .append (fid )
358+ for dep in higher_than [fid ]:
359+ in_degree [dep ] -= 1
360+ if in_degree [dep ] == 0 :
361+ heapq .heappush (heap , (base_pos [dep ], dep ))
362+
363+ # Cycle members: append in base order
364+ for fid in ordered_fids :
365+ if fid in remaining :
366+ sorted_fids .append (fid )
367+
368+ return {fid : pos for pos , fid in enumerate (sorted_fids )}
369+
370+
371+ def _build_collision_suffix_map (
372+ schema_mods : list [dict ],
373+ schema_file_id_to_logical : dict [int , str ],
374+ schema_pos_to_name : dict [int , str ],
375+ schema_file_id_to_pos : dict [int , int ],
376+ ) -> dict [int , str ]:
377+ """Return file_id → suffix to append when multiple collection entries
378+ from different mod pages would otherwise install into the same folder.
379+
380+ Collisions happen when curators include several small "patch"-style mods
381+ that share a generic name (e.g. four "EOTB Patch" entries from different
382+ authors). Without disambiguation each install overwrites the previous —
383+ only the last one survives. Appending " (mod_id)" keeps each in its own
384+ folder while remaining recognisable to the user.
385+
386+ Returns "" for non-colliding entries; the suffix string for colliders.
387+ """
388+ # Group fileIds by their effective base preferred name (lowercased).
389+ base_to_fids : dict [str , list [int ]] = {}
390+ fid_to_base : dict [int , str ] = {}
391+ fid_to_mod_id : dict [int , int ] = {}
392+ for sm in schema_mods :
393+ src = sm .get ("source" ) or {}
394+ fid = src .get ("fileId" )
395+ if fid is None :
396+ continue
397+ fid = int (fid )
398+ logical = schema_file_id_to_logical .get (fid , "" ) or ""
399+ schema_name = schema_pos_to_name .get (
400+ schema_file_id_to_pos .get (fid , - 1 ), "" ) or ""
401+ base = (logical or schema_name or sm .get ("name" ) or "" ).strip ()
402+ if not base :
403+ continue
404+ fid_to_base [fid ] = base
405+ mid = src .get ("modId" )
406+ if mid :
407+ fid_to_mod_id [fid ] = int (mid )
408+ base_to_fids .setdefault (base .lower (), []).append (fid )
409+
410+ result : dict [int , str ] = {}
411+ for fid , base in fid_to_base .items ():
412+ siblings = base_to_fids .get (base .lower (), [])
413+ if len (siblings ) <= 1 :
414+ result [fid ] = ""
415+ continue
416+ # Only disambiguate when siblings come from different mod pages.
417+ # Two entries from the same mod_id (e.g. main+optional file) shouldn't
418+ # collide because they go through the already-installed-by-fid path.
419+ sibling_mod_ids = {fid_to_mod_id .get (s ) for s in siblings }
420+ sibling_mod_ids .discard (None )
421+ if len (sibling_mod_ids ) <= 1 :
422+ result [fid ] = ""
423+ continue
424+ mod_id = fid_to_mod_id .get (fid )
425+ result [fid ] = f" ({ mod_id } )" if mod_id else ""
426+ return result
427+
428+
251429def _fmt_size (n_bytes : int ) -> str :
252430 """Human-readable file size."""
253431 if n_bytes <= 0 :
@@ -2290,11 +2468,12 @@ def _show_and_set(v=value):
22902468 except Exception as _exc :
22912469 self ._log (f"Collection install: could not save manifest: { _exc } " )
22922470
2293- # Build a mapping from file_id → priority position (0 = highest priority)
2294- # respecting modRules before/after constraints via topological sort.
2471+ # Build a mapping from file_id → priority position (0 = highest priority).
2472+ # Prefers the manifest's `loadOrder` array when present (FBLO games like
2473+ # BG3); falls back to topo-sorting `mods` + `modRules`.
22952474 schema_mods : list [dict ] = collection_schema .get ("mods" , [])
22962475 mod_rules : list [dict ] = collection_schema .get ("modRules" , [])
2297- schema_file_id_to_pos : dict [int , int ] = _topo_sort_collection ( schema_mods , mod_rules )
2476+ schema_file_id_to_pos : dict [int , int ] = _resolve_collection_priorities ( collection_schema )
22982477 schema_pos_to_name : dict [int , str ] = {} # collection.json logical name
22992478 schema_file_id_to_logical : dict [int , str ] = {} # file_id → logicalFilename
23002479 schema_file_id_to_mod_id : dict [int , int ] = {} # file_id → mod_id from collection.json
@@ -2357,6 +2536,17 @@ def _sort_key(m):
23572536
23582537 ordered_mods = sorted (mods , key = _sort_key )
23592538
2539+ # Disambiguate collection entries that would install into the same
2540+ # folder name (e.g. four "EOTB Patch" entries from different authors).
2541+ # Without this, each install overwrites the previous and only the
2542+ # last survives in the staging dir.
2543+ schema_file_id_to_suffix : dict [int , str ] = _build_collision_suffix_map (
2544+ schema_mods ,
2545+ schema_file_id_to_logical ,
2546+ schema_pos_to_name ,
2547+ schema_file_id_to_pos ,
2548+ )
2549+
23602550 # ------------------------------------------------------------------
23612551 # Step 2: Install each mod, tracking the folder names in order
23622552 # ------------------------------------------------------------------
@@ -2827,6 +3017,8 @@ def _install_one(mod, result, effective_domain):
28273017 _schema_name = schema_pos_to_name .get (
28283018 schema_file_id_to_pos .get (mod .file_id , - 1 ), "" ) or ""
28293019 _preferred = _logical or _schema_name or mod .mod_name or ""
3020+ # Append a per-mod-id suffix when multiple entries collide.
3021+ _preferred += schema_file_id_to_suffix .get (mod .file_id , "" )
28303022
28313023 # Estimate uncompressed size and acquire memory budget before extracting.
28323024 # This gates concurrency by real memory pressure rather than a fixed limit.
@@ -3110,6 +3302,7 @@ def _write_preliminary_plugins_txt(label: str) -> None:
31103302 _def_schema_name = schema_pos_to_name .get (
31113303 schema_file_id_to_pos .get (_def_mod .file_id , - 1 ), "" ) or ""
31123304 _def_preferred = _def_logical or _def_schema_name or _def_mod .mod_name or ""
3305+ _def_preferred += schema_file_id_to_suffix .get (_def_mod .file_id , "" )
31133306 try :
31143307 _def_folder = install_mod_from_archive (
31153308 _def_archive , self , self ._log , self ._game ,
@@ -4259,7 +4452,7 @@ def _set_status(msg: str):
42594452
42604453 schema_mods : list [dict ] = collection_schema .get ("mods" , [])
42614454 mod_rules : list [dict ] = collection_schema .get ("modRules" , [])
4262- schema_file_id_to_pos = _topo_sort_collection ( schema_mods , mod_rules )
4455+ schema_file_id_to_pos = _resolve_collection_priorities ( collection_schema )
42634456 schema_pos_to_name : dict [int , str ] = {}
42644457 schema_file_id_to_logical : dict [int , str ] = {}
42654458 schema_file_id_to_mod_id : dict [int , int ] = {}
@@ -4311,6 +4504,15 @@ def _sort_key(m):
43114504
43124505 ordered_mods = sorted (mods , key = _sort_key )
43134506
4507+ # Disambiguate collection entries that would install into the same
4508+ # folder name (see _build_collision_suffix_map docstring).
4509+ schema_file_id_to_suffix : dict [int , str ] = _build_collision_suffix_map (
4510+ schema_mods ,
4511+ schema_file_id_to_logical ,
4512+ schema_pos_to_name ,
4513+ schema_file_id_to_pos ,
4514+ )
4515+
43144516 # ------------------------------------------------------------------
43154517 # Step 2: Classify already-installed mods (same as _run_install)
43164518 # ------------------------------------------------------------------
@@ -4536,6 +4738,7 @@ def _do_update(_m=mod, _i=idx, _t=dl_total, _inst=installed, _up=to_download[idx
45364738 _schema_name = schema_pos_to_name .get (
45374739 schema_file_id_to_pos .get (mod .file_id , - 1 ), "" ) or ""
45384740 _preferred = _logical or _schema_name or mod .mod_name or ""
4741+ _preferred += schema_file_id_to_suffix .get (mod .file_id , "" )
45394742
45404743 _manual_fomod_flag = {"value" : False }
45414744 def _manual_capture_fomod (is_fomod : bool = False ):
@@ -5531,10 +5734,9 @@ def _run_reset_load_order(self, profile_dir: Path):
55315734 except Exception as _exc :
55325735 self ._log (f"Could not save manifest: { _exc } " )
55335736
5534- # Build file_id → priority position map respecting modRules
5535- fid_to_pos : dict = _topo_sort_collection (
5536- cj .get ("mods" , []), cj .get ("modRules" , [])
5537- )
5737+ # Build file_id → priority position map. Prefers the manifest's
5738+ # `loadOrder` array (FBLO games like BG3); falls back to modRules.
5739+ fid_to_pos : dict = _resolve_collection_priorities (cj )
55385740
55395741 # Build name-based fallback: logical_name → file_id (for mods missing meta.ini)
55405742 _name_to_fid : dict [str , int ] = {}
0 commit comments