SL2 Analyzer
Read a FromSoftware .sl2 save and report what is in it
Loading...
Searching...
No Matches
sl2.convert Namespace Reference

Classes

class  SaveData
 One parsed save: the game it is, its file-level fields, and its characters. More...
 

Functions

 disclaimer_for (cfg)
 One-line header note for a generated file: the repo, and how this game is read.
 
 save_format_version (data, entries, cfg, game)
 The save-format version this file was written with, or None.
 
 steam_owner (data, entries, game)
 The Steam account that owns this save, or None.
 
 ds2_steam_owner (data, entries, game)
 DS2's account, read as text rather than as a number.
 
 steam_folder (game, owner)
 The folder name the game will look for this save under, or None where the convention has not been verified for that game.
 
 er_game_patch (data, entries)
 Elden Ring's game patch, decoded from its regulation version, or None.
 
 footer_for (cfg, n, version=None, patch=None, owner=None, folder=None, meta=None)
 The closing "about this file" block: game, tier, slot count and the how-it-works note.
 
 parse_save (data, base_dir)
 Read one save file into plain data: which game it is, and every populated character slot already augmented with its progress.
 
 convert (data, filename, base_dir, meta=None, validate=False)
 Build the Markdown document for one save file.
 
 render_markdown (save, filename, meta=None, validate=False, data=None)
 Render an already-parsed save as the Markdown document.
 

Variables

str REPO_URL = "https://github.com/darthdemono/sl2-analyzer"
 Public source repository, printed in every generated file.
 
dict GAMES
 Per-game config: title, tier, db, decrypt/parse, slot range, and a one-line "how it works" for the file header.
 
dict SAVE_VERSION_GAMES = {"dsr", "ds3", "er"}
 Games that stamp a save-format version as a uint32 at slot +0.
 
int SAVE_VERSION_MAX = 4095
 Above this a slot-0 word is not a version counter but data.
 
dict STEAM_ID_GAMES
 Where each game stores the Steam account that owns the save: (menu entry, offset of the uint64 inside it).
 
int DS2_STEAM_ID_OFF = 53
 Dark Souls II keeps the same account, but as ASCII TEXT — entry 0, offset 53, sixteen characters of lowercase hex.
 
int DS2_STEAM_ID_LEN = 16
 
int STEAM_ID64_HIGH = 0x01100001
 High dword of every individual-account SteamID64 (universe 1, type 1, instance 1).
 
dict STEAM_FOLDER_HEX = {"ds3", "ds2sotfs", "ds2vanilla"}
 Games whose save folder is named with the SteamID64 in HEX rather than decimal.
 
dict STEAM_FOLDER_DEC = {"sdt", "nr"}
 Games whose save folder is named with the decimal SteamID64.
 
