SL2 Analyzer
Read a FromSoftware .sl2 save and report what is in it
Loading...
Searching...
No Matches
cli.py
Go to the documentation of this file.
1"""Command line: argument parsing, save auto-detection, and main()."""
2
3import argparse
4import glob
5import hashlib
6import json
7import os
8import sys
9import tempfile
10from datetime import datetime, timezone
11
12from validators import run_validation
13from validators.file_rules import run_file_validation
14from validators.text import validation_text, validation_text_file
15
16from .bnd4 import checksum_ok, parse_bnd4
17from .combine import build_combined, find_saves
18from .convert import parse_save, render_markdown
19from .edit import set_steam_id, trim_er_slots, trim_inactive_slots
20from .jsonout import build_json, parse_meta
21
22
23## @brief Folders a Souls save can live in, per OS. Each game keeps its `.sl2` in
24# a game-named subfolder, hence the trailing `*/`. Steam/Proton, Heroic, Lutris,
25# and plain Wine all mirror the Windows `%APPDATA%` tree inside a prefix, so the
26# tail of every glob is the same `.../AppData/Roaming/<game>/*.sl2`.
28 globs = ["*.sl2", os.path.join("*", "*.sl2")] # cwd and one level down
29 home = os.path.expanduser("~")
30 appdata = os.environ.get("APPDATA")
31 if appdata: # native Windows
32 globs.append(os.path.join(appdata, "*", "*.sl2"))
33 globs.append(os.path.join(appdata, "*", "*", "*.sl2"))
34 # Most games write %APPDATA%/<game>/<file>.sl2; Sekiro puts a Steam-id folder in
35 # between, so every prefix is searched at both depths.
36 roaming = [
37 "drive_c/users/steamuser/AppData/Roaming/*/*.sl2",
38 "drive_c/users/steamuser/AppData/Roaming/*/*/*.sl2",
39 ]
40 user_roaming = [
41 "drive_c/users/*/AppData/Roaming/*/*.sl2",
42 "drive_c/users/*/AppData/Roaming/*/*/*.sl2",
43 ]
44 # Steam through Proton.
45 for steam in (".local/share/Steam", ".steam/steam", ".steam/root"):
46 for tail in roaming:
47 globs.append(os.path.join(home, steam, "steamapps/compatdata/*/pfx", tail))
48 # Heroic (Epic / GOG) Wine prefixes. Heroic names a prefix after the game, so the
49 # per-game folder is a wildcard too.
50 for heroic in (
51 "Games/Heroic/Prefixes/default/*/pfx",
52 "Games/Heroic/Prefixes/*/pfx",
53 ".config/heroic/prefixes/default/*/pfx",
54 "Games/Heroic/*/pfx",
55 ):
56 for tail in roaming:
57 globs.append(os.path.join(home, heroic, tail))
58 # Lutris and a plain ~/.wine prefix (user-named, not always "steamuser").
59 for tail in user_roaming:
60 globs.append(os.path.join(home, ".local/share/lutris/*/pfx", tail))
61 globs.append(os.path.join(home, ".wine", tail))
62 return globs
63
64
65##
66# @brief Find a `.sl2` when none was given on the command line.
67# @details Globs the current folder and the usual Steam/Proton and Windows save
68# locations, and returns the most recently modified match — the live character is
69# almost always the newest file. Exits with a clear message if nothing is found.
70# @return The path to the chosen save.
72 found = []
73 for pat in _save_globs():
74 found += glob.glob(pat)
75 found = sorted(set(found), key=lambda p: os.path.getmtime(p), reverse=True)
76 if not found:
77 sys.exit(
78 "No .sl2 found in the current folder or the usual save locations. "
79 "Pass the path explicitly: sl2_to_md.py <save.sl2>"
80 )
81 if len(found) > 1:
82 print(f"Auto-detected {len(found)} saves; using the newest: {found[0]}")
83 print(" (pass a path to pick another)")
84 else:
85 print(f"Auto-detected save: {found[0]}")
86 return found[0]
87
88
89##
90# @brief Program entry point.
91# @details Output format follows the -o extension (.json → JSON, anything else →
92# Markdown) unless --format says otherwise, so the common case is one flag, not two.
93# @return None. Writes the chosen file and prints where it went.
94def main():
95 ap = argparse.ArgumentParser(
96 description="FromSoftware .sl2 save -> Markdown or JSON playthrough summary "
97 "(DS PtDE/Remastered, DS2 vanilla/SOTFS, DS3, Sekiro, Elden Ring)",
98 epilog="Metadata example: --meta source=Steam --meta os='Nobara 43' "
99 "--meta launcher=Heroic --meta proton='GE-Proton 9-20' "
100 "--meta dlc='Ashes of Ariandel' --meta dlc='The Ringed City'. "
101 "A key repeated becomes a list. Any key is accepted.",
102 )
103 ap.add_argument(
104 "sl2",
105 nargs="*",
106 help="path to a .sl2 save, or a FOLDER of them (auto-detected if "
107 "omitted). Several paths, or one folder, produce a combined "
108 "playthrough document covering every character found.",
109 )
110 ap.add_argument(
111 "-o",
112 "--out",
113 default="playthrough.md",
114 help="output path; a .json extension selects JSON",
115 )
116 ap.add_argument(
117 "--format",
118 choices=("auto", "md", "json"),
119 default="auto",
120 help="output format (default: from the -o extension)",
121 )
122 ap.add_argument(
123 "--meta",
124 action="append",
125 metavar="KEY=VALUE",
126 help="record how the game was run: store, version, DLC, OS, "
127 "launcher, Proton build, anything. Repeatable; a repeated "
128 "key becomes a list. None of it is read from the save.",
129 )
130 ap.add_argument(
131 "--meta-json",
132 metavar="PATH",
133 help="JSON object of the same metadata, merged underneath --meta",
134 )
135 ap.add_argument(
136 "--indent",
137 type=int,
138 default=2,
139 help="JSON indent; 0 for one dense line (default: 2)",
140 )
141 ap.add_argument(
142 "--validate",
143 action="store_true",
144 help="run the validation pass and add its findings to the output: "
145 "states an unmodified game could not produce, and internal "
146 "contradictions. Off by default, and it never changes what is parsed",
147 )
148 ap.add_argument(
149 "--combined",
150 action="store_true",
151 help="force the combined document even for a single save",
152 )
153 ap.add_argument(
154 "--set-steam-id",
155 metavar="STEAMID64",
156 help="write a verified SteamID64 to a NEW .sl2 copy (DS2, DS3, Sekiro)",
157 )
158 ap.add_argument(
159 "--set-username",
160 metavar="USERNAME",
161 help="always refused: Steam usernames are not stored in .sl2 saves",
162 )
163 ap.add_argument(
164 "--trim-slots",
165 metavar="SLOT",
166 nargs="+",
167 type=int,
168 help="write a NEW Elden Ring copy clearing every unselected fixed slot (same file size)",
169 )
170 ap.add_argument(
171 "--trim-inactive",
172 action="store_true",
173 help="write a NEW Sekiro or Elden Ring copy clearing inactive slots (same file size)",
174 )
175 ap.add_argument(
176 "--archive-manifest",
177 nargs="?",
178 const="",
179 metavar="PATH",
180 help="write a JSON archive sidecar; omit PATH only with an archive-copy edit",
181 )
182 ap.add_argument(
183 "--verify-archive",
184 action="store_true",
185 help="read and verify one save, or every save in the named folder(s)",
186 )
187 ap.add_argument(
188 "--force",
189 action="store_true",
190 help="allow an archive copy or manifest to replace its existing output",
191 )
192 args = ap.parse_args()
193
194 if args.set_username is not None:
195 sys.exit("Steam usernames are not stored in .sl2 saves; nothing was changed.")
196 if args.trim_slots is not None and args.trim_inactive:
197 sys.exit("Choose either --trim-slots or --trim-inactive.")
198 edit_requested = any(
199 (args.set_steam_id is not None, args.trim_slots is not None, args.trim_inactive)
200 )
201 # The db_* folders sit beside the package, not inside it.
202 base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
203 if args.verify_archive:
204 if edit_requested or args.archive_manifest is not None or args.combined:
205 sys.exit(
206 "--verify-archive cannot be combined with edit, manifest, or combined mode."
207 )
208 verify_archive(args.sl2, base_dir)
209 return
210 if edit_requested and (
211 args.combined or len(args.sl2) != 1 or not os.path.isfile(args.sl2[0])
212 ):
213 sys.exit("An edit needs exactly one existing .sl2 input file.")
214 if edit_requested:
215 edit_save(args)
216 return
217
218 try:
219 meta = parse_meta(args.meta, args.meta_json)
220 except (OSError, ValueError) as exc:
221 sys.exit(str(exc))
222
223 # A folder, or more than one path, can only mean the combined document — there is
224 # no single save to summarise. One file still takes the single-save path unless
225 # --combined asks otherwise.
226 paths = list(args.sl2)
227 files = []
228 for p in paths:
229 if os.path.isdir(p):
230 files += find_saves(p)
231 elif os.path.isfile(p):
232 files.append(os.path.abspath(p))
233 else:
234 sys.exit(f"No such file or folder: {p}")
235 if paths and not files:
236 sys.exit("No .sl2 files found in: " + ", ".join(paths))
237
238 if args.combined or len(files) > 1 or any(os.path.isdir(p) for p in paths):
239 text = build_combined(files, base_dir, meta)
240 if text is None:
241 sys.exit("None of those files could be read as a supported save.")
242 write_out(args.out, text)
243 return
244
245 sl2 = files[0] if files else auto_find_save()
246 if not os.path.isfile(sl2):
247 sys.exit(f"No such file: {sl2}")
248 with open(sl2, "rb") as f:
249 data = f.read()
250
251 if args.archive_manifest is not None:
252 if args.archive_manifest == "":
253 sys.exit("Pass a manifest path when not creating an archive copy.")
254 save = parse_save(data, base_dir)
255 write_archive_manifest(args.archive_manifest, sl2, data, save, force=args.force)
256 return
257
258 fmt = args.format
259 if fmt == "auto":
260 fmt = "json" if args.out.lower().endswith(".json") else "md"
261
262 name = os.path.basename(sl2)
263 save = parse_save(data, base_dir)
264 warn_foreign_folder(sl2, save)
265 if fmt == "json":
266 text = json.dumps(
267 build_json(save, name, meta, args.validate, data),
268 ensure_ascii=False,
269 indent=args.indent or None,
270 )
271 text += "\n"
272 else:
273 text = render_markdown(save, name, meta, args.validate, data)
274
275 write_out(args.out, text)
276 if args.validate:
277 print_validation(save, data)
278
279
280##
281# @brief Print each character's validation block to the terminal.
282# @details The findings are already in the document; this is so a run that was only
283# asking "does this save add up" answers without opening the file. Stdout, one block per
284# character, in the shape validators/text.py defines.
285# @param save The parsed @ref sl2.convert.SaveData. @param data The file bytes, for
286# the file-level rules.
287def print_validation(save, data):
288 start = save.cfg["slots"].start
289 for i, ch in save.characters:
290 label = f"Slot {i - start + 1}" + (f": {ch['name']}" if ch.get("name") else "")
291 print()
292 print("\n".join(validation_text(run_validation(ch, save.game), label)))
293 entries = parse_bnd4(data) or []
294 report = run_file_validation(save.game, entries, data, checksum_ok)
295 print()
296 print("\n".join(validation_text_file(report)))
297
298
299##
300# @brief Warn when a save is sitting in a folder belonging to a different account.
301# @details DS3, Sekiro and Elden Ring write the owning account into the save, and the
302# game only reads a save back from the folder named for that account — so a save whose
303# account id changed underneath it (a Steam emulator reconfigured, a different profile)
304# will not load, and the game says nothing useful about why. Comparing the two is free
305# once both are known.
306# @note Deliberately printed to STDERR and never into the document: the browser cannot
307# see a dropped file's folder, and the two front ends have to stay byte-identical.
308# @param path The save file's path. @param save The parsed @ref sl2.convert.SaveData.
309def warn_foreign_folder(path, save):
310 if save.folder is None:
311 return
312 here = os.path.basename(os.path.dirname(os.path.abspath(path)))
313 # Only complain when the folder is clearly one of the game's own account folders:
314 # same width as the real thing and made of the right digits. A save copied into a
315 # working directory is not a mismatch, it is just somewhere else — and the width
316 # test is what stops an ordinary folder that happens to spell hex ("beef", "decade")
317 # from being read as somebody's account.
318 if not here or here.lower() == save.folder.lower() or len(here) != len(save.folder):
319 return
320 if not all(c in "0123456789abcdefABCDEF" for c in here):
321 return
322 print(
323 f"Warning: this save was written by Steam account {save.owner[0]} and the "
324 f"game will only load it from a folder named '{save.folder}', but it is in "
325 f"'{here}'. Under the account that owns '{here}' the game will not see it.",
326 file=sys.stderr,
327 )
328
329
330## @brief Write the document, making the output folder if it does not exist yet.
331def write_out(path, text):
332 out_dir = os.path.dirname(os.path.abspath(path))
333 if out_dir and not os.path.isdir(out_dir):
334 os.makedirs(out_dir, exist_ok=True)
335 with open(path, "w", encoding="utf-8") as f:
336 f.write(text)
337 print(f"Wrote {path}")
338
339
340## @brief Apply one or both copy-only edits and atomically create the requested .sl2.
341def edit_save(args):
342 source = os.path.abspath(args.sl2[0])
343 output = os.path.abspath(args.out)
344 if not output.lower().endswith(".sl2"):
345 sys.exit("An edited save needs an explicit .sl2 --out path.")
346 if os.path.realpath(source) == os.path.realpath(output):
347 sys.exit("Refusing to overwrite the input save; choose a different --out path.")
348 if os.path.exists(output) and not args.force:
349 sys.exit("Output already exists; choose another path or pass --force.")
350 if args.archive_manifest is not None:
351 manifest = archive_manifest_path(args.archive_manifest, output)
352 if os.path.exists(manifest) and not args.force:
353 sys.exit("Manifest already exists; choose another path or pass --force.")
354 with open(source, "rb") as f:
355 data = f.read()
356 original = data
357 try:
358 if args.set_steam_id is not None:
359 data = set_steam_id(data, args.set_steam_id)
360 if args.trim_slots is not None:
361 data = trim_er_slots(data, args.trim_slots)
362 if args.trim_inactive:
363 data = trim_inactive_slots(data)
364 base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
365 checked = parse_save(data, base_dir)
366 except (ValueError, SystemExit) as exc:
367 sys.exit(str(exc))
368 folder = os.path.dirname(output) or os.curdir
369 os.makedirs(folder, exist_ok=True)
370 fd, temporary = tempfile.mkstemp(prefix=".sl2-analyzer-", suffix=".tmp", dir=folder)
371 try:
372 with os.fdopen(fd, "wb") as f:
373 f.write(data)
374 f.flush()
375 os.fsync(f.fileno())
376 os.replace(temporary, output)
377 except OSError:
378 try:
379 os.unlink(temporary)
380 except OSError:
381 pass
382 raise
383 print(f"Wrote verified archive copy {args.out}")
384 if args.set_steam_id is not None and checked.folder is not None:
385 print(f"Place it in the Steam folder named {checked.folder}.")
386 if args.archive_manifest is not None:
387 operations = []
388 if args.set_steam_id is not None:
389 operations.append({"set_steam_id": checked.owner[1]})
390 if args.trim_slots is not None:
391 operations.append({"trim_slots": sorted(set(args.trim_slots))})
392 if args.trim_inactive:
393 operations.append({"trim_inactive": True})
395 args.archive_manifest,
396 output,
397 data,
398 checked,
399 source=(source, original),
400 operations=operations,
401 force=args.force,
402 )
403
404
405## @brief Write a small, auditable sidecar describing one parsed archive copy.
407 option, archive, data, save, source=None, operations=None, force=False
408):
409 path = archive_manifest_path(option, archive)
410 if os.path.exists(path) and not force:
411 sys.exit("Manifest already exists; choose another path or pass --force.")
412 slots = []
413 for index, ch in save.characters:
414 row = {"slot": index - save.cfg["slots"].start + 1}
415 for key in ("name", "level"):
416 if ch.get(key) is not None:
417 row[key] = ch[key]
418 slots.append(row)
419 manifest = {
420 "format": "sl2-analyzer archive manifest",
421 "version": 1,
422 "created_utc": datetime.now(timezone.utc).isoformat(),
423 "archive": {
424 "name": os.path.basename(archive),
425 "bytes": len(data),
426 "sha256": hashlib.sha256(data).hexdigest(),
427 },
428 "game": save.game,
429 "steam_id64": save.owner[1] if save.owner else None,
430 "slots": slots,
431 "operations": operations or [],
432 }
433 if source is not None:
434 manifest["source"] = {
435 "name": os.path.basename(source[0]),
436 "sha256": hashlib.sha256(source[1]).hexdigest(),
437 }
438 folder = os.path.dirname(path) or os.curdir
439 os.makedirs(folder, exist_ok=True)
440 fd, temporary = tempfile.mkstemp(prefix=".sl2-analyzer-", suffix=".tmp", dir=folder)
441 try:
442 with os.fdopen(fd, "w", encoding="utf-8") as f:
443 json.dump(manifest, f, ensure_ascii=False, indent=2)
444 f.write("\n")
445 f.flush()
446 os.fsync(f.fileno())
447 os.replace(temporary, path)
448 except OSError:
449 try:
450 os.unlink(temporary)
451 except OSError:
452 pass
453 raise
454 print(f"Wrote archive manifest {path}")
455
456
457## @brief Resolve an explicit archive sidecar path, or the archive's default neighbor.
458def archive_manifest_path(option, archive):
459 return archive + ".archive.json" if option == "" else os.path.abspath(option)
460
461
462## @brief Read every named archive and report BND4, parse, and ownership status.
463def verify_archive(paths, base_dir):
464 files = []
465 for path in paths or [auto_find_save()]:
466 if os.path.isdir(path):
467 files += find_saves(path)
468 elif os.path.isfile(path):
469 files.append(os.path.abspath(path))
470 else:
471 sys.exit(f"No such file or folder: {path}")
472 if not files:
473 sys.exit("No .sl2 files found to verify.")
474 failures = []
475 for path in sorted(set(files)):
476 try:
477 with open(path, "rb") as f:
478 data = f.read()
479 entries = parse_bnd4(data)
480 bad = [str(e.index) for e in entries if not checksum_ok(data, e)]
481 if bad:
482 raise ValueError("bad checksum(s): " + ", ".join(bad))
483 save = parse_save(data, base_dir)
484 warn_foreign_folder(path, save)
485 except (OSError, ValueError, SystemExit) as exc:
486 failures.append(f"FAIL {path}: {exc}")
487 continue
488 print(
489 f"OK {path}: {save.cfg['title']}, {len(save.characters)} slot(s), sha256 {hashlib.sha256(data).hexdigest()}"
490 )
491 if failures:
492 print("\n".join(failures), file=sys.stderr)
493 sys.exit(f"Archive verification failed for {len(failures)} file(s).")
write_out(path, text)
Write the document, making the output folder if it does not exist yet.
Definition cli.py:331
_save_globs()
Folders a Souls save can live in, per OS.
Definition cli.py:27
verify_archive(paths, base_dir)
Read every named archive and report BND4, parse, and ownership status.
Definition cli.py:463
warn_foreign_folder(path, save)
Warn when a save is sitting in a folder belonging to a different account.
Definition cli.py:309
archive_manifest_path(option, archive)
Resolve an explicit archive sidecar path, or the archive's default neighbor.
Definition cli.py:458
write_archive_manifest(option, archive, data, save, source=None, operations=None, force=False)
Write a small, auditable sidecar describing one parsed archive copy.
Definition cli.py:408
main()
Program entry point.
Definition cli.py:94
auto_find_save()
Find a .sl2 when none was given on the command line.
Definition cli.py:71
edit_save(args)
Apply one or both copy-only edits and atomically create the requested .sl2.
Definition cli.py:341
print_validation(save, data)
Print each character's validation block to the terminal.
Definition cli.py:287