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 json
6import os
7import sys
8
9from validators import run_validation
10from validators.file_rules import run_file_validation
11from validators.text import validation_text, validation_text_file
12
13from .bnd4 import checksum_ok, parse_bnd4
14from .combine import build_combined, find_saves
15from .convert import parse_save, render_markdown
16from .jsonout import build_json, parse_meta
17
18
19## @brief Folders a Souls save can live in, per OS. Each game keeps its `.sl2` in
20# a game-named subfolder, hence the trailing `*/`. Steam/Proton, Heroic, Lutris,
21# and plain Wine all mirror the Windows `%APPDATA%` tree inside a prefix, so the
22# tail of every glob is the same `.../AppData/Roaming/<game>/*.sl2`.
24 globs = ["*.sl2", os.path.join("*", "*.sl2")] # cwd and one level down
25 home = os.path.expanduser("~")
26 appdata = os.environ.get("APPDATA")
27 if appdata: # native Windows
28 globs.append(os.path.join(appdata, "*", "*.sl2"))
29 globs.append(os.path.join(appdata, "*", "*", "*.sl2"))
30 # Most games write %APPDATA%/<game>/<file>.sl2; Sekiro puts a Steam-id folder in
31 # between, so every prefix is searched at both depths.
32 roaming = [
33 "drive_c/users/steamuser/AppData/Roaming/*/*.sl2",
34 "drive_c/users/steamuser/AppData/Roaming/*/*/*.sl2",
35 ]
36 user_roaming = [
37 "drive_c/users/*/AppData/Roaming/*/*.sl2",
38 "drive_c/users/*/AppData/Roaming/*/*/*.sl2",
39 ]
40 # Steam through Proton.
41 for steam in (".local/share/Steam", ".steam/steam", ".steam/root"):
42 for tail in roaming:
43 globs.append(os.path.join(home, steam, "steamapps/compatdata/*/pfx", tail))
44 # Heroic (Epic / GOG) Wine prefixes. Heroic names a prefix after the game, so the
45 # per-game folder is a wildcard too.
46 for heroic in (
47 "Games/Heroic/Prefixes/default/*/pfx",
48 "Games/Heroic/Prefixes/*/pfx",
49 ".config/heroic/prefixes/default/*/pfx",
50 "Games/Heroic/*/pfx",
51 ):
52 for tail in roaming:
53 globs.append(os.path.join(home, heroic, tail))
54 # Lutris and a plain ~/.wine prefix (user-named, not always "steamuser").
55 for tail in user_roaming:
56 globs.append(os.path.join(home, ".local/share/lutris/*/pfx", tail))
57 globs.append(os.path.join(home, ".wine", tail))
58 return globs
59
60
61##
62# @brief Find a `.sl2` when none was given on the command line.
63# @details Globs the current folder and the usual Steam/Proton and Windows save
64# locations, and returns the most recently modified match — the live character is
65# almost always the newest file. Exits with a clear message if nothing is found.
66# @return The path to the chosen save.
68 found = []
69 for pat in _save_globs():
70 found += glob.glob(pat)
71 found = sorted(set(found), key=lambda p: os.path.getmtime(p), reverse=True)
72 if not found:
73 sys.exit(
74 "No .sl2 found in the current folder or the usual save locations. "
75 "Pass the path explicitly: sl2_to_md.py <save.sl2>"
76 )
77 if len(found) > 1:
78 print(f"Auto-detected {len(found)} saves; using the newest: {found[0]}")
79 print(" (pass a path to pick another)")
80 else:
81 print(f"Auto-detected save: {found[0]}")
82 return found[0]
83
84
85##
86# @brief Program entry point.
87# @details Output format follows the -o extension (.json → JSON, anything else →
88# Markdown) unless --format says otherwise, so the common case is one flag, not two.
89# @return None. Writes the chosen file and prints where it went.
90def main():
91 ap = argparse.ArgumentParser(
92 description="FromSoftware .sl2 save -> Markdown or JSON playthrough summary "
93 "(DS PtDE/Remastered, DS2 vanilla/SOTFS, DS3, Sekiro, Elden Ring)",
94 epilog="Metadata example: --meta source=Steam --meta os='Nobara 43' "
95 "--meta launcher=Heroic --meta proton='GE-Proton 9-20' "
96 "--meta dlc='Ashes of Ariandel' --meta dlc='The Ringed City'. "
97 "A key repeated becomes a list. Any key is accepted.",
98 )
99 ap.add_argument(
100 "sl2",
101 nargs="*",
102 help="path to a .sl2 save, or a FOLDER of them (auto-detected if "
103 "omitted). Several paths, or one folder, produce a combined "
104 "playthrough document covering every character found.",
105 )
106 ap.add_argument(
107 "-o",
108 "--out",
109 default="playthrough.md",
110 help="output path; a .json extension selects JSON",
111 )
112 ap.add_argument(
113 "--format",
114 choices=("auto", "md", "json"),
115 default="auto",
116 help="output format (default: from the -o extension)",
117 )
118 ap.add_argument(
119 "--meta",
120 action="append",
121 metavar="KEY=VALUE",
122 help="record how the game was run: store, version, DLC, OS, "
123 "launcher, Proton build, anything. Repeatable; a repeated "
124 "key becomes a list. None of it is read from the save.",
125 )
126 ap.add_argument(
127 "--meta-json",
128 metavar="PATH",
129 help="JSON object of the same metadata, merged underneath --meta",
130 )
131 ap.add_argument(
132 "--indent",
133 type=int,
134 default=2,
135 help="JSON indent; 0 for one dense line (default: 2)",
136 )
137 ap.add_argument(
138 "--validate",
139 action="store_true",
140 help="run the validation pass and add its findings to the output: "
141 "states an unmodified game could not produce, and internal "
142 "contradictions. Off by default, and it never changes what is parsed",
143 )
144 ap.add_argument(
145 "--combined",
146 action="store_true",
147 help="force the combined document even for a single save",
148 )
149 args = ap.parse_args()
150
151 try:
152 meta = parse_meta(args.meta, args.meta_json)
153 except (OSError, ValueError) as exc:
154 sys.exit(str(exc))
155
156 # The db_* folders sit beside the package, not inside it.
157 base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
158
159 # A folder, or more than one path, can only mean the combined document — there is
160 # no single save to summarise. One file still takes the single-save path unless
161 # --combined asks otherwise.
162 paths = list(args.sl2)
163 files = []
164 for p in paths:
165 if os.path.isdir(p):
166 files += find_saves(p)
167 elif os.path.isfile(p):
168 files.append(os.path.abspath(p))
169 else:
170 sys.exit(f"No such file or folder: {p}")
171 if paths and not files:
172 sys.exit("No .sl2 files found in: " + ", ".join(paths))
173
174 if args.combined or len(files) > 1 or any(os.path.isdir(p) for p in paths):
175 text = build_combined(files, base_dir, meta)
176 if text is None:
177 sys.exit("None of those files could be read as a supported save.")
178 write_out(args.out, text)
179 return
180
181 sl2 = files[0] if files else auto_find_save()
182 if not os.path.isfile(sl2):
183 sys.exit(f"No such file: {sl2}")
184 with open(sl2, "rb") as f:
185 data = f.read()
186
187 fmt = args.format
188 if fmt == "auto":
189 fmt = "json" if args.out.lower().endswith(".json") else "md"
190
191 name = os.path.basename(sl2)
192 save = parse_save(data, base_dir)
193 warn_foreign_folder(sl2, save)
194 if fmt == "json":
195 text = json.dumps(
196 build_json(save, name, meta, args.validate, data),
197 ensure_ascii=False,
198 indent=args.indent or None,
199 )
200 text += "\n"
201 else:
202 text = render_markdown(save, name, meta, args.validate, data)
203
204 write_out(args.out, text)
205 if args.validate:
206 print_validation(save, data)
207
208
209##
210# @brief Print each character's validation block to the terminal.
211# @details The findings are already in the document; this is so a run that was only
212# asking "does this save add up" answers without opening the file. Stdout, one block per
213# character, in the shape validators/text.py defines.
214# @param save The parsed @ref sl2.convert.SaveData. @param data The file bytes, for
215# the file-level rules.
216def print_validation(save, data):
217 start = save.cfg["slots"].start
218 for i, ch in save.characters:
219 label = f"Slot {i - start + 1}" + (f": {ch['name']}" if ch.get("name") else "")
220 print()
221 print("\n".join(validation_text(run_validation(ch, save.game), label)))
222 entries = parse_bnd4(data) or []
223 report = run_file_validation(save.game, entries, data, checksum_ok)
224 print()
225 print("\n".join(validation_text_file(report)))
226
227
228##
229# @brief Warn when a save is sitting in a folder belonging to a different account.
230# @details DS3, Sekiro and Elden Ring write the owning account into the save, and the
231# game only reads a save back from the folder named for that account — so a save whose
232# account id changed underneath it (a Steam emulator reconfigured, a different profile)
233# will not load, and the game says nothing useful about why. Comparing the two is free
234# once both are known.
235# @note Deliberately printed to STDERR and never into the document: the browser cannot
236# see a dropped file's folder, and the two front ends have to stay byte-identical.
237# @param path The save file's path. @param save The parsed @ref sl2.convert.SaveData.
238def warn_foreign_folder(path, save):
239 if save.folder is None:
240 return
241 here = os.path.basename(os.path.dirname(os.path.abspath(path)))
242 # Only complain when the folder is clearly one of the game's own account folders:
243 # same width as the real thing and made of the right digits. A save copied into a
244 # working directory is not a mismatch, it is just somewhere else — and the width
245 # test is what stops an ordinary folder that happens to spell hex ("beef", "decade")
246 # from being read as somebody's account.
247 if not here or here.lower() == save.folder.lower() or len(here) != len(save.folder):
248 return
249 if not all(c in "0123456789abcdefABCDEF" for c in here):
250 return
251 print(
252 f"Warning: this save was written by Steam account {save.owner[0]} and the "
253 f"game will only load it from a folder named '{save.folder}', but it is in "
254 f"'{here}'. Under the account that owns '{here}' the game will not see it.",
255 file=sys.stderr,
256 )
257
258
259## @brief Write the document, making the output folder if it does not exist yet.
260def write_out(path, text):
261 out_dir = os.path.dirname(os.path.abspath(path))
262 if out_dir and not os.path.isdir(out_dir):
263 os.makedirs(out_dir, exist_ok=True)
264 with open(path, "w", encoding="utf-8") as f:
265 f.write(text)
266 print(f"Wrote {path}")
write_out(path, text)
Write the document, making the output folder if it does not exist yet.
Definition cli.py:260
_save_globs()
Folders a Souls save can live in, per OS.
Definition cli.py:23
warn_foreign_folder(path, save)
Warn when a save is sitting in a folder belonging to a different account.
Definition cli.py:238
main()
Program entry point.
Definition cli.py:90
auto_find_save()
Find a .sl2 when none was given on the command line.
Definition cli.py:67
print_validation(save, data)
Print each character's validation block to the terminal.
Definition cli.py:216