1"""Sekiro: Shadows Die Twice.
3The odd one out in three ways, and each of them makes the read simpler rather than
4harder. The save is NOT encrypted (plaintext BND4 entries behind a plain MD5, like
5PtDE and Elden Ring), the slot offsets do NOT move between patches (so there is no
6content scan and no level identity to check), and reinforcement is NOT baked into an
7item id — a prosthetic tool's upgrade tier is its own id, so a straight lookup names
8"Lazulite Shuriken" with no arithmetic.
10What it does not have is a character name — Sekiro's profiles are unnamed by design —
11or attributes. Its numbers are Attack Power, max HP and max Posture, and the two upgrade
12levels behind them: Vitality and the Healing Gourd. Neither of the last two is in any
13published source; both were pinned here by differentials, and both are spent-token
14counters — the item that raised them is consumed, so the level is the only record that
17Offsets from uberhalit/SimpleSekiroSavegameHelper (the container) and
18alfizari/Sekiro-Save-Editor (the slot fields), every one of them verified against a real
19S0000.sl2 — and two of that editor's labels corrected against a differential, because
20they point at the current value rather than the maximum.
26from .reader
import u8, u32, u64
42SDT_STEAM_OFF = 0x33E54
44SDT_PLAYTIME_OFF = 0x33F80
45SDT_ATTACK_OFF = 0x3449C
64SDT_HP_OFF, SDT_HP_ALT = 0x34470, 0x34474
65SDT_POSTURE_OFF, SDT_POSTURE_ALT = 0x3448C, 0x34490
81SDT_VITALITY_OFF = 0x34498
113SDT_GOURD_OFF = 0x34562
121 value = u32(buf, off)
122 return value
if value
and value == u32(buf, alt)
else None
142SDT_ATTACK_MAX, SDT_NG_MAX = 99, 99
151 (
"inv", 0x8F70C, 0x7000),
152 (
"key", 0x9670C, 0x2000),
153 (
"storage", 0x987A0, 0x9000),
154 (
"storage", 0xA1958, 0x4000),
164SDT_CAT = {0x8:
"weapons", 0x9:
"armors", 0xB:
"goods"}
168SDT_ID_MASK = 0x00FFFFFF
183 if iid
in prosthetics:
185 return "arts" if iid <= SDT_ARTS_MAX
else "skills"
195 (500, 1999,
"consumables"),
197 (3000, 3999,
"consumables"),
198 (4000, 4499,
"beads"),
199 (5100, 5499,
"memories"),
201 (6000, 6999,
"upgrade"),
206def sdt_goods_cat(iid):
207 for lo, hi, cat
in SDT_GOODS_RANGES:
234SDT_SUPPRESSED_PREFIXES = (
236 "Immortal Severance Cutscene",
242def sdt_suppressed(name):
243 return name.startswith(SDT_SUPPRESSED_PREFIXES)
252SDT_MEMORY_COUNTER_IDS = (5100, 5400)
255SDT_BOSS_MEMORY_IDS = (5200, 5299)
260SDT_SKILL_POINT_ID = 1200
264SDT_DB_FILES = (
"weapons",
"armors",
"goods")
281def load_sdt_db(db_dir):
284 with open(os.path.join(db_dir, stem +
".json"), encoding=
"utf-8")
as f:
285 return {int(k): v
for k, v
in json.load(f).items()}
286 except (OSError, ValueError):
289 names = {cat: table(cat)
for cat
in SDT_DB_FILES}
290 dev = {cat: table(cat +
"_devnames")
for cat
in SDT_DB_FILES}
291 return {
"names": names,
"dev": dev,
"prosthetics": set(table(
"prosthetics"))}
303def sdt_resolve(cat, iid, db):
304 name = db[
"names"].get(cat, {}).get(iid)
309 return name, sdt_goods_cat(iid),
False
310 return name, cat,
False
311 name = db[
"dev"].get(cat, {}).get(iid)
312 return (name,
"internal",
True)
if name
is not None else (
None,
None,
False)
324def sdt_items(buf, db):
325 for which, start, length
in SDT_LISTS:
326 for off
in range(start, start + length, SDT_RECORD):
327 handle = u32(buf, off)
330 cat = SDT_CAT.get((handle >> 28) & 0xF)
333 iid = u32(buf, off + 4)
337 name, render_cat, internal = sdt_resolve(cat, iid, db)
338 qty = u32(buf, off + 8)
339 yield which, iid, name, render_cat, qty, internal
353def sdt_memories_spent(attack):
354 if attack
is None or not SDT_ATTACK_BASE <= attack <= SDT_ATTACK_MAX:
356 return attack - SDT_ATTACK_BASE
376def sdt_parse(buf, db):
377 inv, key_items, memories, unknown, internal = {}, [], [], 0, 0
378 suppressed, skill_points = 0,
None
379 for which, iid, name, cat, qty, is_internal
in sdt_items(buf, db):
386 if sdt_suppressed(name)
or (
387 cat ==
"memories" and iid
in SDT_MEMORY_COUNTER_IDS
395 if iid == SDT_SKILL_POINT_ID
and which !=
"storage":
396 skill_points = (skill_points
or 0) + (qty
or 0)
403 and SDT_BOSS_MEMORY_IDS[0] <= iid <= SDT_BOSS_MEMORY_IDS[1]
408 if which ==
"storage":
409 inv.setdefault(
"storage", []).append(row)
410 elif which ==
"key" or cat ==
"key":
411 key_items.append(row)
413 inv.setdefault(cat, []).append(row)
415 play_time = u32(buf, SDT_PLAYTIME_OFF)
416 steam_id = u64(buf, SDT_STEAM_OFF)
417 if not any((inv, key_items, play_time, steam_id)):
420 attack = u32(buf, SDT_ATTACK_OFF)
421 if attack
is not None and attack > SDT_ATTACK_MAX:
423 ng = u8(buf, SDT_NG_OFF)
424 vitality = u32(buf, SDT_VITALITY_OFF)
425 if vitality
is not None and not 1 <= vitality <= SDT_VITALITY_MAX:
427 gourd = u8(buf, SDT_GOURD_OFF)
428 if gourd
is not None and not 1 <= gourd <= SDT_GOURD_MAX:
444 "hp":
sdt_twin(buf, SDT_HP_OFF, SDT_HP_ALT),
445 "posture":
sdt_twin(buf, SDT_POSTURE_OFF, SDT_POSTURE_ALT),
446 "ng_plus": ng
if ng
is not None and ng <= SDT_NG_MAX
else None,
447 "play_time": play_time,
448 "souls": u32(buf, SDT_SEN_OFF),
450 "vitality": vitality,
452 "skill_points": skill_points,
453 "boss_souls": memories,
454 "key_items": key_items,
456 "unknown_count": unknown,
457 "internal_count": internal,
458 "suppressed_count": suppressed,
460 spent = sdt_memories_spent(attack)
461 if spent
is not None:
464 "held": len(memories),
465 "cumulative": bool(ch[
"ng_plus"]),
477def load_sdt_boss_flags(base_dir):
478 if base_dir
not in _BOSS_FLAG_CACHE:
479 path = os.path.join(base_dir,
"db_sdt",
"boss_flags.json")
481 with open(path, encoding=
"utf-8")
as f:
482 _BOSS_FLAG_CACHE[base_dir] = json.load(f)
483 except (OSError, ValueError):
484 _BOSS_FLAG_CACHE[base_dir] = {}
485 return _BOSS_FLAG_CACHE[base_dir]
496def load_sdt_endings(base_dir):
497 if base_dir
not in _ENDING_CACHE:
498 path = os.path.join(base_dir,
"db_sdt",
"endings.json")
500 with open(path, encoding=
"utf-8")
as f:
501 _ENDING_CACHE[base_dir] = json.load(f)
502 except (OSError, ValueError):
503 _ENDING_CACHE[base_dir] = {}
504 return _ENDING_CACHE[base_dir]
530SDT_FLAG_CATEGORY = 0x500
534SDT_FLAG_GLOBAL_MAX = 10000
626def sdt_flag_offset(fid):
627 area, sub = (fid // 100000) % 100, (fid // 10000) % 10
628 if (fid // 10000000) % 10 == SDT_PICKUP_GROUP:
632 k = SDT_FLAG_MAPS.get((area, sub))
636 elif area >= 90
or area + sub == 0:
637 if not 0 <= fid < SDT_FLAG_GLOBAL_MAX:
640 elif (fid // 10000000) % 10 > SDT_PICKUP_GROUP:
653 k = SDT_FLAG_MAPS.get((area, sub))
658 SDT_FLAG_REGION + k * SDT_FLAG_CATEGORY + (fid // 1000) % 10 * SDT_FLAG_BLOCK
660 return block + (n >> 5) * 4 + 3 - ((n & 31) >> 3), 7 - (n & 7)
682SDT_OCCUPANCY_OFF = 212
685def sdt_active_slots(data, entries, decrypt):
686 if len(entries) <= SDT_MENU_ENTRY:
688 e = entries[SDT_MENU_ENTRY]
689 menu = decrypt(data[e.offset : e.offset + e.size])
693 for i
in range(SDT_SLOT_COUNT):
694 byte = u8(menu, SDT_OCCUPANCY_OFF + i)
695 if byte
is None or byte > 1:
699 return active
or None
713def load_sdt_minibosses(base_dir):
714 if base_dir
not in _MINIBOSS_CACHE:
715 path = os.path.join(base_dir,
"db_sdt",
"minibosses.json")
717 with open(path, encoding=
"utf-8")
as f:
718 _MINIBOSS_CACHE[base_dir] = json.load(f)
719 except (OSError, ValueError):
720 _MINIBOSS_CACHE[base_dir] = {}
721 return _MINIBOSS_CACHE[base_dir]
725_MINIBOSS_FALLBACK_CACHE = {}
728def load_sdt_miniboss_fallbacks(base_dir):
729 if base_dir
not in _MINIBOSS_FALLBACK_CACHE:
730 path = os.path.join(base_dir,
"db_sdt",
"miniboss_fallbacks.json")
732 with open(path, encoding=
"utf-8")
as f:
733 _MINIBOSS_FALLBACK_CACHE[base_dir] = json.load(f)
734 except (OSError, ValueError):
735 _MINIBOSS_FALLBACK_CACHE[base_dir] = {}
736 return _MINIBOSS_FALLBACK_CACHE[base_dir]
743def load_sdt_idols(base_dir):
744 if base_dir
not in _IDOL_CACHE:
745 path = os.path.join(base_dir,
"db_sdt",
"idols.json")
747 with open(path, encoding=
"utf-8")
as f:
748 _IDOL_CACHE[base_dir] = json.load(f)
749 except (OSError, ValueError):
750 _IDOL_CACHE[base_dir] = {}
751 return _IDOL_CACHE[base_dir]
759def load_sdt_item_flags(base_dir):
760 if base_dir
not in _ITEM_FLAG_CACHE:
761 path = os.path.join(base_dir,
"db_sdt",
"item_flags.json")
763 with open(path, encoding=
"utf-8")
as f:
764 _ITEM_FLAG_CACHE[base_dir] = json.load(f)
765 except (OSError, ValueError):
766 _ITEM_FLAG_CACHE[base_dir] = {}
767 return _ITEM_FLAG_CACHE[base_dir]
788def load_sdt_lot_flags(base_dir):
789 if base_dir
not in _LOT_FLAG_CACHE:
790 path = os.path.join(base_dir,
"db_sdt",
"lot_flags.json")
792 with open(path, encoding=
"utf-8")
as f:
793 _LOT_FLAG_CACHE[base_dir] = json.load(f)
794 except (OSError, ValueError):
795 _LOT_FLAG_CACHE[base_dir] = {}
796 return _LOT_FLAG_CACHE[base_dir]
808 (11, 0):
"Ashina Outskirts",
809 (10, 0):
"Hirata Estate",
810 (11, 1):
"Ashina Castle",
811 (11, 2):
"Ashina Reservoir",
812 (13, 0):
"Abandoned Dungeon",
813 (20, 0):
"Senpou Temple, Mt. Kongo",
814 (17, 0):
"Sunken Valley",
815 (15, 0):
"Ashina Depths",
816 (25, 0):
"Fountainhead Palace",
824 return (where
or "").replace(
"_",
" ").strip()
834def sdt_pickup_rows(base_dir):
835 fixes = load_sdt_lot_flags(base_dir)
836 moved = fixes.get(
"moved")
or {}
837 misfiled = {(int(fid), name)
for fid, name
in fixes.get(
"misfiled")
or []}
838 rows = {key: []
for key
in SDT_PICKUP_AREAS}
839 for fid, name, where
in load_sdt_item_flags(base_dir).get(
"item_pickup", []):
841 key = ((fid // 100000) % 100, (fid // 10000) % 10)
845 if key
not in rows
or sdt_flag_offset(fid)
is None or (fid, name)
in misfiled:
847 flag = int(moved.get(str(fid), fid))
848 if sdt_flag_offset(flag)
is not None:
849 rows[key].append((flag, name, where))
855def sdt_flag(buf, fid):
856 at = sdt_flag_offset(fid)
859 byte = u8(buf, at[0])
860 return None if byte
is None else (byte >> at[1]) & 1
883def sdt_attach_flags(ch, buf, base_dir):
884 bosses = ch.get(
"bosses")
or {}
885 for name, fid
in load_sdt_boss_flags(base_dir).items():
886 if sdt_flag(buf, fid):
887 bosses[name] = sorted(set(bosses.get(name, ())) | {
"flag"})
889 ch[
"bosses"] = bosses
891 name
for name, fid
in load_sdt_endings(base_dir).items()
if sdt_flag(buf, fid)
894 ch[
"endings"] = endings
895 areas, any_lit = [],
False
896 for area, idols
in load_sdt_idols(base_dir).items():
897 named, missing = [], []
898 for fid, name
in idols:
899 (named
if sdt_flag(buf, int(fid))
else missing).append(name)
900 any_lit = any_lit
or bool(named)
901 areas.append((area, len(named), named, len(idols), missing))
906 ch[
"bonfire_areas"] = areas
912 minis, any_dead = [],
False
913 fallbacks = load_sdt_miniboss_fallbacks(base_dir)
914 keys = {name
for name, _qty
in ch.get(
"key_items")
or []}
915 for area, enemies
in load_sdt_minibosses(base_dir).items():
917 for eid, name
in enemies:
918 fallback = fallbacks.get(str(eid))
919 if isinstance(fallback, dict):
920 gate = fallback.get(
"key_item")
921 fallback = fallback.get(
"flag")
if not gate
or gate
in keys
else None
922 killed = sdt_flag(buf, int(eid))
or (
923 fallback
is not None and sdt_flag(buf, int(fallback))
925 (dead
if killed
else alive).append(name)
926 any_dead = any_dead
or bool(dead)
927 minis.append((area, len(dead), dead, len(enemies), alive))
929 ch[
"minibosses"] = minis
934 picks, any_found = [],
False
935 for key, rows
in sdt_pickup_rows(base_dir).items():
936 got, missing = [], []
937 for fid, name, where
in rows:
938 label = f
"{name} — {sdt_place(where)}" if where
else name
939 (got
if sdt_flag(buf, fid)
else missing).append(label)
940 any_found = any_found
or bool(got)
941 picks.append((SDT_PICKUP_AREAS[key], len(got), len(rows), missing))
943 ch[
"pickups"] = picks
sdt_weapon_cat(iid, prosthetics)
sdt_twin(buf, off, alt)
Read a field the game stores twice, or None unless both copies agree.