int ER_REG_ENTRY = 11
 Elden Ring ships its regulation (the game's own param data) inside the save, in BND4 entry 11 behind a " GER" magic — and that block is versioned.
 
str ER_REG_MAGIC = b" GER"
 
int ER_REG_VER_OFF = 8
 
dict META_LABEL
 Display labels for metadata keys whose acronym capitalize() would mangle ("dlc" -> "Dlc", "os" -> "Os").
 
tuple ER_NOTE
 Elden Ring's item-coverage caveat.
 
tuple SDT_NOTE
 Sekiro's own caveat.
 

Detailed Description

The driver: the GAMES control table, the file-level footer fields, and the
one pass that turns a .sl2 into Markdown.

Function Documentation

◆ disclaimer_for()

sl2.convert.disclaimer_for (   cfg)

One-line header note for a generated file: the repo, and how this game is read.

Replaces the old boilerplate; states the source, not caveats.

Definition at line 219 of file convert.py.

◆ save_format_version()

sl2.convert.save_format_version (   data,
  entries,
  cfg,
  game 
)

The save-format version this file was written with, or None.

Read from the first slot that carries one — an unused slot is all zeros, so the walk continues past it rather than reporting 0.

Parameters
dataThe full file bytes.
entriesBND4 entries.
cfgThe GAMES entry.
gameThe game key.

Definition at line 248 of file convert.py.

◆ steam_owner()

sl2.convert.steam_owner (   data,
  entries,
  game 
)

The Steam account that owns this save, or None.

Returns (account_id, steam_id64_text) — the account id as a plain int and the full SteamID64 rendered as text, because the number is too large to survive a JavaScript double and the two front ends have to agree byte for byte.

Parameters
dataThe full file bytes.
entriesBND4 entries.
gameThe game key.

Definition at line 328 of file convert.py.

References sl2.convert.ds2_steam_owner().

Referenced by sl2.convert.parse_save().

Here is the call graph for this function:
Here is the caller graph for this function:

◆ ds2_steam_owner()

sl2.convert.ds2_steam_owner (   data,
  entries,
  game 
)

DS2's account, read as text rather than as a number.

Returns the same (account_id, steam_id64_text) pair as steam_owner so everything downstream is unchanged. The string is parsed rather than trusted: it must be sixteen hex digits whose top half is the individual-account constant, which is the same validity gate the numeric games use and is what stops an unrelated run of text being printed as somebody's account.

Parameters
dataThe full file bytes.
entriesBND4 entries.
gameThe game key.

Definition at line 355 of file convert.py.

Referenced by sl2.convert.steam_owner().

Here is the caller graph for this function:

◆ steam_folder()

sl2.convert.steam_folder (   game,
  owner 
)

The folder name the game will look for this save under, or None where the convention has not been verified for that game.

The account is baked into the save, and the game only reads a save back out of the folder named for that account — so a save moved to another account's folder, or a save whose account id changed underneath it, will not load. That is the whole reason this is worth printing.

Parameters
gameThe game key.
ownerThe steam_owner pair.

Definition at line 382 of file convert.py.

Referenced by sl2.convert.parse_save().

Here is the caller graph for this function:

◆ er_game_patch()

sl2.convert.er_game_patch (   data,
  entries 
)

Elden Ring's game patch, decoded from its regulation version, or None.

The regulation version is a uint32 laid out as M-mm-p-bbbb: major, minor, patch, build. Both ER saves here read 11601000 → 1.16.0. The layout is not a guess — ER-Save-Lib carries a regulation-version→size table of 24 real ids, and every one of them decodes this way to a version Bandai actually shipped (10210038 → 1.02.1, 10330078 → 1.03.3, 10911000 → 1.09.1, 11611000 → 1.16.1). The build digits are dropped: they identify the regulation revision, not the patch players are told about.

Note
ER only. DS3's entry 11 carries the same " GER" magic but no version word (its +8 is a size), and DS1/DS2 have no regulation block at all — so nothing to read.
Parameters
dataThe full file bytes.
entriesBND4 entries.

Definition at line 415 of file convert.py.

◆ footer_for()

sl2.convert.footer_for (   cfg,
  n,
  version = None,
  patch = None,
  owner = None,
  folder = None,
  meta = None 
)

The closing "about this file" block: game, tier, slot count and the how-it-works note.

These are facts about the TOOL, not about the character, and they are identical in every export — so they sit at the end, out of the way of the save's own numbers, and folded into a <details> block so two exports diff cleanly. The save version is the one line here that IS about the file, and it sits here because it is a property of the file rather than of any one character — as is the game patch.

Parameters
cfgThe GAMES entry.
nCharacters rendered.
versionThe save-format version, or None where the game has no known field.
patchThe game patch (ER only, from its regulation version), or None.
ownerThe Steam account pair from steam_owner, or None.
folderThe folder name from steam_folder, or None.
metaCaller-supplied environment (store, launcher, OS, …), or None. It is printed under its own heading and labelled as SUPPLIED, because none of it is read from the save — the save cannot know which launcher started it.

Definition at line 463 of file convert.py.

◆ parse_save()

sl2.convert.parse_save (   data,
  base_dir 
)

Read one save file into plain data: which game it is, and every populated character slot already augmented with its progress.

This is the whole reading pass, with no rendering in it — the Markdown writer and the JSON writer both start here, so neither can drift from the other. The three branches are the three shapes the games come in: Elden Ring (roster-gated, GaItem items), DS3 (id-scan items, content-scan stats, event flags), and everything else (decrypt the slot, hand it to the game's parse hook, then its optional augment hook).

Parameters
dataThe full file bytes.
base_dirFolder holding the db_* item-table directories.
Returns
A SaveData.

Definition at line 541 of file convert.py.

References sl2.convert.steam_folder(), and sl2.convert.steam_owner().

Here is the call graph for this function:

◆ convert()

sl2.convert.convert (   data,
  filename,
  base_dir,
  meta = None,
  validate = False 
)

Build the Markdown document for one save file.

Parameters
dataThe full file bytes.
filenameThe source filename, for the header line.
base_dirFolder holding the db_* item-table directories.
Returns
The complete Markdown string.

Definition at line 727 of file convert.py.

◆ render_markdown()

sl2.convert.render_markdown (   save,
  filename,
  meta = None,
  validate = False,
  data = None 
)

Render an already-parsed save as the Markdown document.

Parameters
saveA SaveData from parse_save.
filenameThe source filename, for the header line.
metaCaller-supplied environment for the footer, or None.
validateAppend each character's validation section (off by default; the validators package is a separate pass and nothing here depends on it).
dataThe original file bytes, when the caller has them: the file-level rules (entry checksums) need the container, not a character.
Returns
The complete Markdown string.

Definition at line 741 of file convert.py.

Variable Documentation

◆ REPO_URL

str sl2.convert.REPO_URL = "https://github.com/darthdemono/sl2-analyzer"

Public source repository, printed in every generated file.

Definition at line 52 of file convert.py.

◆ GAMES

dict sl2.convert.GAMES

Per-game config: title, tier, db, decrypt/parse, slot range, and a one-line "how it works" for the file header.

Definition at line 57 of file convert.py.

◆ SAVE_VERSION_GAMES

dict sl2.convert.SAVE_VERSION_GAMES = {"dsr", "ds3", "er"}

Games that stamp a save-format version as a uint32 at slot +0.

ClayAmore's ER-Save-Lib names this field ("File version", the word right after the 16-byte checksum) and branches on it, so it is documented rather than inferred. DSR reads 71, DS3 98, ER 220 or 251. It is NOT the game patch — two ER saves here read 220 and 251 with the same regulation version — so it is printed as a bare number beside the patch, not translated into one. DS2 is deliberately absent: it reads a constant 0x6F there on vanilla, Scholar and all 41 mules alike, so that word is structure, not a version. PtDE's first word is its slot size. Neither is guessed at.

Definition at line 235 of file convert.py.

◆ SAVE_VERSION_MAX

int sl2.convert.SAVE_VERSION_MAX = 4095

Above this a slot-0 word is not a version counter but data.

Definition at line 239 of file convert.py.

◆ STEAM_ID_GAMES

dict sl2.convert.STEAM_ID_GAMES
Initial value:
1= {
2 "ds3": (10, 0x04),
3 "er": (10, 0x04),
4 "sdt": (10, 0x24),
5 "nr": (10, 0x08),
6}

Where each game stores the Steam account that owns the save: (menu entry, offset of the uint64 inside it).

Found by scanning every entry of every fixture for a well-formed SteamID64 and checking the hit against the account the save's own folder is named for. DS3 and ER keep it at the very front of the menu block, Sekiro a little further in.

Note
DS1 and DS2 are ABSENT because they genuinely do not store it — an exact byte search for both the SteamID64 and the bare account id, over PtDE, DSR, vanilla DS2 and Scholar saves whose owning account is known from the folder name, finds nothing. Those two games pick the save folder from whichever account is logged in and never write it down, which is also why their saves move between accounts and these ones do not.

Definition at line 272 of file convert.py.

◆ DS2_STEAM_ID_OFF

int sl2.convert.DS2_STEAM_ID_OFF = 53

Dark Souls II keeps the same account, but as ASCII TEXT — entry 0, offset 53, sixteen characters of lowercase hex.

This file used to say DS2 stored no account at all. That was wrong, and the reason it was wrong is worth keeping: the search that "proved" it looked for the SteamID64 as a 64-bit integer and as UTF-16, and DS2 writes the folder name — the printable hex string — so neither form was ever going to hit. mi5hmash/SL2Bonfire names the offset (SteamIdOffsetInUserDataFile 53, UserDataFileNumber 0), and it checks out on every DS2 save here: eight backups in 0110000100001337 all read that string, and the vanilla mule reads 011000015ab603ef, a different account. Two releases, two accounts, and every one matching the folder it was found in.

Definition at line 291 of file convert.py.

◆ DS2_STEAM_ID_LEN

int sl2.convert.DS2_STEAM_ID_LEN = 16

Definition at line 292 of file convert.py.

◆ STEAM_ID64_HIGH

int sl2.convert.STEAM_ID64_HIGH = 0x01100001

High dword of every individual-account SteamID64 (universe 1, type 1, instance 1).

The low dword is the account id proper.

Reading the field as two halves rather than one uint64 is deliberate and has to be matched in the JS port: a SteamID64 is larger than JavaScript's exact integer range, so forming the 64-bit value in a double loses the last digits. Both ports read two uint32 and assemble the printable forms from those. Requiring this exact constant in the high half is also the validity gate: it is what separates the real field from an arbitrary word, so an unrecognised layout omits the field instead of printing a nonsense account.

Definition at line 305 of file convert.py.

◆ STEAM_FOLDER_HEX

dict sl2.convert.STEAM_FOLDER_HEX = {"ds3", "ds2sotfs", "ds2vanilla"}

Games whose save folder is named with the SteamID64 in HEX rather than decimal.

Verified against the folders on disk: DS3 sits in 011000013fc93365, Sekiro in the decimal 76561199030416229. DS2 names its folder the same hex way but is NOT listed, because it stores no account to derive one from. ER is not listed either: no ER folder was on hand, so its convention is unchecked and unclaimed.

Definition at line 313 of file convert.py.

◆ STEAM_FOLDER_DEC

dict sl2.convert.STEAM_FOLDER_DEC = {"sdt", "nr"}

Games whose save folder is named with the decimal SteamID64.

Nightreign is listed on evidence: the test save reads 76561197960272671 and shipped in a folder of that name.

Definition at line 319 of file convert.py.

◆ ER_REG_ENTRY

int sl2.convert.ER_REG_ENTRY = 11

Elden Ring ships its regulation (the game's own param data) inside the save, in BND4 entry 11 behind a " GER" magic — and that block is versioned.

Definition at line 395 of file convert.py.

◆ ER_REG_MAGIC

str sl2.convert.ER_REG_MAGIC = b" GER"

Definition at line 398 of file convert.py.

◆ ER_REG_VER_OFF

int sl2.convert.ER_REG_VER_OFF = 8

Definition at line 401 of file convert.py.

◆ META_LABEL

dict sl2.convert.META_LABEL
Initial value:
1= {
2 "dlc": "DLC",
3 "os": "OS",
4 "cpu": "CPU",
5 "gpu": "GPU",
6 "ram": "RAM",
7 "mangohud": "MangoHud",
8 "gamemode": "GameMode",
9 "dxvk": "DXVK",
10 "fps": "FPS",
11 "hdr": "HDR",
12 "url": "URL",
13 "id": "ID",
14}

Display labels for metadata keys whose acronym capitalize() would mangle ("dlc" -> "Dlc", "os" -> "Os").

Any key not listed falls back to capitalising, so a caller inventing their own key still gets a sane label.

Definition at line 431 of file convert.py.

◆ ER_NOTE

tuple sl2.convert.ER_NOTE
Initial value:
1= (
2 "_Elden Ring identity, attributes, and runes are read directly; the "
3 "**item list is partial**. Owned items come from the GaItem array, "
4 "which holds weapons, armour and Ashes of War — each named against "
5 "its own type table (so no cross-type mis-naming) and reinforced/"
6 "affinity weapons resolve to the base weapon (the upgrade level "
7 "itself is not read). Talismans, spells and consumable goods live in "
8 "a separate held-inventory that shifts between patches and is not "
9 "parsed, so they are not listed. What is listed is really owned._"
10)

Elden Ring's item-coverage caveat.

ER is the one game whose item list is deliberately partial, so the document says so where the list is.

Definition at line 687 of file convert.py.

◆ SDT_NOTE

tuple sl2.convert.SDT_NOTE
Initial value:
1= (
2 "_Sekiro has no character name and no attributes: both are absent from the "
3 "game, not missing here. The item lists (carried, key items and the storage "
4 "box) are read whole and named by type, so nothing in them is guessed. Max HP "
5 "and max Posture come from the second of each field's two copies, because the "
6 "offsets the published editor labels as maxima are the CURRENT values — a "
7 "save pair either side of taking damage settled that. **Spirit Emblems** is "
8 "not read as a number of its own: the documented field holds 15 across saves "
9 "taken before and after the character gained a prosthetic, so it is the carry "
10 "cap rather than the count — and emblems you actually hold are an ordinary "
11 "inventory item, listed with everything else. The **Sculptor's Idols** and the "
12 "boss-defeat **flags** are both read: the event-flag region is at a fixed "
13 "place in the save, worked out from save pairs that lit one idol and killed "
14 "one boss, so a kill tagged _(confirmed)_ is proven rather than counted off a "
15 "Memory. Both RESET on a new journey — Attack Power carries and the flags do "
16 "not, so an NG+ save reports fewer than the character has earned._"
17)

Sekiro's own caveat.

The item lists are complete, the two stat maxima are pinned, and the event-flag region is located — so both the idols and the boss kills come out of their own flags. What is left needs id tables the game does not publish.

Definition at line 702 of file convert.py.