aboutsummaryrefslogtreecommitdiff
path: root/libs/basic_games/games/openmw_support/openmw_cfg.py
blob: d6d0d01a59b2a4b7ddad8f1c06db8022993db7eb (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
"""
openmw_cfg.py — generate the managed block of an ``openmw.cfg``.

OpenMW does not use a separate VFS injector: it reads its mods directly from an
ordered list of ``data=`` directories and loads plugins in the order of the
``content=`` lines.  This module rewrites *only* the keys we own, leaving every
other line (engine settings, comments, unrelated keys) untouched:

    data=               asset/plugin search dirs; LATER entries override earlier
    content=            ordered plugin load list (.esp/.esm/.omwaddon/.omwscripts)
    groundcover=        grass/groundcover plugins (kept out of content= for perf)
    fallback-archive=   .bsa archives; later entries override earlier

It is deliberately pure standard library (no Qt / mobase) so it can be unit
tested on its own and reused by the OpenMW game plugin.  The caller decides what
goes into ``data_dirs`` — a single merged dir (Fluorine FUSE) or one entry per
mod (native OpenMW VFS) — this module stays agnostic about that choice.
"""

from __future__ import annotations

from pathlib import Path
from typing import Iterable

# Morrowind masters, in canonical load order.
#
# Do NOT list ``builtin.omwscripts`` here.  It is OpenMW's built-in Lua bundle,
# shipped inside the engine's ``resources/vfs-mw`` and loaded *implicitly* by the
# engine itself.  If we also emit ``content=builtin.omwscripts`` it ends up
# specified twice across the config chain and OpenMW aborts on startup with
# "Content file specified more than once: builtin.omwscripts. Aborting...".
VANILLA_MASTERS: list[str] = [
    "Morrowind.esm",
    "Tribunal.esm",
    "Bloodmoon.esm",
]

# Vanilla BSAs, always emitted as the lowest-priority fallback-archive entries.
VANILLA_BSAS: list[str] = [
    "Morrowind.bsa",
    "Tribunal.bsa",
    "Bloodmoon.bsa",
]

# Keys this module fully owns (lowercase, exact match).
_MANAGED_KEYS = frozenset({"data", "content", "groundcover", "fallback-archive"})


def escape_data_path(path: str) -> str:
    """Quote/escape a path for a ``data=`` line.

    openmw.cfg uses boost::filesystem quoting: ``&`` and ``"`` are escaped with a
    leading ``&`` and the whole value is wrapped in double quotes.  This matches
    AnyOldName3's MO2 exporter so paths containing spaces or quotes round-trip.
    """
    out = ['"']
    for ch in path:
        if ch in ("&", '"'):
            out.append("&")
        out.append(ch)
    out.append('"')
    return "".join(out)


def _is_managed(line: str) -> bool:
    s = line.strip()
    if not s or s.startswith("#") or "=" not in s:
        return False
    return s.split("=", 1)[0].strip().lower() in _MANAGED_KEYS


def _preserve_non_managed(cfg_path: Path) -> list[str]:
    """Return existing cfg lines minus the keys we manage, trailing blanks trimmed."""
    kept: list[str] = []
    if cfg_path.is_file():
        for raw in cfg_path.read_text(encoding="utf-8", errors="replace").splitlines():
            if not _is_managed(raw):
                kept.append(raw)
    while kept and not kept[-1].strip():
        kept.pop()
    return kept


def _dedup_preserving_order(
    base: Iterable[str], extra: Iterable[str]
) -> list[str]:
    """Concatenate ``base`` then ``extra``, dropping case-insensitive duplicates."""
    result: list[str] = []
    seen: set[str] = set()
    for item in (*base, *extra):
        key = item.lower()
        if key not in seen:
            seen.add(key)
            result.append(item)
    return result


def build_managed_block(
    data_dirs: Iterable[Path | str],
    content_plugins: Iterable[str],
    groundcover_plugins: Iterable[str] = (),
    fallback_archives: Iterable[str] = (),
    *,
    vanilla_masters: Iterable[str] = VANILLA_MASTERS,
    vanilla_bsas: Iterable[str] = VANILLA_BSAS,
) -> list[str]:
    """Build the managed cfg lines (no I/O), in the order OpenMW expects."""
    content = _dedup_preserving_order(vanilla_masters, content_plugins)
    archives = _dedup_preserving_order(vanilla_bsas, fallback_archives)

    block: list[str] = [""]  # blank separator from the preserved section
    block += [f"data={escape_data_path(str(d))}" for d in data_dirs]
    block += [f"content={c}" for c in content]
    block += [f"groundcover={g}" for g in groundcover_plugins]
    block += [f"fallback-archive={a}" for a in archives]
    return block


def write_openmw_cfg(
    cfg_path: Path,
    data_dirs: Iterable[Path | str],
    content_plugins: Iterable[str],
    groundcover_plugins: Iterable[str] = (),
    fallback_archives: Iterable[str] = (),
    *,
    vanilla_masters: Iterable[str] = VANILLA_MASTERS,
    vanilla_bsas: Iterable[str] = VANILLA_BSAS,
    log_fn=None,
) -> None:
    """Rewrite the managed data=/content=/groundcover=/fallback-archive= block."""
    _log = log_fn or (lambda _: None)
    data_dirs = list(data_dirs)  # consumed twice (block + log); avoid generator exhaustion
    kept = _preserve_non_managed(cfg_path)
    block = build_managed_block(
        data_dirs,
        content_plugins,
        groundcover_plugins,
        fallback_archives,
        vanilla_masters=vanilla_masters,
        vanilla_bsas=vanilla_bsas,
    )
    cfg_path.parent.mkdir(parents=True, exist_ok=True)
    cfg_path.write_text("\n".join(kept + block) + "\n", encoding="utf-8")
    _log(f"  Wrote openmw.cfg: {len(data_dirs)} data dir(s) to {cfg_path}.")


def restore_openmw_cfg(
    cfg_path: Path,
    data_dirs: Iterable[Path | str],
    *,
    vanilla_masters: Iterable[str] = VANILLA_MASTERS,
    vanilla_bsas: Iterable[str] = VANILLA_BSAS,
    log_fn=None,
) -> None:
    """Reset the managed block to vanilla-only (used on uninstall / 'clear')."""
    _log = log_fn or (lambda _: None)
    if not cfg_path.is_file():
        return
    kept = _preserve_non_managed(cfg_path)
    block = build_managed_block(
        data_dirs,
        (),
        (),
        (),
        vanilla_masters=vanilla_masters,
        vanilla_bsas=vanilla_bsas,
    )
    cfg_path.write_text("\n".join(kept + block) + "\n", encoding="utf-8")
    _log(f"  Restored openmw.cfg to vanilla content at {cfg_path}.")