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)
248SDT_SKILL_POINT_ID = 1200
252SDT_DB_FILES = (
"weapons",
"armors",
"goods")
269def load_sdt_db(db_dir):
272 with open(os.path.join(db_dir, stem +
".json"), encoding=
"utf-8")
as f:
273 return {int(k): v
for k, v
in json.load(f).items()}
274 except (OSError, ValueError):
277 names = {cat: table(cat)
for cat
in SDT_DB_FILES}
278 dev = {cat: table(cat +
"_devnames")
for cat
in SDT_DB_FILES}
279 return {
"names": names,
"dev": dev,
"prosthetics": set(table(
"prosthetics"))}
291def sdt_resolve(cat, iid, db):
292 name = db[
"names"].get(cat, {}).get(iid)
297 return name, sdt_goods_cat(iid),
False
298 return name, cat,
False
299 name = db[
"dev"].get(cat, {}).get(iid)
300 return (name,
"internal",
True)
if name
is not None else (
None,
None,
False)
312def sdt_items(buf, db):
313 for which, start, length
in SDT_LISTS:
314 for off
in range(start, start + length, SDT_RECORD):
315 handle = u32(buf, off)
318 cat = SDT_CAT.get((handle >> 28) & 0xF)
321 iid = u32(buf, off + 4)
325 name, render_cat, internal = sdt_resolve(cat, iid, db)
326 qty = u32(buf, off + 8)
327 yield which, iid, name, render_cat, qty, internal
341def sdt_memories_spent(attack):
342 if attack
is None or not SDT_ATTACK_BASE <= attack <= SDT_ATTACK_MAX:
344 return attack - SDT_ATTACK_BASE
364def sdt_parse(buf, db):
365 inv, key_items, memories, unknown, internal = {}, [], [], 0, 0
366 suppressed, skill_points = 0,
None
367 for which, iid, name, cat, qty, is_internal
in sdt_items(buf, db):
374 if sdt_suppressed(name):
381 if iid == SDT_SKILL_POINT_ID
and which !=
"storage":
382 skill_points = (skill_points
or 0) + (qty
or 0)
387 if which ==
"storage":
388 inv.setdefault(
"storage", []).append(row)
389 elif which ==
"key" or cat ==
"key":
390 key_items.append(row)
392 inv.setdefault(cat, []).append(row)
393 if cat ==
"memories":
396 play_time = u32(buf, SDT_PLAYTIME_OFF)
397 steam_id = u64(buf, SDT_STEAM_OFF)
398 if not any((inv, key_items, play_time, steam_id)):
401 attack = u32(buf, SDT_ATTACK_OFF)
402 if attack
is not None and attack > SDT_ATTACK_MAX:
404 ng = u8(buf, SDT_NG_OFF)
405 vitality = u32(buf, SDT_VITALITY_OFF)
406 if vitality
is not None and not 1 <= vitality <= SDT_VITALITY_MAX:
408 gourd = u8(buf, SDT_GOURD_OFF)
409 if gourd
is not None and not 1 <= gourd <= SDT_GOURD_MAX:
425 "hp":
sdt_twin(buf, SDT_HP_OFF, SDT_HP_ALT),
426 "posture":
sdt_twin(buf, SDT_POSTURE_OFF, SDT_POSTURE_ALT),
427 "ng_plus": ng
if ng
is not None and ng <= SDT_NG_MAX
else None,
428 "play_time": play_time,
429 "souls": u32(buf, SDT_SEN_OFF),
431 "vitality": vitality,
433 "skill_points": skill_points,
434 "boss_souls": memories,
435 "key_items": key_items,
437 "unknown_count": unknown,
438 "internal_count": internal,
439 "suppressed_count": suppressed,
441 spent = sdt_memories_spent(attack)
442 if spent
is not None:
445 "held": len(memories),
446 "cumulative": bool(ch[
"ng_plus"]),
458def load_sdt_boss_flags(base_dir):
459 if base_dir
not in _BOSS_FLAG_CACHE:
460 path = os.path.join(base_dir,
"db_sdt",
"boss_flags.json")
462 with open(path, encoding=
"utf-8")
as f:
463 _BOSS_FLAG_CACHE[base_dir] = json.load(f)
464 except (OSError, ValueError):
465 _BOSS_FLAG_CACHE[base_dir] = {}
466 return _BOSS_FLAG_CACHE[base_dir]
492SDT_FLAG_CATEGORY = 0x500
496SDT_FLAG_GLOBAL_MAX = 10000
586def sdt_flag_offset(fid):
587 area, sub = (fid // 100000) % 100, (fid // 10000) % 10
588 if (fid // 10000000) % 10 == SDT_PICKUP_GROUP:
592 k = SDT_FLAG_MAPS.get((area, sub))
596 elif area >= 90
or area + sub == 0:
597 if not 0 <= fid < SDT_FLAG_GLOBAL_MAX:
600 elif (fid // 10000000) % 10 > SDT_PICKUP_GROUP:
613 k = SDT_FLAG_MAPS.get((area, sub))
618 SDT_FLAG_REGION + k * SDT_FLAG_CATEGORY + (fid // 1000) % 10 * SDT_FLAG_BLOCK
620 return block + (n >> 5) * 4 + 3 - ((n & 31) >> 3), 7 - (n & 7)
642SDT_OCCUPANCY_OFF = 212
645def sdt_active_slots(data, entries, decrypt):
646 if len(entries) <= SDT_MENU_ENTRY:
648 e = entries[SDT_MENU_ENTRY]
649 menu = decrypt(data[e.offset : e.offset + e.size])
653 for i
in range(SDT_SLOT_COUNT):
654 byte = u8(menu, SDT_OCCUPANCY_OFF + i)
655 if byte
is None or byte > 1:
659 return active
or None
672def load_sdt_minibosses(base_dir):
673 if base_dir
not in _MINIBOSS_CACHE:
674 path = os.path.join(base_dir,
"db_sdt",
"minibosses.json")
676 with open(path, encoding=
"utf-8")
as f:
677 _MINIBOSS_CACHE[base_dir] = json.load(f)
678 except (OSError, ValueError):
679 _MINIBOSS_CACHE[base_dir] = {}
680 return _MINIBOSS_CACHE[base_dir]
687def load_sdt_idols(base_dir):
688 if base_dir
not in _IDOL_CACHE:
689 path = os.path.join(base_dir,
"db_sdt",
"idols.json")
691 with open(path, encoding=
"utf-8")
as f:
692 _IDOL_CACHE[base_dir] = json.load(f)
693 except (OSError, ValueError):
694 _IDOL_CACHE[base_dir] = {}
695 return _IDOL_CACHE[base_dir]
703def load_sdt_item_flags(base_dir):
704 if base_dir
not in _ITEM_FLAG_CACHE:
705 path = os.path.join(base_dir,
"db_sdt",
"item_flags.json")
707 with open(path, encoding=
"utf-8")
as f:
708 _ITEM_FLAG_CACHE[base_dir] = json.load(f)
709 except (OSError, ValueError):
710 _ITEM_FLAG_CACHE[base_dir] = {}
711 return _ITEM_FLAG_CACHE[base_dir]
722 (11, 0):
"Ashina Outskirts",
723 (10, 0):
"Hirata Estate",
724 (11, 1):
"Ashina Castle",
725 (11, 2):
"Ashina Reservoir",
726 (13, 0):
"Abandoned Dungeon",
727 (20, 0):
"Senpou Temple, Mt. Kongo",
728 (17, 0):
"Sunken Valley",
729 (15, 0):
"Ashina Depths",
730 (25, 0):
"Fountainhead Palace",
738 return (where
or "").replace(
"_",
" ").strip()
746def sdt_pickup_rows(base_dir):
747 rows = {key: []
for key
in SDT_PICKUP_AREAS}
748 for fid, name, where
in load_sdt_item_flags(base_dir).get(
"item_pickup", []):
750 key = ((fid // 100000) % 100, (fid // 10000) % 10)
754 if key
in rows
and sdt_flag_offset(fid)
is not None:
755 rows[key].append((fid, name, where))
761def sdt_flag(buf, fid):
762 at = sdt_flag_offset(fid)
765 byte = u8(buf, at[0])
766 return None if byte
is None else (byte >> at[1]) & 1
789def sdt_attach_flags(ch, buf, base_dir):
790 bosses = ch.get(
"bosses")
or {}
791 for name, fid
in load_sdt_boss_flags(base_dir).items():
792 if sdt_flag(buf, fid):
793 bosses[name] = sorted(set(bosses.get(name, ())) | {
"flag"})
795 ch[
"bosses"] = bosses
796 areas, any_lit = [],
False
797 for area, idols
in load_sdt_idols(base_dir).items():
798 named, missing = [], []
799 for fid, name
in idols:
800 (named
if sdt_flag(buf, int(fid))
else missing).append(name)
801 any_lit = any_lit
or bool(named)
802 areas.append((area, len(named), named, len(idols), missing))
807 ch[
"bonfire_areas"] = areas
813 minis, any_dead = [],
False
814 for area, enemies
in load_sdt_minibosses(base_dir).items():
816 for eid, name
in enemies:
817 (dead
if sdt_flag(buf, int(eid))
else alive).append(name)
818 any_dead = any_dead
or bool(dead)
819 minis.append((area, len(dead), dead, len(enemies), alive))
821 ch[
"minibosses"] = minis
826 picks, any_found = [],
False
827 for key, rows
in sdt_pickup_rows(base_dir).items():
828 got, missing = [], []
829 for fid, name, where
in rows:
830 label = f
"{name} — {sdt_place(where)}" if where
else name
831 (got
if sdt_flag(buf, fid)
else missing).append(label)
832 any_found = any_found
or bool(got)
833 picks.append((SDT_PICKUP_AREAS[key], len(got), len(rows), missing))
835 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.