Skip to content

Commit 6c168b1

Browse files
committed
Fix duplicate mod installs
1 parent 12d0a39 commit 6c168b1

1 file changed

Lines changed: 210 additions & 8 deletions

File tree

src/gui/collections_dialog.py

Lines changed: 210 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
251429
def _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

Comments
 (0)