-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathmodscan.py
More file actions
402 lines (351 loc) · 17.8 KB
/
Copy pathmodscan.py
File metadata and controls
402 lines (351 loc) · 17.8 KB
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
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
"""Fremde Mods in ~mods scannen und mit den eigenen Reglern abgleichen.
Ablauf (GUI ruft das hier auf, siehe gui.App._run_modscan):
1. .pak-Dateien in ~mods finden, auch in Unterordnern: UE5 laedt Paks
rekursiv, und Spieler sortieren ihre Mods gern selbst in
Unterordner (z.B. ~mods\\oxa\\). Eigene Ausgabe-Pak ausnehmen.
2. Je Pak nur die cfg-Eintraege unter GameData/DLCGameData listen und in
EINEM gebuendelten repak-Aufruf extrahieren — NIE die ganze Pak
(Overhaul-Mods sind bis zu 2 GB gross).
3. Aus jeder cfg die Menge der (Top-Level-Struct, Blattname)-Paare ziehen.
4. Die GUI vergleicht diese Paare mit dem "Fussabdruck" jedes Reglers
(tweaks.build_patches mit genau EINEM verstellten Regler).
Verglichen wird bewusst auf (Top-Level-Struct + Blattname), NICHT auf dem
vollen Pfad: fremde Mods patchen denselben Wert oft ueber eine andere
Dateiablage oder Verschachtelung (volle Struct-Kopien statt {bpatch},
andere Patch-Ordner, das offizielle "Base.cfg_patch_<Mod>"-Namensschema
ohne .cfg-Endung, Legacy-refkey-Patches unter freiem Namen). Drei
Verfeinerungen halten die Trefferqualitaet hoch:
- Legacy-Patches (refkey=<SID> statt {bpatch}) werden zusaetzlich unter
ihrem ZIEL-Prototyp gezaehlt — der eigene Struct-Name ist frei waehlbar.
- Vollkopien (Structs OHNE {bpatch}) wiederholen fast alle Vanilla-Werte;
gezaehlt wird dort nur, was sich vom Vanilla-Wert unterscheidet (Index
ueber die bereits geparsten Spieldaten; Unbekanntes bleibt vorsichtig
drin). Sonst markiert eine 60-MB-Vollkopie fast jeden Regler.
- "Weight" zaehlt nur direkt unter dem Top-Struct: in ItemPrototypes ist
das die Masse (kg), tiefer verschachtelt ist es ueberall die
Auswahl-Lotterie (PackOfItemsGroup, Loot-Listen) — eine andere Mechanik.
IoStore-Mods (.pak mit .ucas/.utoc daneben) werden ueber ihren .pak-Teil
gescannt: UE5 legt nur gekochte Assets in .ucas/.utoc ab, lose Dateien wie
cfg-Patches bleiben in der .pak (das Spiel haelt es genauso — die Vanilla-
cfgs liegen in pakchunk0-Windows.pak neben einer .ucas). Verifiziert 04.09.
an 15 Workshop-Abos: 12 tragen echte cfg-Patches in der .pak, die .ucas ist
meist 48 Bytes (leer). Nur die gepackten Assets bleiben uninspiziert — das
sagt die Notiz je Mod. Bis dahin wurden solche Paks pauschal als "contains
data I can't read (IoStore format)" gemeldet, womit der komplette Workshop
am Scan vorbeilief. Doppelte Abos (alter + neuer Workshop-Pfad, gleicher
Mod-Name) legt merge_same_name() zu EINEM Eintrag zusammen.
"""
from __future__ import annotations
import re
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from . import cfgparse, pakio, vendor_bin2cfg
from .cfgparse import CfgStruct
# DLC-Konfigs liegen in einem Schwester-Ordner; kein Regler patcht dort,
# aber der Ergebnis-Dialog soll nicht "no config changes" behaupten.
GAMEDATA_MARKERS = ("/GameLite/GameData/", "/GameLite/DLCGameData/")
# Notizen, die der Ergebnis-Dialog und der Report woertlich zeigen.
# Regel: KEIN "; " im Text — _join_notes() trennt daran.
NO_CFG_NOTE = "no config changes (probably meshes, textures or audio)"
PACKED_NOTE = ("also ships packed assets (IoStore .ucas/.utoc) this tool "
"can't inspect - only its config changes are compared")
PACKED_NO_CFG_NOTE = ("no config overrides in its .pak part - its packed "
"assets (IoStore .ucas/.utoc) can't be inspected")
_INDEX_KEY = re.compile(r"^\[\d+\]$")
# Diese gd-Attribute (alle cached_property) bilden den Vanilla-Index fuer
# den Werte-Vergleich bei Vollkopien. Nur Dateien, die das Tool ohnehin
# kennt — mehr braucht der Abgleich nicht, denn nur dort liegen Regler.
_GD_TREES = (
"obj", "items", "difficulty", "weightparams", "effectmax", "effects",
"floatproviders", "weaponsettings", "weaponattributes", "trade",
"holdbreath", "weapongeneral", "corevars", "stashes", "aiglobals",
"hearingsensors", "visionscanners", "camerashake", "artifactspawners",
"passivedetectors", "fasttravel", "boolproviders", "abilities", "melee",
"weatherselection", "itemgenerators", "relations",
)
@dataclass
class ModInfo:
"""Ergebnis des Scans EINER fremden Pak."""
name: str # Dateiname ohne .pak
path: Path
readable: bool = True
note: str = "" # z.B. "contains data I can't read"
n_cfg: int = 0 # gefundene cfg-Dateien unter GameData
pairs: set = field(default_factory=set) # {(TopStruct, Blattname)}
base_names: set = field(default_factory=set) # {"DifficultyPrototypes", ...}
source: str = "~mods" # "~mods" | "workshop"
packed_assets: bool = False # .ucas/.utoc daneben (IoStore-Assets)
n_paks: int = 1 # > 1, wenn merge_same_name() zusammenlegte
def find_mod_paks(mods_dir: Path, exclude_names: set[str]) -> list[Path]:
"""Alle fremden .pak-Dateien in ~mods, REKURSIV: UE5 mountet auch
Unterordner, und Spieler legen sich dort gern eine eigene Ordnung
an (manche Mods liefern Unterordner auch selbst mit)."""
if not mods_dir.is_dir():
return []
excl = {n.lower() for n in exclude_names}
return sorted(
p for p in mods_dir.rglob("*.pak")
if p.name.lower() not in excl
)
def find_workshop_paks(workshop_dir: Path | None) -> list[Path]:
"""Alle .pak-Dateien abonnierter Steam-Workshop-Mods (rekursiv — die
Struktur ist tief: <id>\\Windows\\{New,Override}Content\\...\\Paks\\...).
Verifiziert 02.09.: das Spiel liest sie direkt von dort, kopiert wird
nichts in den Spielordner."""
if workshop_dir is None or not workshop_dir.is_dir():
return []
return sorted(workshop_dir.rglob("*.pak"))
def workshop_mod_name(pak: Path, workshop_dir: Path) -> str:
"""Anzeigename einer Workshop-Pak: der Mod-Ordner unter Stalker2/Mods/
(letztes Vorkommen — so heisst die Mod wirklich), sonst die Item-ID.
NewContent-Paks (neue Assets, Mount ausserhalb GameData) werden im
Namen unterschieden, damit die zwei Paks einer Mod im Dialog nicht
identisch heissen."""
parts = pak.parts
name = None
for i in range(len(parts) - 2):
if parts[i].lower() == "mods":
name = parts[i + 1]
if name is None:
try:
name = pak.relative_to(workshop_dir).parts[0]
except ValueError:
name = pak.stem
kind = ", new content" if "newcontent" in pak.stem.lower() else ""
return f"{name} (Workshop{kind})"
def is_iostore(pak: Path) -> bool:
"""Liegt ein IoStore-Container (.utoc/.ucas) neben der Pak? Dann traegt
die Mod gepackte Assets, die der Scan nicht sehen kann. Die .pak selbst
bleibt lesbar — dort liegen bei UE5 die losen Dateien (cfg-Patches)."""
return (pak.with_suffix(".utoc").is_file()
or pak.with_suffix(".ucas").is_file())
def _join_notes(*notes: str) -> str:
"""Notizen mit "; " verketten, Dubletten und Leeres weglassen."""
seen: list[str] = []
for note in notes:
for part in note.split("; "):
part = part.strip()
if part and part not in seen:
seen.append(part)
return "; ".join(seen)
def merge_same_name(infos: list["ModInfo"]) -> list["ModInfo"]:
"""Paks mit demselben Anzeigenamen (und derselben Quelle) zu EINEM
Eintrag zusammenlegen. Anlass: Steam-Workshop-Abos liegen seit dem
Layout-Wechsel oft doppelt vor — einmal im alten Pfad direkt unter
Stalker2/Mods/<Name>/ und einmal unter Windows/OverrideContent/... —
mit (fast) identischem Inhalt. Getrennt gezaehlt stuende jede Mod
zweimal im Dialog und in jeder Tooltip-Liste. Vereinigt werden Paare
und Basisnamen; lesbar ist der Eintrag, sobald EINE Pak lesbar war;
n_cfg ist das Maximum (identische Dateien nicht doppelt zaehlen).
Reihenfolge bleibt die des Erstvorkommens, der Pfad der erste."""
merged: dict[tuple[str, str], ModInfo] = {}
for info in infos:
key = (info.name, info.source)
first = merged.get(key)
if first is None:
merged[key] = info
continue
first.n_paks += 1
first.pairs |= info.pairs
first.base_names |= info.base_names
first.n_cfg = max(first.n_cfg, info.n_cfg)
first.readable = first.readable or info.readable
first.packed_assets = first.packed_assets or info.packed_assets
first.note = _join_notes(first.note, info.note)
out = list(merged.values())
for info in out:
if info.n_paks == 1:
continue
if info.n_cfg:
# "keine cfg" einer Haelfte gilt nicht mehr, wenn die andere
# welche hat.
info.note = "; ".join(
p for p in info.note.split("; ")
if p not in (NO_CFG_NOTE, PACKED_NO_CFG_NOTE))
info.note = _join_notes(
info.note, f"{info.n_paks} pak files with this name were "
"merged into one entry")
return out
def _short_error(exc: Exception) -> str:
"""Fehlertext auf EINE kurze Zeile eindampfen — repak wirft sonst 1,7 KB
Roh-stderr, die im Ergebnis-Dialog landen wuerden."""
text = str(exc).strip()
if "version unsupported" in text or "trying version" in text:
return "not a readable .pak file"
first = text.splitlines()[0] if text else "unknown error"
return first[:120]
def _split_marker(path: str) -> str | None:
"""Pfad hinter dem GameData-/DLCGameData-Marker, sonst None."""
norm = path.replace("\\", "/")
for marker in GAMEDATA_MARKERS:
if marker in norm:
return norm.split(marker, 1)[1]
return None
def _is_cfg_name(name: str) -> bool:
"""Config-Datei? ENTHAELT .cfg statt endswith: das offizielle
Patch-Namensschema ist "Base.cfg_patch_<Mod>" OHNE weitere Endung
(docs/SPEC.md, Abschnitt 0) — endswith(".cfg") uebersieht es."""
return ".cfg" in name
def _norm_value(raw: str) -> str:
"""Wert fuer den Vanilla-Vergleich normalisieren: 1 / 1. / 1.0 / 1.f
sind dieselbe Zahl, True/False/false dieselben Booleans."""
v = raw.strip().rstrip(";").strip()
if v.endswith("%"):
core = v[:-1].strip()
suffix = "%"
else:
core = v
suffix = ""
core2 = core.rstrip("fF").rstrip(".")
try:
return repr(float(core2)) + suffix
except ValueError:
return v.lower()
def build_vanilla_index(gd) -> dict[tuple[str, str], set[str]]:
"""(Top-Level-Struct, Blattname) -> {normalisierte Vanilla-Werte} ueber
alle Spieldaten-Dateien, die das Tool kennt. Grundlage fuer den
"hat die Vollkopie den Wert wirklich geaendert?"-Vergleich."""
index: dict[tuple[str, str], set[str]] = {}
for attr in _GD_TREES:
try:
tree = getattr(gd, attr)
except Exception:
continue # Datei fehlt im Dev-Dump: tolerieren
for top_key, top in tree.children.items():
top_name = top_key.split("#")[0]
for node in top.walk():
for key, value in node.values.items():
index.setdefault((top_name, key), set()).add(
_norm_value(value))
return index
def collect_pairs(root: CfgStruct,
vanilla_index: dict | None = None) -> set[tuple[str, str]]:
"""(Top-Level-Struct, Blattname)-Paare einer geparsten cfg.
- "#n"-Suffixe des Parsers (doppelte Namen) werden entfernt.
- Legacy-Patches ({refkey=<SID>} mit freiem Struct-Namen) zaehlen
ZUSAETZLICH unter ihrem Ziel-Prototyp.
- Structs ohne {bpatch} gelten als Vollkopie: mit vanilla_index werden
nur Werte gezaehlt, die sich von Vanilla unterscheiden (unbekannte
Paare bleiben vorsichtig drin).
- "Weight" nur direkt unter dem Top-Struct (siehe Modul-Docstring)."""
pairs: set[tuple[str, str]] = set()
for top_key, top in root.children.items():
own = top_key.split("#")[0]
names = {own}
attrs = top.attr_dict()
refkey = (attrs.get("refkey") or "").strip()
if refkey and not _INDEX_KEY.fullmatch(refkey):
names.add(refkey)
check_vanilla = (vanilla_index is not None
and "bpatch" not in attrs)
for node in top.walk():
for key, value in node.values.items():
if key == "Weight" and node is not top:
continue
if check_vanilla:
# Geprueft wird gegen den EIGENEN Namen; nur wenn der in
# Vanilla unbekannt ist (Legacy-Patch unter freiem
# Namen), gegen das refkey-Ziel. Beides zugleich waere
# falsch: ein erbender Struct (Hard erbt von Empty)
# saehe sonst jeden eigenen Wert als "geaendert", weil
# er vom Basis-Wert abweicht.
vals = vanilla_index.get((own, key))
if vals is None and refkey in names:
vals = vanilla_index.get((refkey, key))
if vals is not None and _norm_value(value) in vals:
continue # Vollkopie wiederholt Vanilla
for name in names:
pairs.add((name, key))
return pairs
def base_segments(rel_after_gamedata: str) -> set[str]:
"""Alle Pfadsegmente eines GameData-Eintrags, um ".cfg"-Endungen
bereinigt. Dient nur als billiger Vorfilter fuer die teuren Regler
("kommt ItemGeneratorPrototypes in dieser Mod ueberhaupt vor?") —
der eigentliche Vergleich laeuft ueber die (Struct, Blatt)-Paare."""
return {part.split(".cfg")[0]
for part in rel_after_gamedata.split("/") if part}
def _escape_glob(entry: str) -> str:
"""repak -i ist ein GLOB-Muster, kein Literal: "[...]" ist eine
Zeichenklasse. "[" zuerst escapen macht alle uebrigen "]" literal
(mehr Sonderzeichen sind in Windows-Dateinamen nicht erlaubt)."""
return entry.replace("\\", "/").replace("[", "[[]")
def scan_pak(pak: Path, progress=None, vanilla_index: dict | None = None) -> ModInfo:
"""Eine fremde Pak scannen: cfg-Eintraege listen, entpacken, parsen."""
info = ModInfo(name=pak.stem, path=pak)
# .utoc/.ucas daneben heisst NICHT "unlesbar": die cfg-Patches liegen
# im .pak-Teil, nur die gepackten Assets bleiben unsichtbar (Notiz).
info.packed_assets = is_iostore(pak)
try:
entries = pakio.list_pak(pak)
except Exception as exc: # kaputte/unlesbare Pak: melden, nicht crashen
info.readable = False
info.note = f"contains data I can't read ({_short_error(exc)})"
return info
cfg_entries = [e for e in entries
if _split_marker(e) is not None
and _is_cfg_name(e.replace("\\", "/").split("/")[-1])]
info.n_cfg = len(cfg_entries)
if not cfg_entries:
info.note = PACKED_NO_CFG_NOTE if info.packed_assets else NO_CFG_NOTE
return info
with tempfile.TemporaryDirectory(prefix="s2tweaker_scan_") as tmp:
out = Path(tmp)
if progress:
progress(f"Scanning {pak.name} "
f"({info.n_cfg} config file{'s' if info.n_cfg != 1 else ''}) ...")
try:
# EIN gebuendelter repak-Aufruf statt einem Prozess pro Datei —
# nur die cfg-Eintraege werden extrahiert, nie die ganze Pak.
pakio.unpack_many(pak, out,
[_escape_glob(e) for e in cfg_entries])
except Exception as exc:
info.readable = False
info.note = f"contains data I can't read ({_short_error(exc)})"
return info
# Statt die Ablage je Eintrag zu erraten (Mount-Points variieren je
# Mod), einfach ALLES Extrahierte einsammeln — mehr als die
# angeforderten cfg-Eintraege kann nicht dort liegen.
parsed = 0
for extracted in sorted(out.rglob("*")):
if not extracted.is_file() or not _is_cfg_name(extracted.name):
continue
rel = _split_marker(extracted.as_posix())
if rel is None:
continue
try:
if ".cfg.bin" in extracted.name:
roots = vendor_bin2cfg.read_binary_cfg(
extracted.read_bytes())
text = "\n".join(r.to_string() for r in roots)
else:
text = extracted.read_bytes().decode(
"utf-8-sig", errors="replace")
root = cfgparse.parse(text)
except Exception:
# Einzelne kaputte Datei: den Rest der Pak trotzdem werten
info.note = "some files could not be read"
continue
parsed += 1
info.pairs |= collect_pairs(root, vanilla_index)
info.base_names |= base_segments(rel)
# Sicherheitsnetz: repak "erfolgreich", aber Dateien fehlen (z.B.
# ein nicht abgefangenes Muster-Problem) -> nie stilles Alles-ok.
if parsed < info.n_cfg and not info.note:
info.note = "some files could not be read"
if info.packed_assets:
info.note = _join_notes(info.note, PACKED_NOTE)
return info
def pairs_from_patches(patches: dict[str, str]) -> set[tuple[str, str]]:
"""Fussabdruck eines Reglers: (Top-Struct, Blattname)-Paare aus den von
tweaks.build_patches erzeugten Patch-Texten.
"Type" fliegt heraus: einzelne Builder emittieren den Feldtyp als
unveraenderten ANKER neben dem eigentlichen Wert (z.B. die
Hearing-Patches). Im Fussabdruck wuerde ('Default', 'Type') sonst jede
Mod treffen, die irgendein Struct namens Default mit einem Type-Feld
patcht (nachgewiesen: RelationPrototypes). Die Mod-Seite behaelt ihre
Type-Paare — nur der Fussabdruck verzichtet darauf."""
pairs: set[tuple[str, str]] = set()
for text in patches.values():
pairs |= collect_pairs(cfgparse.parse(text))
return {(top, leaf) for top, leaf in pairs if leaf != "Type"}