SL2 Analyzer
Read a FromSoftware .sl2 save and report what is in it
Loading...
Searching...
No Matches
convert.py
Go to the documentation of this file.
1"""The driver: the GAMES control table, the file-level footer fields, and the
2one pass that turns a .sl2 into Markdown.
3"""
4
5import os
6import sys
7from datetime import datetime
8
9from validators import run_validation
10from validators.file_rules import run_file_validation
11from validators.text import validation_md, validation_md_file
12
13from .bnd4 import checksum_ok, parse_bnd4
14from .crypto import decrypt_ds2, decrypt_iv_prefixed, decrypt_none, decrypt_nr
15from .detect import detect_game
16from .ds1 import ds1_augment, dsr_parse, ptde_parse
17from .ds2 import DS2_GAMES, ds2_active_slots, ds2_augment, ds2_parse
18from .ds3 import (
19 DS3_DB_FILES,
20 ds3_attach_flags,
21 ds3_attach_ring_effects,
22 ds3_event_flag_base,
23 ds3_item_cat,
24 ds3_journey,
25 ds3_parse,
26 ds3_playtime,
27)
28from .er import er_parse, er_roster, load_er_db
29from .itemdb import DS1_DB_FILES, DS2_DB_FILES, load_item_db, load_scan_db
30from .keys import DS2_VANILLA_KEY, DS3_KEY, DSR_KEY
31from .nr import (
32 NR_SLOT_COUNT,
33 NR_STEAM_ENTRY,
34 nr_parse,
35 nr_roster,
36 nr_slot_used,
37)
38from .progress import attach_defeated_bosses
39from .reader import u32
40from .render import md_for_character
41from .roster import parse_roster
42from .sdt import (
43 SDT_SLOT_COUNT,
44 load_sdt_db,
45 sdt_active_slots,
46 sdt_attach_flags,
47 sdt_parse,
48)
49from .totals import attach_progress_totals
50
51## @brief Public source repository, printed in every generated file.
52REPO_URL = "https://github.com/darthdemono/sl2-analyzer"
53
54
55## @brief Per-game config: title, tier, db, decrypt/parse, slot range, and a
56# one-line "how it works" for the file header.
57GAMES = {
58 "ds2sotfs": {
59 "title": "Dark Souls II: Scholar of the First Sin",
60 "tier": "full",
61 "db": ("db_ds2", True, DS2_DB_FILES),
62 "decrypt": decrypt_ds2,
63 "parse": ds2_parse,
64 "slots": range(1, 11),
65 "active": ds2_active_slots,
66 "augment": ds2_augment,
67 "how": "the save is scrambled with a lock (AES-128 encryption) "
68 "whose key ships inside the game itself, so the tool applies "
69 "that key to unlock the raw data. From there each character's "
70 "details sit at fixed, known positions: name, level, the nine "
71 "attributes, and souls are read straight from those spots. "
72 "Every inventory entry stores a numeric item ID, which the "
73 "tool looks up in a name table built from the community's "
74 "SOTFS ID list, so you read 'Longsword' instead of a number; "
75 "reinforcement level and infusion sit in a separate field of "
76 "each item record and are shown as a '+N' suffix and an "
77 "infusion prefix (e.g. 'Fire Longsword +6')",
78 },
79 "ds2vanilla": {
80 "title": "Dark Souls II",
81 "tier": "full",
82 "db": ("db_ds2", True, DS2_DB_FILES),
83 "decrypt": lambda b: decrypt_ds2(b, DS2_VANILLA_KEY),
84 "parse": lambda b, d: ds2_parse(b, d, "ds2vanilla"),
85 "slots": range(1, 11),
86 # The header and world blocks are encrypted with the same key as
87 # the slot, so both hooks must be given the vanilla one — reading
88 # them with the Scholar key yields noise, not an empty result.
89 "active": lambda d, e, s: ds2_active_slots(
90 d, e, s, lambda b: decrypt_ds2(b, DS2_VANILLA_KEY)
91 ),
92 "augment": lambda ch, d, e, i, b: ds2_augment(
93 ch, d, e, i, b, lambda x: decrypt_ds2(x, DS2_VANILLA_KEY)
94 ),
95 "how": "the original (pre-Scholar) release locks its save with a "
96 "different AES-128 key from Scholar's, but stores everything "
97 "in the same places once unlocked — so the same reader "
98 "handles both. Name, level, the nine attributes, souls and "
99 "the inventory sit at fixed known positions, and every item "
100 "ID is looked up in the community SOTFS name table. Note "
101 "the Scholar-only items and bonfires simply never appear in "
102 "an original-edition save",
103 },
104 "dsr": {
105 "title": "Dark Souls Remastered",
106 "tier": "full",
107 "db": ("db_ds1", False, DS1_DB_FILES),
108 "decrypt": lambda b: decrypt_iv_prefixed(b, DSR_KEY),
109 "parse": dsr_parse,
110 "slots": range(0, 10),
111 "augment": lambda ch, d, e, i, b: ds1_augment(
112 ch, d, e, i, b, lambda x: decrypt_iv_prefixed(x, DSR_KEY)
113 ),
114 "how": "the save is locked the same way (AES-128 encryption, key shipped "
115 "inside the game), so the tool unlocks it first. The character "
116 "block does not sit at a fixed spot — it shifts as the save grows "
117 "— so the tool locates it by a fixed marker (a 'magic' byte "
118 "pattern) that always sits beside it, then reads the level, stats, "
119 "and souls at known distances from that marker. The inventory is "
120 "found by a second, separate marker, and every item ID is matched "
121 "to its real name",
122 },
123 "ptde": {
124 "title": "Dark Souls: Prepare to Die Edition",
125 "tier": "full",
126 "db": ("db_ds1", False, DS1_DB_FILES),
127 "decrypt": decrypt_none,
128 "parse": ptde_parse,
129 "slots": range(0, 10),
130 "augment": lambda ch, d, e, i, b: ds1_augment(ch, d, e, i, b, decrypt_none),
131 "how": "this original edition does not encrypt its save at all, so "
132 "there is nothing to unlock. It stores a character the same way "
133 "Remastered does but without that version's marker, so the tool "
134 "finds the character by locating the name text and reads the "
135 "level, stats, souls, and inventory that sit at known distances "
136 "around it",
137 },
138 "ds3": {
139 "title": "Dark Souls III",
140 "tier": "full",
141 "db": ("db_ds3", DS3_DB_FILES),
142 "decrypt": lambda b: decrypt_iv_prefixed(b, DS3_KEY),
143 "menu": 10,
144 "slots": range(0, 10),
145 "how": "the save is locked with AES-128 encryption, key shipped in the "
146 "game, so the tool unlocks it first. The stats do not sit at a "
147 "fixed position, and that position moves between game patches, so "
148 "instead of trusting a location the tool searches for the stat "
149 "block by its content: it looks for the run of nine numbers that, "
150 "added together, equal the character's stored level — a rule the "
151 "game itself follows, which makes a wrong match almost impossible. "
152 "Items are found by scanning the slot for known IDs and matched to "
153 "names",
154 },
155 "er": {
156 "title": "Elden Ring",
157 "tier": "full",
158 "db": "db_er",
159 "decrypt": decrypt_none,
160 "menu": 10,
161 "slots": range(0, 10),
162 "how": "the save is not encrypted, so the tool reads it directly. Like "
163 "Dark Souls III, the stats are found by content rather than a fixed "
164 "spot — the tool looks for the eight numbers that add up to the "
165 "character's level — which matters more here because that stat "
166 "block sits in a different place for every character. Every item "
167 "the character owns is read from the game's item array and matched "
168 "to its real name",
169 },
170 "sdt": {
171 "title": "Sekiro: Shadows Die Twice",
172 "tier": "full",
173 "db": "db_sdt",
174 "decrypt": decrypt_none,
175 "slots": range(0, SDT_SLOT_COUNT),
176 # `coverage` says what the tier does NOT cover, so a reader who sees
177 # "full" does not fairly infer that everything the other games report is
178 # here. Moving the tier would redefine what the word means for every game.
179 "coverage": "world item pickups are read for the nine areas whose flag "
180 "bank is mapped (583 of the 826 known item lots); the rest sit in "
181 "families no idol names, so they have no category to be read from",
182 "how": "the save is not encrypted and, unlike every other game here, its "
183 "fields do not move between patches — so play time, journey (New "
184 "Game+) count, Attack Power and Sen are read straight from fixed "
185 "positions. The item lists are read the same way: each entry stores "
186 "a type code beside its item ID, so a piece of armour can never be "
187 "named as a weapon, and a prosthetic tool's upgrade tier is its own "
188 "ID (there is no '+N' to work out). Sekiro has no character name and "
189 "no attributes to level, so neither appears; Attack Power doubles as "
190 "a count of the Memories already spent, which is how bosses whose "
191 "token is long gone are still counted",
192 },
193 "nr": {
194 "title": "Elden Ring Nightreign",
195 "tier": "roster",
196 "db": None,
197 "decrypt": decrypt_nr,
198 "slots": range(0, NR_SLOT_COUNT),
199 "menu": NR_STEAM_ENTRY,
200 "coverage": "identity only. The save opens and every entry proves its own "
201 "checksum, so the bytes are certain; what is not is the layout past the "
202 "roster. Relics, unlocked Nightfarers, Murks and Sigs, and which Nightlords "
203 "are dead are all in there and none of them is read yet, because pinning a "
204 "field against one save is how a wrong number gets shipped",
205 "how": "the save is a BND4 like the rest, and every entry is AES-128-CBC "
206 "with the initialisation vector at the very front — half a step from Dark "
207 "Souls III, which puts a checksum there instead and the IV second. Reading "
208 "it the Dark Souls III way produces noise that still looks like data, so "
209 "the key is not taken on trust: Nightreign stores an MD5 of each entry's "
210 "own plaintext, and all fourteen hash to the value they carry. The account "
211 "and the character names are read from the menu block, and the roster is "
212 "found by its own contents rather than a fixed offset",
213 },
214}
215
216
217## @brief One-line header note for a generated file: the repo, and how this game
218# is read. Replaces the old boilerplate; states the source, not caveats.
219def disclaimer_for(cfg):
220 return (
221 f"> Automated dump of the save. Code Repo: {REPO_URL} . "
222 f"How it works for {cfg['title']}: {cfg['how']}."
223 )
224
225
226##
227# @brief Games that stamp a save-format version as a @c uint32 at slot @c +0.
228# @details ClayAmore's ER-Save-Lib names this field ("File version", the word right
229# after the 16-byte checksum) and branches on it, so it is documented rather than
230# inferred. DSR reads 71, DS3 98, ER 220 or 251. It is NOT the game patch — two ER saves
231# here read 220 and 251 with the same regulation version — so it is printed as a bare
232# number beside the patch, not translated into one. DS2 is deliberately absent: it reads
233# a constant @c 0x6F there on vanilla, Scholar and all 41 mules alike, so that word is
234# structure, not a version. PtDE's first word is its slot size. Neither is guessed at.
235SAVE_VERSION_GAMES = {"dsr", "ds3", "er"}
236
237
238## @brief Above this a slot-0 word is not a version counter but data.
239SAVE_VERSION_MAX = 4095
240
241
242##
243# @brief The save-format version this file was written with, or None.
244# @details Read from the first slot that carries one — an unused slot is all zeros, so
245# the walk continues past it rather than reporting 0.
246# @param data The full file bytes. @param entries BND4 entries.
247# @param cfg The GAMES entry. @param game The game key.
248def save_format_version(data, entries, cfg, game):
249 if game not in SAVE_VERSION_GAMES:
250 return None
251 for e in entries:
252 if e.index not in cfg["slots"]:
253 continue
254 buf = cfg["decrypt"](data[e.offset : e.offset + e.size])
255 v = u32(buf, 0) if buf else None
256 if v is not None and 0 < v <= SAVE_VERSION_MAX:
257 return v
258 return None
259
260
261##
262# @brief Where each game stores the Steam account that owns the save: (menu entry,
263# offset of the @c uint64 inside it).
264# @details Found by scanning every entry of every fixture for a well-formed SteamID64
265# and checking the hit against the account the save's own folder is named for. DS3 and
266# ER keep it at the very front of the menu block, Sekiro a little further in.
267# @note DS1 and DS2 are ABSENT because they genuinely do not store it — an exact byte
268# search for both the SteamID64 and the bare account id, over PtDE, DSR, vanilla DS2 and
269# Scholar saves whose owning account is known from the folder name, finds nothing. Those
270# two games pick the save folder from whichever account is logged in and never write it
271# down, which is also why their saves move between accounts and these ones do not.
272STEAM_ID_GAMES = {
273 "ds3": (10, 0x04),
274 "er": (10, 0x04),
275 "sdt": (10, 0x24),
276 "nr": (10, 0x08),
277}
278
279
280##
281# @brief Dark Souls II keeps the same account, but as ASCII TEXT — entry 0, offset 53,
282# sixteen characters of lowercase hex.
283# @details This file used to say DS2 stored no account at all. That was wrong, and the
284# reason it was wrong is worth keeping: the search that "proved" it looked for the
285# SteamID64 as a 64-bit integer and as UTF-16, and DS2 writes the *folder name* — the
286# printable hex string — so neither form was ever going to hit. `mi5hmash/SL2Bonfire`
287# names the offset (`SteamIdOffsetInUserDataFile` 53, `UserDataFileNumber` 0), and it
288# checks out on every DS2 save here: eight backups in `0110000100001337` all read that
289# string, and the vanilla mule reads `011000015ab603ef`, a different account. Two
290# releases, two accounts, and every one matching the folder it was found in.
291DS2_STEAM_ID_OFF = 53
292DS2_STEAM_ID_LEN = 16
293
294
295##
296# @brief High dword of every individual-account SteamID64 (universe 1, type 1,
297# instance 1). The low dword is the account id proper.
298# @details Reading the field as two halves rather than one @c uint64 is deliberate and
299# has to be matched in the JS port: a SteamID64 is larger than JavaScript's exact
300# integer range, so forming the 64-bit value in a double loses the last digits. Both
301# ports read two @c uint32 and assemble the printable forms from those. Requiring this
302# exact constant in the high half is also the validity gate: it is what separates the
303# real field from an arbitrary word, so an unrecognised layout omits the field instead
304# of printing a nonsense account.
305STEAM_ID64_HIGH = 0x01100001
306
307
308## @brief Games whose save folder is named with the SteamID64 in HEX rather than
309# decimal. Verified against the folders on disk: DS3 sits in `011000013fc93365`,
310# Sekiro in the decimal `76561199030416229`. DS2 names its folder the same hex way but
311# is NOT listed, because it stores no account to derive one from. ER is not listed
312# either: no ER folder was on hand, so its convention is unchecked and unclaimed.
313STEAM_FOLDER_HEX = {"ds3", "ds2sotfs", "ds2vanilla"}
314
315
316## @brief Games whose save folder is named with the decimal SteamID64.
317## Nightreign is listed on evidence: the test save reads 76561197960272671 and
318## shipped in a folder of that name.
319STEAM_FOLDER_DEC = {"sdt", "nr"}
320
321
322##
323# @brief The Steam account that owns this save, or None.
324# @details Returns @c (account_id, steam_id64_text) — the account id as a plain int and
325# the full SteamID64 rendered as text, because the number is too large to survive a
326# JavaScript double and the two front ends have to agree byte for byte.
327# @param data The full file bytes. @param entries BND4 entries. @param game The game key.
328def steam_owner(data, entries, game):
329 if game in DS2_GAMES:
330 return ds2_steam_owner(data, entries, game)
331 where = STEAM_ID_GAMES.get(game)
332 if where is None:
333 return None
334 entry, off = where
335 if len(entries) <= entry:
336 return None
337 e = entries[entry]
338 buf = GAMES[game]["decrypt"](data[e.offset : e.offset + e.size])
339 if buf is None:
340 return None
341 low, high = u32(buf, off), u32(buf, off + 4)
342 if low is None or high != STEAM_ID64_HIGH or low == 0:
343 return None
344 return low, str((high << 32) | low)
345
346
347##
348# @brief DS2's account, read as text rather than as a number.
349# @details Returns the same @c (account_id, steam_id64_text) pair as @ref steam_owner so
350# everything downstream is unchanged. The string is parsed rather than trusted: it must
351# be sixteen hex digits whose top half is the individual-account constant, which is the
352# same validity gate the numeric games use and is what stops an unrelated run of text
353# being printed as somebody's account.
354# @param data The full file bytes. @param entries BND4 entries. @param game The game key.
355def ds2_steam_owner(data, entries, game):
356 if not entries:
357 return None
358 e = entries[0]
359 buf = GAMES[game]["decrypt"](data[e.offset : e.offset + e.size])
360 if buf is None or len(buf) < DS2_STEAM_ID_OFF + DS2_STEAM_ID_LEN:
361 return None
362 raw = bytes(buf[DS2_STEAM_ID_OFF : DS2_STEAM_ID_OFF + DS2_STEAM_ID_LEN])
363 try:
364 text = raw.decode("ascii")
365 value = int(text, 16)
366 except (UnicodeDecodeError, ValueError):
367 return None
368 account = value & 0xFFFFFFFF
369 if (value >> 32) != STEAM_ID64_HIGH or not account:
370 return None
371 return account, str(value)
372
373
374##
375# @brief The folder name the game will look for this save under, or None where the
376# convention has not been verified for that game.
377# @details The account is baked into the save, and the game only reads a save back out
378# of the folder named for that account — so a save moved to another account's folder,
379# or a save whose account id changed underneath it, will not load. That is the whole
380# reason this is worth printing.
381# @param game The game key. @param owner The @ref steam_owner pair.
382def steam_folder(game, owner):
383 if owner is None:
384 return None
385 account, _ = owner
386 if game in STEAM_FOLDER_HEX:
387 return f"{STEAM_ID64_HIGH:08x}{account:08x}"
388 if game in STEAM_FOLDER_DEC:
389 return str((STEAM_ID64_HIGH << 32) | account)
390 return None
391
392
393## @brief Elden Ring ships its regulation (the game's own param data) inside the save,
394# in BND4 entry 11 behind a " GER" magic — and that block is versioned.
395ER_REG_ENTRY = 11
396
397
398ER_REG_MAGIC = b" GER"
399
400
401ER_REG_VER_OFF = 8 # magic[4] + unk u32, per ER-Save-Lib's UserData11
402
403
404##
405# @brief Elden Ring's game patch, decoded from its regulation version, or None.
406# @details The regulation version is a @c uint32 laid out as @c M-mm-p-bbbb: major,
407# minor, patch, build. Both ER saves here read @c 11601000 → 1.16.0. The layout is not
408# a guess — ER-Save-Lib carries a regulation-version→size table of 24 real ids, and
409# every one of them decodes this way to a version Bandai actually shipped (10210038 →
410# 1.02.1, 10330078 → 1.03.3, 10911000 → 1.09.1, 11611000 → 1.16.1). The build digits are
411# dropped: they identify the regulation revision, not the patch players are told about.
412# @note ER only. DS3's entry 11 carries the same " GER" magic but no version word (its
413# @c +8 is a size), and DS1/DS2 have no regulation block at all — so nothing to read.
414# @param data The full file bytes. @param entries BND4 entries.
415def er_game_patch(data, entries):
416 if len(entries) <= ER_REG_ENTRY:
417 return None
418 e = entries[ER_REG_ENTRY]
419 buf = decrypt_none(data[e.offset : e.offset + e.size])
420 if buf[:4] != ER_REG_MAGIC:
421 return None
422 v = u32(buf, ER_REG_VER_OFF)
423 if v is None or not 10000000 <= v <= 19999999:
424 return None
425 return f"{v // 10**7}.{v // 10**5 % 100:02d}.{v // 10**4 % 10}"
426
427
428## @brief Display labels for metadata keys whose acronym `capitalize()` would mangle
429# ("dlc" -> "Dlc", "os" -> "Os"). Any key not listed falls back to capitalising, so a
430# caller inventing their own key still gets a sane label.
431META_LABEL = {
432 "dlc": "DLC",
433 "os": "OS",
434 "cpu": "CPU",
435 "gpu": "GPU",
436 "ram": "RAM",
437 "mangohud": "MangoHud",
438 "gamemode": "GameMode",
439 "dxvk": "DXVK",
440 "fps": "FPS",
441 "hdr": "HDR",
442 "url": "URL",
443 "id": "ID",
444}
445
446
447##
448# @brief The closing "about this file" block: game, tier, slot count and the
449# how-it-works note.
450# @details These are facts about the TOOL, not about the character, and they are
451# identical in every export — so they sit at the end, out of the way of the save's own
452# numbers, and folded into a @c <details> block so two exports diff cleanly. The save
453# version is the one line here that IS about the file, and it sits here because it is a
454# property of the file rather than of any one character — as is the game patch.
455# @param cfg The GAMES entry. @param n Characters rendered.
456# @param version The save-format version, or None where the game has no known field.
457# @param patch The game patch (ER only, from its regulation version), or None.
458# @param owner The Steam account pair from @ref steam_owner, or None.
459# @param folder The folder name from @ref steam_folder, or None.
460# @param meta Caller-supplied environment (store, launcher, OS, …), or None. It is
461# printed under its own heading and labelled as SUPPLIED, because none of it
462# is read from the save — the save cannot know which launcher started it.
463def footer_for(cfg, n, version=None, patch=None, owner=None, folder=None, meta=None):
464 ver = [f"- **Save format version:** {version}"] if version is not None else []
465 if patch is not None:
466 ver.append(f"- **Game patch:** {patch} _(from the save's own regulation)_")
467 if owner is not None:
468 account, sid = owner
469 ver.append(
470 f"- **Steam account:** {account} _(SteamID64 {sid} — the account "
471 f"this save was written by)_"
472 )
473 if folder is not None:
474 ver.append(
475 f"- **Save folder:** `{folder}` _(the game loads this save only "
476 f"from a folder of this name)_"
477 )
478 env = []
479 if meta:
480 env = [
481 "",
482 "**Setup** _(supplied by the caller — not read from the save, "
483 "which cannot know any of it)_",
484 "",
485 ]
486 for key, value in meta.items():
487 label = META_LABEL.get(key) or key.replace("_", " ").capitalize()
488 shown = (
489 " · ".join(str(v) for v in value) if isinstance(value, list) else value
490 )
491 env.append(f"- **{label}:** {shown}")
492 return [
493 "<details>",
494 "<summary>About this file — how it was produced, "
495 "and how far to trust it</summary>",
496 "",
497 f"- **Game:** {cfg['title']}",
498 f"- **Support tier:** {cfg['tier']}"
499 + (f" _({cfg['coverage']})_" if cfg.get("coverage") else ""),
500 f"- **Character slots read:** {n}",
501 *ver,
502 *env,
503 "",
504 disclaimer_for(cfg),
505 "",
506 "</details>",
507 "",
508 ]
509
510
511##
512# @brief One parsed save: the game it is, its file-level fields, and its characters.
513# @details A plain record rather than a dict so the two writers cannot disagree about
514# a key name. @c characters is a list of @c (entry index, character dict) pairs, the
515# entry index being what the slot number is derived from.
517 __slots__ = ("game", "cfg", "version", "patch", "owner", "folder", "characters")
518
519 def __init__(self, game, cfg, version, patch, characters, owner=None, folder=None):
520 self.game = game # game id, e.g. "ds3"
521 self.cfg = cfg # its GAMES entry
522 self.version = version # save-format version, or None
523 self.patch = patch # ER regulation version, or None
524 self.owner = owner # (account id, SteamID64 text), or None
525 self.folder = folder # the folder name that account implies, or None
526 self.characters = characters
527
528
529##
530# @brief Read one save file into plain data: which game it is, and every populated
531# character slot already augmented with its progress.
532# @details This is the whole reading pass, with no rendering in it — the Markdown
533# writer and the JSON writer both start here, so neither can drift from the
534# other. The three branches are the three shapes the games come in: Elden Ring
535# (roster-gated, GaItem items), DS3 (id-scan items, content-scan stats, event
536# flags), and everything else (decrypt the slot, hand it to the game's parse
537# hook, then its optional augment hook).
538# @param data The full file bytes.
539# @param base_dir Folder holding the @c db_* item-table directories.
540# @return A @ref SaveData.
541def parse_save(data, base_dir):
542 entries = parse_bnd4(data)
543 game = detect_game(data, entries)
544 cfg = GAMES[game]
545 version = save_format_version(data, entries, cfg, game)
546 patch = er_game_patch(data, entries) if game == "er" else None
547 owner = steam_owner(data, entries, game)
548 folder = steam_folder(game, owner)
549 characters = []
550
551 # Elden Ring: identity + stats (content-scan) + owned items (GaItem walk).
552 if game == "er":
553 iddb = load_er_db(os.path.join(base_dir, cfg["db"]))
554 if not iddb:
555 sys.exit(f"No item database found in {os.path.join(base_dir, cfg['db'])}")
556 menu_entry = entries[cfg["menu"]]
557 roster = er_roster(
558 data[menu_entry.offset : menu_entry.offset + menu_entry.size]
559 )
560 for i in cfg["slots"]:
561 if i >= len(entries):
562 continue
563 active, name, level = roster[i] if i < len(roster) else (True, None, None)
564 if not active:
565 continue
566 slot = cfg["decrypt"](
567 data[entries[i].offset : entries[i].offset + entries[i].size]
568 )
569 if slot is None:
570 continue
571 ch = er_parse(slot, iddb, name, level)
572 if ch is not None:
573 attach_defeated_bosses(ch, base_dir)
574 attach_progress_totals(ch, base_dir)
575 characters.append((i, ch))
576 return SaveData(game, cfg, version, patch, characters, owner, folder)
577
578 # Nightreign: identity only. The container decrypts and self-checks, but the layout
579 # past the roster is not pinned, so nothing past a name is claimed. A slot is
580 # occupied or it is not, and that its content answers.
581 if game == "nr":
582 menu_entry = entries[cfg["menu"]]
583 roster = cfg["decrypt"](
584 data[menu_entry.offset : menu_entry.offset + menu_entry.size]
585 )
586 names = nr_roster(roster) if roster is not None else [None] * NR_SLOT_COUNT
587 for i in cfg["slots"]:
588 if i >= len(entries):
589 continue
590 slot = cfg["decrypt"](
591 data[entries[i].offset : entries[i].offset + entries[i].size]
592 )
593 if slot is None or not nr_slot_used(slot):
594 continue
595 characters.append((i, nr_parse(names[i] if i < len(names) else None)))
596 return SaveData(game, cfg, version, patch, characters, owner, folder)
597
598 # Sekiro: fixed offsets throughout, and the slot's own content decides whether it
599 # holds a character — the game publishes no occupancy array.
600 if game == "sdt":
601 db_dir = os.path.join(base_dir, cfg["db"])
602 iddb = load_sdt_db(db_dir)
603 if not any(iddb["names"].values()):
604 sys.exit(f"No item database found in {db_dir}")
605 # The game's own occupancy array, where it can be read — it is what tells a live
606 # character from a deleted one, which the slot's content cannot.
607 active = sdt_active_slots(data, entries, cfg["decrypt"])
608 for i in cfg["slots"]:
609 if i >= len(entries) or (active is not None and i not in active):
610 continue
611 slot = cfg["decrypt"](
612 data[entries[i].offset : entries[i].offset + entries[i].size]
613 )
614 if slot is None:
615 continue
616 ch = sdt_parse(slot, iddb)
617 if ch is not None:
618 attach_defeated_bosses(ch, base_dir)
619 sdt_attach_flags(ch, slot, base_dir)
620 attach_progress_totals(ch, base_dir)
621 characters.append((i, ch))
622 return SaveData(game, cfg, version, patch, characters, owner, folder)
623
624 # DS3: names from the header, inventory by id-scan, stats by content-scan.
625 if game == "ds3":
626 db_dir = os.path.join(base_dir, cfg["db"][0])
627 iddb = load_scan_db(db_dir, cfg["db"][1], ds3_item_cat)
628 if not iddb:
629 sys.exit(f"No item database found in {db_dir}")
630 menu_entry = entries[cfg["menu"]]
631 menu = cfg["decrypt"](
632 data[menu_entry.offset : menu_entry.offset + menu_entry.size]
633 )
634 names = dict(parse_roster(menu or b"", game)) if menu is not None else {}
635 for i in cfg["slots"]:
636 if i >= len(entries):
637 continue
638 slot = cfg["decrypt"](
639 data[entries[i].offset : entries[i].offset + entries[i].size]
640 )
641 if slot is None:
642 continue
643 ch = ds3_parse(slot, iddb, names.get(i))
644 if ch is not None:
645 if menu is not None:
646 ch["play_time"] = ds3_playtime(menu, i)
647 flag_base = ds3_event_flag_base(slot) # walk the block chain once
648 ch["ng_plus"] = ds3_journey(slot, flag_base)
649 attach_defeated_bosses(ch, base_dir)
650 ds3_attach_ring_effects(ch, base_dir)
651 ds3_attach_flags(ch, slot, flag_base, base_dir)
652 attach_progress_totals(ch, base_dir)
653 characters.append((i, ch))
654 return SaveData(game, cfg, version, patch, characters, owner, folder)
655
656 # Full / inventory tier: decrypt each slot and parse it.
657 db_dir = os.path.join(base_dir, cfg["db"][0])
658 item_db = load_item_db(db_dir, cfg["db"][1], cfg["db"][2])
659 if not item_db:
660 sys.exit(f"No item database found in {db_dir}")
661
662 # Some games keep a deleted character's block intact and only drop it from the
663 # menu; an "active" hook returns the still-listed entries so ghosts are skipped.
664 active = cfg["active"](data, entries, cfg["slots"]) if "active" in cfg else None
665
666 for i in cfg["slots"]:
667 if i >= len(entries):
668 continue
669 if active is not None and i not in active:
670 continue
671 blob = data[entries[i].offset : entries[i].offset + entries[i].size]
672 game_data = cfg["decrypt"](blob)
673 if game_data is None:
674 continue
675 ch = cfg["parse"](game_data, item_db)
676 if ch is not None:
677 if "augment" in cfg:
678 cfg["augment"](ch, data, entries, i, base_dir)
679 attach_defeated_bosses(ch, base_dir)
680 attach_progress_totals(ch, base_dir)
681 characters.append((i, ch))
682 return SaveData(game, cfg, version, patch, characters, owner, folder)
683
684
685## @brief Elden Ring's item-coverage caveat. ER is the one game whose item list is
686# deliberately partial, so the document says so where the list is.
687ER_NOTE = (
688 "_Elden Ring identity, attributes, and runes are read directly; the "
689 "**item list is partial**. Owned items come from the GaItem array, "
690 "which holds weapons, armour and Ashes of War — each named against "
691 "its own type table (so no cross-type mis-naming) and reinforced/"
692 "affinity weapons resolve to the base weapon (the upgrade level "
693 "itself is not read). Talismans, spells and consumable goods live in "
694 "a separate held-inventory that shifts between patches and is not "
695 "parsed, so they are not listed. What is listed is really owned._"
696)
697
698
699## @brief Sekiro's own caveat. The item lists are complete, the two stat maxima are
700# pinned, and the event-flag region is located — so both the idols and the boss kills
701# come out of their own flags. What is left needs id tables the game does not publish.
702SDT_NOTE = (
703 "_Sekiro has no character name and no attributes: both are absent from the "
704 "game, not missing here. The item lists (carried, key items and the storage "
705 "box) are read whole and named by type, so nothing in them is guessed. Max HP "
706 "and max Posture come from the second of each field's two copies, because the "
707 "offsets the published editor labels as maxima are the CURRENT values — a "
708 "save pair either side of taking damage settled that. **Spirit Emblems** is "
709 "not read as a number of its own: the documented field holds 15 across saves "
710 "taken before and after the character gained a prosthetic, so it is the carry "
711 "cap rather than the count — and emblems you actually hold are an ordinary "
712 "inventory item, listed with everything else. The **Sculptor's Idols** and the "
713 "boss-defeat **flags** are both read: the event-flag region is at a fixed "
714 "place in the save, worked out from save pairs that lit one idol and killed "
715 "one boss, so a kill tagged _(confirmed)_ is proven rather than counted off a "
716 "Memory. Both RESET on a new journey — Attack Power carries and the flags do "
717 "not, so an NG+ save reports fewer than the character has earned._"
718)
719
720
721##
722# @brief Build the Markdown document for one save file.
723# @param data The full file bytes.
724# @param filename The source filename, for the header line.
725# @param base_dir Folder holding the @c db_* item-table directories.
726# @return The complete Markdown string.
727def convert(data, filename, base_dir, meta=None, validate=False):
728 return render_markdown(parse_save(data, base_dir), filename, meta, validate, data)
729
730
731##
732# @brief Render an already-parsed save as the Markdown document.
733# @param save A @ref SaveData from @ref parse_save.
734# @param filename The source filename, for the header line.
735# @param meta Caller-supplied environment for the footer, or None.
736# @param validate Append each character's validation section (off by default; the
737# validators package is a separate pass and nothing here depends on it).
738# @param data The original file bytes, when the caller has them: the file-level rules
739# (entry checksums) need the container, not a character.
740# @return The complete Markdown string.
741def render_markdown(save, filename, meta=None, validate=False, data=None):
742 cfg = save.cfg
743 # Only the save's own identity up top; what the TOOL is and how far to trust it
744 # is the same in every export, so it goes in the closing block (footer_for).
745 head = [
746 f"# {cfg['title']} — Playthrough Save Summary",
747 "",
748 f"_Source: `{filename}` · generated {datetime.now():%Y-%m-%d %H:%M} · sl2_to_md_",
749 "",
750 "---",
751 "",
752 ]
753 body = ["_No populated character slots found._"] if not save.characters else []
754 for i, ch in save.characters:
755 body.append(md_for_character(ch, i - cfg["slots"].start + 1))
756 if validate:
757 body += validation_md(run_validation(ch, save.game))
758 body += ["---", ""]
759 if validate and data is not None:
760 entries = parse_bnd4(data) or []
761 body += validation_md_file(
762 run_file_validation(save.game, entries, data, checksum_ok)
763 )
764 body += ["---", ""]
765 if save.game == "er":
766 body += [ER_NOTE, ""]
767 if save.game == "sdt":
768 body += [SDT_NOTE, ""]
769 return "\n".join(
770 head
771 + body
772 + footer_for(
773 cfg,
774 len(save.characters),
775 save.version,
776 save.patch,
777 save.owner,
778 save.folder,
779 meta,
780 )
781 )
One parsed save: the game it is, its file-level fields, and its characters.
Definition convert.py:516
__init__(self, game, cfg, version, patch, characters, owner=None, folder=None)
Definition convert.py:519
ds2_steam_owner(data, entries, game)
DS2's account, read as text rather than as a number.
Definition convert.py:355
steam_folder(game, owner)
The folder name the game will look for this save under, or None where the convention has not been ver...
Definition convert.py:382
steam_owner(data, entries, game)
The Steam account that owns this save, or None.
Definition convert.py:328