1"""Command line: argument parsing, save auto-detection, and main()."""
10from datetime
import datetime, timezone
12from validators
import run_validation
13from validators.file_rules
import run_file_validation
14from validators.text
import validation_text, validation_text_file
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
28 globs = [
"*.sl2", os.path.join(
"*",
"*.sl2")]
29 home = os.path.expanduser(
"~")
30 appdata = os.environ.get(
"APPDATA")
32 globs.append(os.path.join(appdata,
"*",
"*.sl2"))
33 globs.append(os.path.join(appdata,
"*",
"*",
"*.sl2"))
37 "drive_c/users/steamuser/AppData/Roaming/*/*.sl2",
38 "drive_c/users/steamuser/AppData/Roaming/*/*/*.sl2",
41 "drive_c/users/*/AppData/Roaming/*/*.sl2",
42 "drive_c/users/*/AppData/Roaming/*/*/*.sl2",
45 for steam
in (
".local/share/Steam",
".steam/steam",
".steam/root"):
47 globs.append(os.path.join(home, steam,
"steamapps/compatdata/*/pfx", tail))
51 "Games/Heroic/Prefixes/default/*/pfx",
52 "Games/Heroic/Prefixes/*/pfx",
53 ".config/heroic/prefixes/default/*/pfx",
57 globs.append(os.path.join(home, heroic, tail))
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))
74 found += glob.glob(pat)
75 found = sorted(set(found), key=
lambda p: os.path.getmtime(p), reverse=
True)
78 "No .sl2 found in the current folder or the usual save locations. "
79 "Pass the path explicitly: sl2_to_md.py <save.sl2>"
82 print(f
"Auto-detected {len(found)} saves; using the newest: {found[0]}")
83 print(
" (pass a path to pick another)")
85 print(f
"Auto-detected save: {found[0]}")
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.",
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.",
113 default=
"playthrough.md",
114 help=
"output path; a .json extension selects JSON",
118 choices=(
"auto",
"md",
"json"),
120 help=
"output format (default: from the -o extension)",
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.",
133 help=
"JSON object of the same metadata, merged underneath --meta",
139 help=
"JSON indent; 0 for one dense line (default: 2)",
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",
151 help=
"force the combined document even for a single save",
156 help=
"write a verified SteamID64 to a NEW .sl2 copy (DS2, DS3, Sekiro)",
161 help=
"always refused: Steam usernames are not stored in .sl2 saves",
168 help=
"write a NEW Elden Ring copy clearing every unselected fixed slot (same file size)",
173 help=
"write a NEW Sekiro or Elden Ring copy clearing inactive slots (same file size)",
176 "--archive-manifest",
180 help=
"write a JSON archive sidecar; omit PATH only with an archive-copy edit",
185 help=
"read and verify one save, or every save in the named folder(s)",
190 help=
"allow an archive copy or manifest to replace its existing output",
192 args = ap.parse_args()
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)
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:
206 "--verify-archive cannot be combined with edit, manifest, or combined mode."
210 if edit_requested
and (
211 args.combined
or len(args.sl2) != 1
or not os.path.isfile(args.sl2[0])
213 sys.exit(
"An edit needs exactly one existing .sl2 input file.")
219 meta = parse_meta(args.meta, args.meta_json)
220 except (OSError, ValueError)
as exc:
226 paths = list(args.sl2)
230 files += find_saves(p)
231 elif os.path.isfile(p):
232 files.append(os.path.abspath(p))
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))
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)
241 sys.exit(
"None of those files could be read as a supported save.")
246 if not os.path.isfile(sl2):
247 sys.exit(f
"No such file: {sl2}")
248 with open(sl2,
"rb")
as f:
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)
260 fmt =
"json" if args.out.lower().endswith(
".json")
else "md"
262 name = os.path.basename(sl2)
263 save = parse_save(data, base_dir)
267 build_json(save, name, meta, args.validate, data),
269 indent=args.indent
or None,
273 text = render_markdown(save, name, meta, args.validate, 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 "")
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)
296 print(
"\n".join(validation_text_file(report)))
310 if save.folder
is None:
312 here = os.path.basename(os.path.dirname(os.path.abspath(path)))
318 if not here
or here.lower() == save.folder.lower()
or len(here) != len(save.folder):
320 if not all(c
in "0123456789abcdefABCDEF" for c
in here):
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.",
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:
337 print(f
"Wrote {path}")
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:
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:
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:
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)
372 with os.fdopen(fd,
"wb")
as f:
376 os.replace(temporary, output)
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:
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,
399 source=(source, original),
400 operations=operations,
407 option, archive, data, save, source=None, operations=None, force=False
410 if os.path.exists(path)
and not force:
411 sys.exit(
"Manifest already exists; choose another path or pass --force.")
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:
420 "format":
"sl2-analyzer archive manifest",
422 "created_utc": datetime.now(timezone.utc).isoformat(),
424 "name": os.path.basename(archive),
426 "sha256": hashlib.sha256(data).hexdigest(),
429 "steam_id64": save.owner[1]
if save.owner
else None,
431 "operations": operations
or [],
433 if source
is not None:
434 manifest[
"source"] = {
435 "name": os.path.basename(source[0]),
436 "sha256": hashlib.sha256(source[1]).hexdigest(),
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)
442 with os.fdopen(fd,
"w", encoding=
"utf-8")
as f:
443 json.dump(manifest, f, ensure_ascii=
False, indent=2)
447 os.replace(temporary, path)
454 print(f
"Wrote archive manifest {path}")
459 return archive +
".archive.json" if option ==
"" else os.path.abspath(option)
466 if os.path.isdir(path):
467 files += find_saves(path)
468 elif os.path.isfile(path):
469 files.append(os.path.abspath(path))
471 sys.exit(f
"No such file or folder: {path}")
473 sys.exit(
"No .sl2 files found to verify.")
475 for path
in sorted(set(files)):
477 with open(path,
"rb")
as f:
479 entries = parse_bnd4(data)
480 bad = [str(e.index)
for e
in entries
if not checksum_ok(data, e)]
482 raise ValueError(
"bad checksum(s): " +
", ".join(bad))
483 save = parse_save(data, base_dir)
485 except (OSError, ValueError, SystemExit)
as exc:
486 failures.append(f
"FAIL {path}: {exc}")
489 f
"OK {path}: {save.cfg['title']}, {len(save.characters)} slot(s), sha256 {hashlib.sha256(data).hexdigest()}"
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.
_save_globs()
Folders a Souls save can live in, per OS.
verify_archive(paths, base_dir)
Read every named archive and report BND4, parse, and ownership status.
warn_foreign_folder(path, save)
Warn when a save is sitting in a folder belonging to a different account.
archive_manifest_path(option, archive)
Resolve an explicit archive sidecar path, or the archive's default neighbor.
write_archive_manifest(option, archive, data, save, source=None, operations=None, force=False)
Write a small, auditable sidecar describing one parsed archive copy.
main()
Program entry point.
auto_find_save()
Find a .sl2 when none was given on the command line.
edit_save(args)
Apply one or both copy-only edits and atomically create the requested .sl2.
print_validation(save, data)
Print each character's validation block to the terminal.