SL2 Analyzer
Read a FromSoftware .sl2 save and report what is in it
Loading...
Searching...
No Matches
combine.py
Go to the documentation of this file.
1"""Read a FOLDER of saves and write one document covering every run in it.
2
3The single-save export answers "what is in this file". This answers "what have I
4played" — drop a directory holding Dark Souls, Dark Souls II, Dark Souls III and
5Elden Ring backups together and it sorts them into runs, reconstructs each run's
6history from its backups, and writes one Markdown file with a cross-game journey
7chart on top, a branch chart per run, and every source file numbered in a reference
8list at the end.
9
10Nothing here is filename-driven. Which game a file is comes from its header, which
11character a save belongs to comes from the save, and the order comes from the game's
12own play-time clock (falling back to the file date where a game does not store one).
13Backups can be named anything.
14"""
15
16import os
17from collections import OrderedDict
18from datetime import datetime
19
20from .chart import (
21 hms,
22 journey_chart,
23 plural,
24 rank,
25 rank_cell,
26 rank_label,
27 reference_list,
28 run_chart,
29)
30from .convert import GAMES, META_LABEL, parse_save
31from .render import md_for_character
32from .timeline import (
33 build_tree,
34 carried_only,
35 carry_bosses,
36 first_seen,
37 fork_count,
38 group_runs,
39 reference_index,
40 snapshot,
41)
42
43## @brief Evidence tags, spelled out. Same words the single-save export uses.
44SRC = {
45 "flag": "confirmed",
46 "soul": "soul held",
47 "gate": "progression",
48 "clear": "cleared (NG+)",
49}
50
51
52##
53# @brief Every .sl2 under @p folder, recursively.
54# @details Case-insensitive, because Windows writes DS30000.sl2 and some tools write
55# .SL2, and sorted so a run of the tool twice over the same folder reads the same.
56def find_saves(folder):
57 out = []
58 for root, _dirs, files in os.walk(os.path.abspath(folder)):
59 out += [os.path.join(root, f) for f in files if f.lower().endswith(".sl2")]
60 return sorted(out)
61
62
63##
64# @brief Parse one file into a snapshot per populated character.
65# @details A save that will not parse is SKIPPED, not fatal: a folder of backups
66# collected over years will contain a truncated copy sooner or later, and losing the
67# whole document to one bad file would be the wrong trade. Vanilla Dark Souls II
68# raises SystemExit from the detector, which is caught for the same reason.
69def read_file(path, base_dir):
70 try:
71 with open(path, "rb") as f:
72 save = parse_save(f.read(), base_dir)
73 except (OSError, ValueError, SystemExit):
74 return []
75 cfg = GAMES[save.game]
76 start = cfg["slots"].start
77 return [
78 snapshot(ch, path, i - start + 1, save.game, cfg["title"])
79 for i, ch in save.characters
80 ]
81
82
83##
84# @brief The one-line summary under a run's heading.
85# @param carried The newest snapshot's carried boss set, from @ref carry_bosses.
86def run_summary(rows, carried=None):
87 first, last = rows[0], rows[-1]
88 bits = [
89 f"{len(rows)} save{'' if len(rows) == 1 else 's'}",
90 f"{rank(first)} → {rank(last)}",
91 ]
92 if last["play_time"]:
93 bits.append(f"{hms(first['play_time'])} → {hms(last['play_time'])} played")
94 if last["bonfires"]:
95 bits.append(plural(len(last["bonfires"]), "bonfire"))
96 known = carried if carried else last["bosses"]
97 if known:
98 extra = len(known) - len(last["bosses"])
99 bits.append(
100 plural(len(known), "boss")
101 + (
102 f" ({len(last['bosses'])} still provable in the newest save, "
103 f"{extra} carried from earlier)"
104 if extra
105 else ""
106 )
107 )
108 if last["pickups"]:
109 bits.append(
110 f"{sum(last['pickups'].values())} of {last['pickup_total']} world items"
111 )
112 if last["endings"]:
113 bits.append("finished: " + " · ".join(sorted(last["endings"])))
114 return "_" + " · ".join(bits) + "._"
115
116
117##
118# @brief A run's timeline tables — the "when did this first appear" half.
119# @details Every section is skipped when the game has nothing to put in it, so a Dark
120# Souls run does not print an empty Covenants table and an Elden Ring one does not
121# print bonfires it cannot read. The tables walk snapshots in order regardless of
122# branch, which is stated in the document rather than hidden.
123def run_timeline(rows, refs):
124 L = []
125
126 def ref(r):
127 return f"^{refs.get(r['path'], '?')}"
128
129 lv = rank_label(rows[0]["game"]) if rows else "Lv"
130
131 bosses = first_seen(rows, lambda r: r["bosses"])
132 if bosses:
133 L += [
134 "#### Bosses — first appearance",
135 "",
136 f"| Play Time | {lv} | Boss | Evidence | Save |",
137 "|---|---|---|---|---|",
138 ]
139 for boss, r in bosses:
140 ev = ", ".join(SRC.get(e, e) for e in sorted(r["bosses"][boss]))
141 L.append(
142 f"| {hms(r['play_time'])} | {rank_cell(r)} | {boss} | {ev} | {ref(r)} |"
143 )
144 L.append("")
145
146 covs = first_seen(rows, lambda r: r["covenants"])
147 if covs:
148 L += [
149 "#### Covenants — first found",
150 "",
151 f"| Play Time | {lv} | Covenant | Progress | Save |",
152 "|---|---|---|---|---|",
153 ]
154 for cov, r in covs:
155 L.append(
156 f"| {hms(r['play_time'])} | {rank_cell(r)} | {cov} | "
157 f"{', '.join(r['covenants'][cov])} | {ref(r)} |"
158 )
159 L.append("")
160
161 rewards = first_seen(
162 rows, lambda r: [(q, rw) for q, v in r["questlines"].items() for rw in v]
163 )
164 if rewards:
165 L += [
166 "#### Rewards — first obtained",
167 "",
168 "_A floor: only rewards actually collected are visible._",
169 "",
170 f"| Play Time | {lv} | Source | Reward | Save |",
171 "|---|---|---|---|---|",
172 ]
173 for pair, r in rewards:
174 L.append(
175 f"| {hms(r['play_time'])} | {rank_cell(r)} | {pair[0]} | {pair[1]} "
176 f"| {ref(r)} |"
177 )
178 L.append("")
179
180 fires = first_seen(rows, lambda r: [tuple(b) for b in r["bonfires"]])
181 if fires:
182 L += ["#### Bonfires — first lit", ""]
183 seen, total = set(), 0
184 for r in rows:
185 new = [tuple(b) for b in r["bonfires"] if tuple(b) not in seen]
186 if not new:
187 continue
188 seen.update(new)
189 total += len(new)
190 L.append(
191 f"**{hms(r['play_time'])} · {rank(r)} · {ref(r)}** — "
192 f"{total} total (+{len(new)})"
193 )
194 L += ["", *[f"- {a}: {n}" if a else f"- {n}" for a, n in sorted(new)], ""]
195
196 est = [
197 r
198 for i, r in enumerate(rows)
199 if r["estus"] is not None and (i == 0 or r["estus"] != rows[i - 1]["estus"])
200 ]
201 if len(est) > 1:
202 L += [
203 "#### Estus — reinforcement",
204 "",
205 "_Each step is one Undead Bone Shard burned. The level is stored in the "
206 "flask's own item id, so this is read, not inferred._",
207 "",
208 f"| Play Time | {lv} | Estus | Save |",
209 "|---|---|---|---|",
210 ]
211 for r in est:
212 L.append(
213 f"| {hms(r['play_time'])} | {rank_cell(r)} | +{r['estus']} | {ref(r)} |"
214 )
215 L.append("")
216
217 if any(r["pickups"] for r in rows):
218 L += [
219 "#### World items — where the count moved",
220 "",
221 "_Only the areas whose pickup-flag group is mapped are counted, so an area "
222 "absent here is unmapped, not empty._",
223 "",
224 ]
225 prev = {}
226 for r in rows:
227 gained = [
228 (a, c - prev.get(a, 0))
229 for a, c in sorted(r["pickups"].items())
230 if c > prev.get(a, 0)
231 ]
232 if not gained:
233 continue
234 L.append(
235 f"**{hms(r['play_time'])} · {rank(r)} · {ref(r)}** — "
236 f"{sum(r['pickups'].values())} total "
237 f"(+{sum(n for _a, n in gained)})"
238 )
239 L += ["", *[f"- {a}: +{n} (now {r['pickups'][a]})" for a, n in gained], ""]
240 prev = r["pickups"]
241 return L
242
243
244##
245# @brief One run's whole section: chart, current state, timeline.
246# @details The newest snapshot is re-parsed so its FULL dump can be printed — the
247# timeline knows what changed but not the character's inventory, and re-reading one
248# file is cheaper than carrying every field of every backup through the walk.
249def run_section(key, rows, refs, base_dir):
250 game, name, _slot = key
251 last = rows[-1]
252 parents, restarts = build_tree(rows)
253 forks = fork_count(parents)
254 carried = carry_bosses(rows, parents)
255 for row, got in zip(rows, carried):
256 row["carried_bosses"] = {b: ev for b, (ev, _at) in got.items()}
257
258 L = [f"## {last['title']} — {name}", "", run_summary(rows, carried[-1]), ""]
259 L += ["### Save Tree", ""]
260 L.append(
261 "_Each box is one save file, numbered as in the references at the end. A "
262 "snapshot's parent is the latest earlier one whose progress it still "
263 "entirely contains — event flags never clear, so a fork (the same save "
264 "played on twice) lands both children on the shared ancestor._"
265 )
266 L.append("")
267 note = [
268 f"{forks} fork{'' if forks == 1 else 's'}" if forks else "No forks",
269 f"{len(restarts)} separate line{'' if len(restarts) == 1 else 's'}"
270 if restarts
271 else None,
272 "a dashed box is where a line stopped",
273 ]
274 L.append("_" + ", ".join(n for n in note if n) + "._")
275 if restarts:
276 L.append("")
277 L.append(
278 "_A box marked SEPARATE LINE could not descend from anything before it "
279 "— it holds less progress than saves that came earlier, so it belongs to "
280 "a different playthrough that happens to share this character's name and "
281 "slot._"
282 )
283 L.append("")
284 L += run_chart(rows, parents, restarts, refs, game)
285 L.append("")
286
287 L += [f"### Current State — `{last['file']}` (^{refs.get(last['path'], '?')})", ""]
288 try:
289 with open(last["path"], "rb") as f:
290 save = parse_save(f.read(), base_dir)
291 start = GAMES[save.game]["slots"].start
292 for i, ch in save.characters:
293 # Matched on SLOT, not name: an all-characters mule holds several unnamed
294 # slots, and matching by name would render the same one under each of them.
295 if i - start + 1 == last["slot"]:
296 # Demote every heading one level: the dump's own "## Slot 1" has to sit
297 # under this run's "##", not beside it.
298 L += [
299 ln if not ln.startswith("#") else "#" + ln
300 for ln in md_for_character(ch, last["slot"]).split("\n")
301 ]
302 break
303 except (OSError, ValueError, SystemExit):
304 L.append("_The newest save could not be re-read for a full dump._")
305 L.append("")
306
307 lost = carried_only(last, carried[-1])
308 if lost:
309 L += [
310 "### Bosses Carried Forward",
311 "",
312 "_Proven by an EARLIER save on this line and not by the newest one. A held "
313 "boss soul is proof of a kill, and spending the soul destroys the proof — "
314 "but a kill is permanent, so the evidence stands. Only this save's own "
315 "ancestors count; a boss killed on a different branch was never killed "
316 "here._",
317 "",
318 "| Boss | Evidence | Proven in | Play Time |",
319 "|---|---|---|---|",
320 ]
321 for boss, ev, at in lost:
322 src = rows[at]
323 L.append(
324 f"| {boss} | {', '.join(SRC.get(e, e) for e in ev)} | "
325 f"^{refs.get(src['path'], '?')} | {hms(src['play_time'])} |"
326 )
327 L.append("")
328
329 tl = run_timeline(rows, refs)
330 if tl:
331 L += ["### Timeline", ""] + tl
332 return L
333
334
335##
336# @brief Build the whole combined document.
337# @param folder A directory to walk, or a list of files already found.
338# @param base_dir Repo root, for the item databases.
339# @param meta The environment block from parse_meta, or None.
340# @return The Markdown, as one string.
341def build_combined(folder, base_dir, meta=None):
342 paths = folder if isinstance(folder, list) else find_saves(folder)
343 snaps = []
344 for p in paths:
345 snaps += read_file(p, base_dir)
346 if not snaps:
347 return None
348
349 runs = group_runs(snaps)
350 refs, order = reference_index(snaps)
351 # The carry has to happen before the journey chart, not inside the run sections:
352 # the chart is drawn first and would otherwise report the newest save's own count
353 # while the section below it reports the carried one.
354 for rows in runs.values():
355 parents, _restarts = build_tree(rows)
356 for row, got in zip(rows, carry_bosses(rows, parents)):
357 row["carried_bosses"] = {b: ev for b, (ev, _at) in got.items()}
358 games = OrderedDict(
359 (s["title"], None) for s in sorted(snaps, key=lambda s: s["mtime"])
360 )
361
362 L = [
363 "# FromSoftware Saves — Combined Playthrough Timeline",
364 "",
365 f"_Reconstructed from {len(order)} save file{'' if len(order) == 1 else 's'} "
366 f"across {len(runs)} run{'' if len(runs) == 1 else 's'} and "
367 f"{len(games)} game{'' if len(games) == 1 else 's'}: "
368 + " · ".join(games)
369 + "._",
370 "",
371 "_Every timestamp is an UPPER BOUND, not the moment it happened: a thing is "
372 "dated to the first save it appears in, so the real event is somewhere between "
373 "the previous save and that one. This is a reconstruction from sparse backups, "
374 "not a log._",
375 "",
376 "---",
377 "",
378 "## The Journey",
379 "",
380 "_One box per character, in the order the files were last written — the only "
381 "clock the games share, since a Dark Souls II play time and a Dark Souls III "
382 "one are unrelated numbers._",
383 "",
384 ]
385 L += journey_chart(runs, refs)
386 L += ["", "---", ""]
387
388 for key, rows in runs.items():
389 L += run_section(key, rows, refs, base_dir) + ["", "---", ""]
390
391 L += reference_list(order)
392
393 L += combined_footer(snaps, runs, meta)
394 return "\n".join(L) + "\n"
395
396
397##
398# @brief The closing block: which games this document covers, how far to trust each,
399# and any setup the caller supplied.
400# @details The single-save footer names one game because a save IS one game; a combined
401# document spans several, each with its own support tier, so it lists them rather than
402# picking one and quietly misreporting the rest.
403def combined_footer(snaps, runs, meta):
404 seen = OrderedDict()
405 for s in sorted(snaps, key=lambda s: s["mtime"]):
406 seen.setdefault(s["game"], s["title"])
407 L = [
408 "---",
409 "",
410 "<details>",
411 "<summary>About this file — how it was produced, and how far to trust it"
412 "</summary>",
413 "",
414 f"- **Save files read:** {len({s['path'] for s in snaps})}",
415 f"- **Runs (characters):** {len(runs)}",
416 "",
417 "**Games covered**",
418 "",
419 ]
420 for game, title in seen.items():
421 L.append(f"- **{title}:** support tier {GAMES[game]['tier']}")
422 if meta:
423 L += [
424 "",
425 "**Setup** _(supplied by the caller — not read from the saves, "
426 "which cannot know any of it)_",
427 "",
428 ]
429 for key, value in meta.items():
430 lab = META_LABEL.get(key) or key.replace("_", " ").capitalize()
431 shown = (
432 " · ".join(str(v) for v in value) if isinstance(value, list) else value
433 )
434 L.append(f"- **{lab}:** {shown}")
435 L += [
436 "",
437 "Everything above is read out of the saves themselves, in this browser or "
438 "on this machine — nothing is uploaded. A field the tool cannot verify is "
439 "left out rather than guessed, and every progress section is a FLOOR: it "
440 "reports what the saves prove, never what they merely suggest.",
441 "",
442 f"_Generated {datetime.now():%Y-%m-%d %H:%M} by sl2-analyzer._",
443 "",
444 "</details>",
445 "",
446 ]
447 return L
run_section(key, rows, refs, base_dir)
One run's whole section: chart, current state, timeline.
Definition combine.py:249
run_timeline(rows, refs)
A run's timeline tables — the "when did this first appear" half.
Definition combine.py:123
run_summary(rows, carried=None)
The one-line summary under a run's heading.
Definition combine.py:86
combined_footer(snaps, runs, meta)
The closing block: which games this document covers, how far to trust each, and any setup the caller ...
Definition combine.py:403
read_file(path, base_dir)
Parse one file into a snapshot per populated character.
Definition combine.py:69