Skip to content

Commit 6b02207

Browse files
committed
modsettings writer uses collection order if present
1 parent 6c168b1 commit 6b02207

2 files changed

Lines changed: 114 additions & 3 deletions

File tree

src/Games/Baldur's Gate 3/baldurs_gate_3.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,10 +356,26 @@ def deploy(self, log_fn=None, mode: LinkMode = LinkMode.HARDLINK,
356356
_log("Step 4: Generating modsettings.lsx ...")
357357
modsettings = larian_root / _MODSETTINGS_REL
358358
game_data = self._game_path / "Data" if self._game_path else None
359+
# If this profile was created from a collection, the manifest's
360+
# loadOrder array drives the pak ordering. Curators interleave paks
361+
# from different mods (e.g. load-order divider packs whose 30+ entries
362+
# span the full LO), which the default folder-walk order destroys.
363+
manifest_lo = None
364+
collection_json = profile_dir / "collection.json"
365+
if collection_json.is_file():
366+
try:
367+
cj = json.loads(collection_json.read_text(encoding="utf-8"))
368+
lo = cj.get("loadOrder")
369+
if isinstance(lo, list) and lo:
370+
manifest_lo = lo
371+
_log(f" Using collection manifest load order ({len(lo)} entries).")
372+
except (OSError, json.JSONDecodeError) as exc:
373+
_log(f" Warning: could not read collection.json: {exc}")
359374
mod_count = write_modsettings(modsettings, modlist, staging,
360375
log_fn=_log,
361376
game_data_path=game_data,
362-
patch_version=self._patch_version)
377+
patch_version=self._patch_version,
378+
manifest_load_order=manifest_lo)
363379

364380
_log(
365381
f"Deploy complete. "

src/Utils/modsettings.py

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -507,13 +507,98 @@ def _build_modsettings_xml_p6(ordered_mods: list[BG3ModInfo]) -> str:
507507
return "".join(parts)
508508

509509

510+
def _apply_manifest_pak_order(
511+
enabled: list[ModEntry],
512+
mod_infos: dict[str, BG3ModInfo],
513+
manifest_load_order: list[dict],
514+
log_fn,
515+
) -> list[BG3ModInfo]:
516+
"""Order paks by the collection manifest's loadOrder array.
517+
518+
A single mod folder can ship multiple paks that the collection author
519+
intends to be interleaved with paks from other mods (e.g. load-order
520+
divider packs whose 30+ entries are spread throughout the LO). Walking
521+
by mod folder and emitting all paks per folder destroys this intent.
522+
523+
Strategy: build pak-filename → BG3ModInfo from on-disk scan, then walk
524+
``manifest_load_order`` in order. Manifest entries point at pak files via
525+
their ``id`` field. Paks present on disk but missing from the manifest
526+
(user-added patches, mods installed outside the collection) are appended
527+
at the end — that maps to "top of modlist.txt" = highest priority for
528+
overrides, which matches how this manager treats user-added content.
529+
530+
Returns BG3ModInfo entries in lowest-priority-first order (ready for
531+
modsettings.lsx, where later entries override earlier ones).
532+
"""
533+
_log = _safe_log(log_fn)
534+
535+
# mod_infos is keyed by uuid. Build a casefold lookup once.
536+
uuid_to_info_cf: dict[str, BG3ModInfo] = {
537+
u.lower(): info for u, info in mod_infos.items()
538+
}
539+
540+
# Walk manifest in order, matching by data.uuid. Only emit if the uuid
541+
# corresponds to an actually-installed pak.
542+
ordered: list[BG3ModInfo] = []
543+
seen_uuids: set[str] = set()
544+
for manifest_entry in manifest_load_order:
545+
data = manifest_entry.get("data") or {}
546+
uuid = (data.get("uuid") or "").strip().lower()
547+
if not uuid:
548+
continue
549+
info = uuid_to_info_cf.get(uuid)
550+
if info is None:
551+
continue
552+
if info.uuid in seen_uuids:
553+
continue
554+
seen_uuids.add(info.uuid)
555+
ordered.append(info)
556+
557+
# Append any installed paks not covered by the manifest.
558+
# Walk in modlist order (already lowest-priority-first) so user-added
559+
# mods land in the same relative order they appear in modlist.txt.
560+
by_source: dict[str, list[BG3ModInfo]] = {}
561+
for info in mod_infos.values():
562+
if info.source_mod:
563+
by_source.setdefault(info.source_mod, []).append(info)
564+
for entry in enabled:
565+
for info in by_source.get(entry.name, ()):
566+
if info.uuid in seen_uuids:
567+
continue
568+
seen_uuids.add(info.uuid)
569+
ordered.append(info)
570+
571+
# Dependency sweep: the manifest already orders deps before dependents
572+
# (curators verify their collections boot), but user-added trailing
573+
# paks may reference deps that were emitted later by the manifest.
574+
# Walk the result and pull each pak's missing deps to immediately
575+
# before the pak itself, preserving the manifest's pak-level interleave.
576+
final: list[BG3ModInfo] = []
577+
placed: set[str] = set()
578+
579+
def _emit_with_deps(info: BG3ModInfo) -> None:
580+
if info.uuid in placed:
581+
return
582+
for dep_uuid in info.dependencies:
583+
dep = mod_infos.get(dep_uuid) or uuid_to_info_cf.get(dep_uuid.lower())
584+
if dep is not None and dep.uuid not in placed:
585+
_emit_with_deps(dep)
586+
placed.add(info.uuid)
587+
final.append(info)
588+
589+
for info in ordered:
590+
_emit_with_deps(info)
591+
return final
592+
593+
510594
def write_modsettings(
511595
modsettings_path: Path,
512596
modlist_path: Path,
513597
staging_root: Path,
514598
log_fn=None,
515599
game_data_path: Path | None = None,
516600
patch_version: int = 8,
601+
manifest_load_order: list[dict] | None = None,
517602
) -> int:
518603
"""End-to-end: scan paks, resolve order, write modsettings.lsx.
519604
@@ -527,6 +612,12 @@ def write_modsettings(
527612
- 7: Gustav campaign, Mods node only, Version64 + PublishHandle
528613
- 6: Gustav campaign, ModOrder + Mods nodes, 32-bit Version
529614
615+
*manifest_load_order* — optional list of entries from a collection
616+
manifest's ``loadOrder`` array. When provided, paks are emitted in the
617+
manifest's exact order (curators interleave paks from different mods —
618+
e.g. load-order divider packs — which the default folder-walk order
619+
destroys). Paks installed but not in the manifest are appended.
620+
530621
Returns the number of mod entries written (excluding the campaign entry).
531622
"""
532623
_log = _safe_log(log_fn)
@@ -548,8 +639,12 @@ def write_modsettings(
548639
modsettings_path.write_text(xml, encoding="utf-8")
549640
return 0
550641

551-
_log("Resolving load order with dependency sorting ...")
552-
ordered = resolve_load_order(enabled, mod_infos)
642+
if manifest_load_order:
643+
_log(f"Resolving load order from collection manifest ({len(manifest_load_order)} entries) ...")
644+
ordered = _apply_manifest_pak_order(enabled, mod_infos, manifest_load_order, _log)
645+
else:
646+
_log("Resolving load order with dependency sorting ...")
647+
ordered = resolve_load_order(enabled, mod_infos)
553648
_log(f" Load order: {', '.join(m.name for m in ordered)}")
554649

555650
# Build the set of UUIDs that are known to exist (installed mods +

0 commit comments

Comments
 (0)