SL2 Analyzer
Read a FromSoftware .sl2 save and report what is in it
Loading...
Searching...
No Matches
timeline.py
Go to the documentation of this file.
1"""Turn a pile of saves into runs, and each run into a tree of snapshots.
2
3A folder of backups is not a list. Backups sorted by time LOOK linear, but reloading
4an earlier save and playing on forks the run — the four Dark Souls III endings are
5exactly that, one pre-ending save finished four different ways. This module works out
6which snapshot descends from which, using the one thing every game in the series
7guarantees: event flags never clear. If a save has a bonfire lit, every save after it
8on the same line has that bonfire lit too, so a snapshot's parent is the latest
9earlier one whose progress it still entirely contains — a sibling branch holds a flag
10this one lacks, fails that test, and both land on the shared ancestor.
11
12Nothing here renders. It reads parsed characters and returns data; sl2.chart draws it
13and sl2.combine writes the document, so the inference can be tested without a document
14and the document can change without touching the inference.
15"""
16
17import os
18import re
19from collections import OrderedDict
20
21##
22# @brief The Estus Flask's reinforcement level, or None if this character holds none.
23# @details Not a stored field. DS3 keeps the flask's level IN its goods id (two ids per
24# level), so the parser resolves it to a name and the level rides in that name. Undead
25# Bone Shards are the only thing that moves it, which is why it earns a timeline row.
26# A @c +0 flask has no suffix, hence the optional group.
27ESTUS_RE = re.compile(r"^Estus Flask(?: \+(\d+))?$")
28
29
31 for name, _qty in (ch.get("inv") or {}).get("consumables", []):
32 mt = ESTUS_RE.match(name)
33 if mt:
34 return int(mt.group(1) or 0)
35 return None
36
37
38##
39# @brief Flatten one parsed character into the fields a timeline needs.
40# @details Bonfires are kept as (area, name) pairs so two areas sharing a bonfire name
41# cannot collide in a first-seen set, and so every part of the document prints them the
42# same way. Games that do not have a field simply do not get it — a DS1 character has
43# no pickups and an Elden Ring one has no bonfires, and both are fine here.
44# @param ch A parsed character dict.
45# @param path The file it came from.
46# @param slot_no Its 1-based slot number.
47# @param game The game id, @param title that game's display name.
48# @return A snapshot dict.
49def snapshot(ch, path, slot_no, game, title):
50 areas = ch.get("bonfire_areas") or []
51 bonfires = [(a, n) for a, _c, names, _t, _m in areas for n in names]
52 if not bonfires and ch.get("bonfires"):
53 bonfires = [(None, n) for n in ch["bonfires"]]
54 st = os.stat(path)
55 return {
56 "path": path,
57 "file": os.path.basename(path),
58 "mtime": int(st.st_mtime),
59 "size": st.st_size,
60 "game": game,
61 "title": title,
62 "slot": slot_no,
63 "name": ch.get("name") or "?",
64 "tier": ch.get("tier"),
65 "play_time": ch.get("play_time") or 0,
66 "level": ch.get("level") or 0,
67 "souls": ch.get("souls") or 0,
68 "soul_memory": ch.get("soul_memory"),
69 "deaths": ch.get("deaths"),
70 "hollow_lvl": ch.get("hollow_lvl"),
71 "embered": ch.get("embered"),
72 "covenant": ch.get("covenant"),
73 "ng_plus": ch.get("ng_plus"),
74 "attack": ch.get("attack"),
75 "vitality": ch.get("vitality"),
76 "gourd": ch.get("gourd"),
77 "key_items": sorted({n for n, _q in (ch.get("key_items") or [])}),
78 "estus": estus_level(ch),
79 "bonfires": bonfires,
80 "bosses": {b: list(ev) for b, ev in (ch.get("bosses") or {}).items()},
81 "covenants": {c: list(v) for c, v in (ch.get("covenants") or {}).items()},
82 "questlines": {q: list(v) for q, v in (ch.get("questlines") or {}).items()},
83 "pickups": {a: c for a, c, _t, _m in (ch.get("pickups") or [])},
84 "pickup_total": sum(t for _a, _c, t, _m in (ch.get("pickups") or [])),
85 "endings": list(ch.get("endings") or []),
86 "cinders": list(ch.get("cinders") or []),
87 "boss_total": ch.get("boss_total"),
88 }
89
90
91##
92# @brief Group snapshots into RUNS — one per character, across every file that holds it.
93# @details The key is (game, character name, slot), not the file: a run is spread over
94# dozens of backups, and one backup can hold several characters. The slot is in the key
95# because an all-characters mule really does hold ten slots called the same thing, and
96# merging those into one run would invent a history none of them had. A character that
97# was moved to a different slot splits instead, which is the rarer mistake.
98#
99# Ordering inside a run is by PLAY TIME, the game's own clock, falling back to the file
100# date where a game does not store it (Elden Ring). That matters: file dates reorder
101# when saves are copied around, play time does not.
102# @return OrderedDict {(game, name): [snapshot, ...]}, runs ordered by first appearance.
103def group_runs(snaps):
104 runs = OrderedDict()
105 for s in snaps:
106 runs.setdefault((s["game"], s["name"], s["slot"]), []).append(s)
107 for key in runs:
108 runs[key].sort(key=lambda s: (s["play_time"], s["mtime"], s["file"], s["slot"]))
109 return OrderedDict(
110 sorted(runs.items(), key=lambda kv: min(s["mtime"] for s in kv[1]))
111 )
112
113
114## @brief Boss kills that came from a FLAG, which is the only boss evidence that
115# cannot go backwards. A boss known by its held soul disappears the moment the soul is
116# consumed, and one inferred through a progression gate goes with it, so counting
117# those as progress would fork the tree at every boss-soul spend.
119 return {b for b, ev in s["bosses"].items() if "flag" in ev or "clear" in ev}
120
121
122##
123# @brief The monotone progress a snapshot holds — the things that only ever grow.
124# @details This is the whole basis for reconstructing lineage, so it contains only
125# one-way signals. Souls are spent, a covenant is switched, embered is consumed and
126# hollowing goes down with an effigy: any one of those would fork the tree on every
127# death. Level, Estus and the flag-backed sets only ever climb.
128#
129# Sekiro's Attack Power is the same kind of signal and it is the ONLY one that game
130# offers: it has no level, no bonfires and no flags the tool can read, so without it a
131# Sekiro run would have nothing to reconstruct a lineage from. A Memory consumed is
132# never un-consumed, not even by a New Game+ lap.
133def progress(s):
134 return {
135 "bonfires": {tuple(b) for b in s["bonfires"]},
136 "bosses": flag_bosses(s),
137 "endings": set(s["endings"]),
138 "cinders": set(s["cinders"]),
139 "covenants": set(s["covenants"]),
140 "pickups": s["pickups"],
141 "level": s["level"],
142 "attack": s["attack"] if s["attack"] is not None else -1,
143 "vitality": s["vitality"] if s["vitality"] is not None else -1,
144 "gourd": s["gourd"] if s["gourd"] is not None else -1,
145 "estus": s["estus"] if s["estus"] is not None else -1,
146 "ng_plus": s["ng_plus"] if s["ng_plus"] is not None else -1,
147 }
148
149
150## @brief Progress that a New Game+ lap wipes — the per-map flags. Bonfires go out,
151# world pickups reset, the thrones empty, and the per-map boss flags clear (the
152# cumulative victory flags do not, but a merged boss set cannot tell the two apart, so
153# the whole set is treated as resettable rather than risk a false fork).
154RESETTABLE = ("bonfires", "bosses", "cinders", "covenants")
155
156
157##
158# @brief Could @p b be a continuation of @p a — is everything a had still in b?
159# @details New Game+ is the one place a real continuation LOSES progress, so a journey
160# bump waives the flags a lap resets. Endings are NOT waived: they accumulate across
161# journeys, which is exactly what makes them the thing that separates two saves finished
162# different ways from the same parent. Level and Estus never reset either.
163def descends(a, b):
164 pa, pb = progress(a), progress(b)
165 if pa["ng_plus"] > pb["ng_plus"]:
166 return False
167 if not pa["endings"] <= pb["endings"]:
168 return False
169 if pa["level"] > pb["level"] or pa["estus"] > pb["estus"]:
170 return False
171 if pa["attack"] > pb["attack"] or pa["vitality"] > pb["vitality"]:
172 return False
173 if pa["gourd"] > pb["gourd"]:
174 return False
175 if pb["ng_plus"] > pa["ng_plus"]:
176 return True
177 for k in RESETTABLE:
178 if not pa[k] <= pb[k]:
179 return False
180 return all(n <= pb["pickups"].get(area, 0) for area, n in pa["pickups"].items())
181
182
183##
184# @brief Work out each snapshot's parent, turning a run into a forest.
185# @details A snapshot's parent is the LATEST earlier one it still contains, so a
186# sibling branch is skipped over and both forks land on the shared ancestor.
187#
188# When NOTHING earlier qualifies, the snapshot becomes a root of its own rather than
189# being hung off whatever happened to precede it. That case is real and it is not an
190# error: two characters with the same name in the same slot look like one run here, and
191# a save that lost progress cannot be a continuation of anything before it. Drawing it
192# as a second tree says "these are not the same line", which is what the data says; an
193# edge would claim a descent that the flags refute.
194# @param rows Snapshots of one run, in order.
195# @return (parent index or None per row, indices of roots after the first).
196def build_tree(rows):
197 parents, restarts = [], set()
198 for i, r in enumerate(rows):
199 best = None
200 for j in range(i - 1, -1, -1):
201 if descends(rows[j], r):
202 best = j
203 break
204 if best is None and i > 0:
205 restarts.add(i)
206 parents.append(best)
207 return parents, restarts
208
209
210##
211# @brief Carry every boss kill forward down each line of descent.
212# @details A single save is a floor and it can only fall: the held-soul evidence that
213# proves a kill DISAPPEARS the moment the soul is spent, so a later save reports fewer
214# bosses than an earlier one on the same run. That is honest for one file — the save
215# genuinely no longer proves it — but a document that has both files in front of it and
216# still says "no evidence" is throwing away what it was given. A kill is permanent, so
217# a boss proven at any ancestor is proven here.
218#
219# ANCESTORS, not "every earlier snapshot": a sibling branch is a different line, and a
220# boss killed there was never killed on this one. This is exactly the case the DS3
221# endings make real.
222# @param rows Snapshots of one run, @param parents from @ref build_tree.
223# @return [{boss: (sorted evidence, index of the snapshot it was proven in)}, ...].
224def carry_bosses(rows, parents):
225 out = []
226 for i, r in enumerate(rows):
227 got = dict(out[parents[i]]) if parents[i] is not None else {}
228 for boss, ev in r["bosses"].items():
229 # The current save's own evidence always wins: it is the one still standing.
230 got[boss] = (sorted(ev), i)
231 out.append(got)
232 return out
233
234
235##
236# @brief The bosses a snapshot can only prove through an ancestor, newest first.
237# @return [(boss, evidence, index of the snapshot that proved it)].
238def carried_only(row, carried):
239 return sorted(
240 ((b, ev, at) for b, (ev, at) in carried.items() if b not in row["bosses"]),
241 key=lambda t: (t[2], t[0]),
242 )
243
244
245##
246# @brief Children of each node, in order. @return {parent index: [child index, ...]}.
247def children(parents):
248 kids = {}
249 for i, p in enumerate(parents):
250 if p is not None:
251 kids.setdefault(p, []).append(i)
252 return kids
253
254
255##
256# @brief How many snapshots in this run have more than one child — the fork count.
257def fork_count(parents):
258 return sum(1 for v in children(parents).values() if len(v) > 1)
259
260
261##
262# @brief What this snapshot achieved that its parent had not — the node's headline.
263# @details Ordered by how much it means, and capped, because a node has to stay
264# readable: an ending outranks a boss, a boss outranks a bonfire, and "+3 bonfires"
265# outranks a level-up. A snapshot that achieved nothing returns [], which is the honest
266# answer for the many backups taken minutes apart.
267# @param cur The snapshot, @param prev its parent (or None for the first).
268# @param cap Most lines to return.
269# @return A list of short strings.
270def achievements(cur, prev, cap=3):
271 was = (
272 progress(prev)
273 if prev
274 else {
275 "bonfires": set(),
276 "bosses": set(),
277 "endings": set(),
278 "cinders": set(),
279 "covenants": set(),
280 "pickups": {},
281 "level": 0,
282 "attack": -1,
283 "vitality": -1,
284 "gourd": -1,
285 "estus": -1,
286 "ng_plus": -1,
287 }
288 )
289 out = []
290 for end in sorted(set(cur["endings"]) - was["endings"]):
291 out.append(f"ENDING: {end}")
292 if prev and (cur["ng_plus"] or 0) > (prev["ng_plus"] or 0):
293 out.append(f"NEW JOURNEY: NG+{cur['ng_plus']}")
294 # Bosses are compared on the WHOLE set here, not the flag-only one containment
295 # uses: a kill proven by a held soul is still news worth putting in the box, and
296 # the two sides of the subtraction have to be the same kind of set or every node
297 # claims the entire roster.
298 had = set(prev["bosses"]) if prev else set()
299 new_bosses = sorted(set(cur["bosses"]) - had)
300 if new_bosses:
301 out.append(
302 "BOSS: "
303 + " · ".join(new_bosses[:2])
304 + (f" +{len(new_bosses) - 2} more" if len(new_bosses) > 2 else "")
305 )
306 # Sekiro's version of the same news, and the only one it can give: Attack Power
307 # goes up by one per Memory consumed, so a step here IS a boss whose token has
308 # already been spent — the kill the boss list can no longer see.
309 if cur["attack"] is not None and cur["attack"] > was["attack"] >= 0:
310 out.append(f"MEMORY SPENT: attack {was['attack']} → {cur['attack']}")
311 # And the other Sekiro token that leaves no trace once used: a Prayer Necklace is
312 # consumed, so only Vitality remembers it.
313 if cur["vitality"] is not None and cur["vitality"] > was["vitality"] >= 0:
314 out.append(f"NECKLACE USED: vitality {was['vitality']} → {cur['vitality']}")
315 # The third of them. A Gourd Seed vanishes the moment Emma takes it, so the gourd's
316 # own level is the only thing that records the upgrade ever happened.
317 if cur["gourd"] is not None and cur["gourd"] > was["gourd"] >= 0:
318 out.append(f"GOURD SEED USED: gourd {was['gourd']} → {cur['gourd']}")
319 # Key items are the one per-save delta Sekiro can show besides its three counters —
320 # it has no bonfires and no readable flags, so without this its nodes carry nothing
321 # but "atk 1". Deliberately NOT part of the containment test: a few key items are
322 # consumed on use, so a run can legitimately hold fewer than an ancestor did.
323 new_keys = sorted(set(cur["key_items"]) - set(prev["key_items"] if prev else []))
324 if new_keys:
325 out.append(
326 "KEY ITEM: "
327 + " · ".join(new_keys[:2])
328 + (f" +{len(new_keys) - 2} more" if len(new_keys) > 2 else "")
329 )
330 new_cinders = sorted(set(cur["cinders"]) - was["cinders"])
331 if new_cinders:
332 out.append("CINDERS: " + " · ".join(new_cinders))
333 new_covs = sorted(set(cur["covenants"]) - was["covenants"])
334 if new_covs:
335 out.append("COVENANT: " + " · ".join(new_covs[:2]))
336 new_fires = sorted(progress(cur)["bonfires"] - was["bonfires"])
337 if new_fires:
338 named = " · ".join(n for _a, n in new_fires[:2])
339 out.append(
340 f"+{len(new_fires)} bonfire{'' if len(new_fires) == 1 else 's'}: {named}"
341 if len(new_fires) <= 2
342 else f"+{len(new_fires)} bonfires"
343 )
344 if cur["estus"] is not None and cur["estus"] > was["estus"] >= 0:
345 out.append(f"Estus +{was['estus']} → +{cur['estus']}")
346 gained = sum(
347 max(0, n - was["pickups"].get(a, 0)) for a, n in cur["pickups"].items()
348 )
349 if gained:
350 out.append(f"+{gained} world item{'' if gained == 1 else 's'}")
351 if cur["level"] > was["level"]:
352 out.append(
353 f"lv{was['level']} → lv{cur['level']}" if prev else f"lv{cur['level']}"
354 )
355 return out[:cap]
356
357
358##
359# @brief Number every file in the whole document, earliest to latest by FILE DATE.
360# @details The reference list is what lets a node say "^12" instead of carrying a
361# 40-character filename, and it is ordered by the file's modified time rather than play
362# time because it spans games — the point of the ordering is "what did you play, in
363# what order", which only the file date can answer.
364#
365# Keyed by full PATH, never by name: every game writes to a fixed filename, so a folder
366# of backups is full of files all called DS30000.sl2 and a name-keyed index would
367# collapse them into one reference. Where the names really do collide the list shows
368# enough of each path to tell them apart, and where they do not it stays short.
369# @param snaps Every snapshot in the document.
370# @return ({path: number}, [(number, label, mtime, path), ...] in order).
371def reference_index(snaps):
372 seen = {}
373 for s in snaps:
374 if s["path"] not in seen or s["mtime"] < seen[s["path"]]:
375 seen[s["path"]] = s["mtime"]
376 order = sorted(seen.items(), key=lambda kv: (kv[1], kv[0]))
377 refs = {p: i + 1 for i, (p, _t) in enumerate(order)}
378 dupes = {}
379 for p, _t in order:
380 dupes[os.path.basename(p)] = dupes.get(os.path.basename(p), 0) + 1
381 # Paths are absolute by the time they get here, but a caller could still hand in a
382 # single file or a mix, and commonpath raises rather than coping. A missing root
383 # only means a colliding name shows its whole path, which is still unambiguous.
384 try:
385 root = os.path.commonpath([p for p, _t in order]) if len(order) > 1 else ""
386 except ValueError:
387 root = ""
388 if root and not os.path.isdir(root):
389 root = os.path.dirname(root)
390 out = []
391 for p, t in order:
392 base = os.path.basename(p)
393 label = (
394 base
395 if dupes.get(base, 0) < 2
396 else (os.path.relpath(p, root) if root else p)
397 )
398 out.append((refs[p], label, t, p))
399 return refs, out
400
401
402##
403# @brief Generic first-seen walk: the earliest snapshot each item appears in.
404# @param get Pulls the iterable of items from a snapshot.
405# @return [(item, snapshot), ...] in the order the items first showed up.
406def first_seen(rows, get):
407 seen, out = set(), []
408 for r in rows:
409 for item in get(r):
410 if item not in seen:
411 seen.add(item)
412 out.append((item, r))
413 return out
descends(a, b)
Could b be a continuation of a — is everything a had still in b?
Definition timeline.py:163
flag_bosses(s)
Boss kills that came from a FLAG, which is the only boss evidence that cannot go backwards.
Definition timeline.py:118
estus_level(ch)
Definition timeline.py:30