|
SL2 Analyzer
Read a FromSoftware .sl2 save and report what is in it
|
Functions | |
| sdt_twin (buf, off, alt) | |
| Read a field the game stores twice, or None unless both copies agree. | |
| sdt_weapon_cat (iid, prosthetics) | |
Variables | |
| int | SDT_SLOT_COUNT = 10 |
| How many character slots the game has. | |
| int | SDT_STEAM_OFF = 0x33E54 |
| Slot fields, at fixed offsets into the decrypted (plaintext) slot payload. | |
| int | SDT_NG_OFF = 0x33F34 |
| int | SDT_PLAYTIME_OFF = 0x33F80 |
| int | SDT_ATTACK_OFF = 0x3449C |
| int | SDT_SEN_OFF = 0x344D0 |
| SDT_HP_OFF | |
| Max HP and max Posture, each stored TWICE, and neither at the offset the published source names. | |
| SDT_HP_ALT | |
| Max HP and max Posture, each stored TWICE, and neither at the offset the published source names. | |
| SDT_POSTURE_OFF | |
| SDT_POSTURE_ALT | |
| int | SDT_VITALITY_OFF = 0x34498 |
| Vitality, the second of Sekiro's two upgrade tracks, at the word immediately before Attack Power. | |
| int | SDT_VITALITY_MAX = 20 |
| int | SDT_GOURD_OFF = 0x34562 |
The Healing Gourd's CHARGE COUNT — a byte, and Sekiro's third spent-token counter — at 0x34562. | |
| int | SDT_GOURD_MAX = 10 |
| int | SDT_ATTACK_BASE = 1 |
| Attack power on a character who has consumed no Memory. | |
| SDT_ATTACK_MAX | |
| Ceilings that make a field a field rather than noise: a value past either means the read landed somewhere it should not have, and the field is dropped. | |
| SDT_NG_MAX | |
| Ceilings that make a field a field rather than noise: a value past either means the read landed somewhere it should not have, and the field is dropped. | |
| tuple | SDT_LISTS |
| One inventory-style list: where it starts and how long it runs. | |
| int | SDT_RECORD = 16 |
| dict | SDT_CAT = {0x8: "weapons", 0x9: "armors", 0xB: "goods"} |
| Item type, from the top nibble of the record's HANDLE. | |
| int | SDT_ID_MASK = 0x00FFFFFF |
| The item id proper is the low 24 bits; the rest is the type code. | |
| int | SDT_ARTS_MAX = 9999 |
| Category refinement for the weapons table, applied at load time. | |
Sekiro: Shadows Die Twice. The odd one out in three ways, and each of them makes the read simpler rather than harder. The save is NOT encrypted (plaintext BND4 entries behind a plain MD5, like PtDE and Elden Ring), the slot offsets do NOT move between patches (so there is no content scan and no level identity to check), and reinforcement is NOT baked into an item id — a prosthetic tool's upgrade tier is its own id, so a straight lookup names "Lazulite Shuriken" with no arithmetic. What it does not have is a character name — Sekiro's profiles are unnamed by design — or attributes. Its numbers are Attack Power, max HP and max Posture, and the two upgrade levels behind them: Vitality and the Healing Gourd. Neither of the last two is in any published source; both were pinned here by differentials, and both are spent-token counters — the item that raised them is consumed, so the level is the only record that it was ever held. Offsets from uberhalit/SimpleSekiroSavegameHelper (the container) and alfizari/Sekiro-Save-Editor (the slot fields), every one of them verified against a real S0000.sl2 — and two of that editor's labels corrected against a differential, because they point at the current value rather than the maximum.
| sl2.sdt.sdt_twin | ( | buf, | |
| off, | |||
| alt | |||
| ) |
Read a field the game stores twice, or None unless both copies agree.
| buf | The slot payload. |
| off | The field. |
| alt | Its second copy. |
Definition at line 120 of file sdt.py.
Referenced by sl2.sdt.sdt_weapon_cat().
| sl2.sdt.sdt_weapon_cat | ( | iid, | |
| prosthetics | |||
| ) |
Definition at line 182 of file sdt.py.
References sl2.sdt.sdt_twin(), and sl2.sdt.sdt_weapon_cat().
Referenced by sl2.sdt.sdt_weapon_cat().
| int sl2.sdt.SDT_SLOT_COUNT = 10 |
| int sl2.sdt.SDT_STEAM_OFF = 0x33E54 |
Slot fields, at fixed offsets into the decrypted (plaintext) slot payload.
These do not move between patches — Sekiro has no equivalent of the DS3 stat block that drifts — so they are read directly rather than searched for. The Steam id is NOT printed anywhere; it is read only as the occupancy test, because an unused slot is all zeros and a used one carries its owner's id. That was checked both ways: the id matches the save's own folder name, and the nine unused slots (and a second, characterless save file) read zero.
| sl2.sdt.SDT_HP_OFF |
Max HP and max Posture, each stored TWICE, and neither at the offset the published source names.
Both live in the same four-word shape — [0][current][max][max] — and alfizari's editor labels the CURRENT field of each as the maximum. A real differential settles it: across a 42-minute window the word at 0x3446C moved 32 → 160 while 0x34470 and 0x34474 both held at 320. A value that moves while its neighbours do not is the current one; the pair that holds is the maximum. Posture sits in the identical shape one group along (its zero word at 0x34484), so its maximum is the same two fields over — which also explains why all three of its words read 120: posture is a pool that depletes, and an undamaged character is at full.
Each is read only where BOTH copies agree and the value is nonzero. That is the same self-consistency gate the DS3 equipment slots use, and here it costs nothing: the game writes both, so a disagreement means the read landed somewhere it should not have.
| sl2.sdt.SDT_HP_ALT |
Max HP and max Posture, each stored TWICE, and neither at the offset the published source names.
Both live in the same four-word shape — [0][current][max][max] — and alfizari's editor labels the CURRENT field of each as the maximum. A real differential settles it: across a 42-minute window the word at 0x3446C moved 32 → 160 while 0x34470 and 0x34474 both held at 320. A value that moves while its neighbours do not is the current one; the pair that holds is the maximum. Posture sits in the identical shape one group along (its zero word at 0x34484), so its maximum is the same two fields over — which also explains why all three of its words read 120: posture is a pool that depletes, and an undamaged character is at full.
Each is read only where BOTH copies agree and the value is nonzero. That is the same self-consistency gate the DS3 equipment slots use, and here it costs nothing: the game writes both, so a disagreement means the read landed somewhere it should not have.
| int sl2.sdt.SDT_VITALITY_OFF = 0x34498 |
Vitality, the second of Sekiro's two upgrade tracks, at the word immediately before Attack Power.
In no published source at all — it was pinned by the differential the old blocker asked for. A 21-second window in which the character used four Prayer Beads (the First Prayer Necklace) moved Max HP 320 → 400 and Max Posture 120 → 150, and in the whole player struct exactly ONE other word moved: this one, 1 → 2. Every earlier save on the same ladder reads 1 with no necklace used, and a characterless save reads 0, so the field is not a coincidence of that one window.
The stored value IS the number the status screen shows — no base to subtract, unlike SDT_ATTACK_BASE, because a fresh character reads 1 and one necklace makes it 2. The ceiling is the in-game cap; past it the read landed somewhere it should not have.
| int sl2.sdt.SDT_GOURD_OFF = 0x34562 |
The Healing Gourd's CHARGE COUNT — a byte, and Sekiro's third spent-token counter — at 0x34562.
In no published source either, and pinned the same way Vitality was. A four-minute window in which one Gourd Seed was handed to Emma moved exactly one byte in the whole player struct: this one, 7 → 8. The other five words that moved in the same window are play time, two timers and a pair that only tracks with play time, none of them a small monotone count.
Two independent checks across the whole 45-save ladder say what the byte MEANS, and they disagree with the obvious first reading of "seeds used":
this byte + the Gourd Seeds still in the inventory must never fall. It never does, over 45 saves, and on each save where a held seed disappears the byte takes exactly the +1 the inventory lost. A wrong offset cannot stay conserved against a list parsed independently of it.So the byte IS the number of charges, it starts at 1 (a characterless save and every unused slot read 0; the earliest save on the ladder reads 1 with a one-charge gourd), and one seed buys one charge — which makes value - 1 the seeds consumed, cumulative across journeys like Attack Power and Vitality. The ceiling is the game's own: the maxNum of EquipParamGoods row 3000, read out of this machine's install.
| int sl2.sdt.SDT_ATTACK_BASE = 1 |
Attack power on a character who has consumed no Memory.
Read off a real save that is minutes from the opening — no Memory held, no gourd, one key item — which is what makes the base a measurement rather than an assumption. It matters because sdt_memories_spent subtracts it.
| sl2.sdt.SDT_ATTACK_MAX |
Ceilings that make a field a field rather than noise: a value past either means the read landed somewhere it should not have, and the field is dropped.
Attack power was 98 here and that was one too low — a real journey-9 save reads exactly 99, so the tool was silently dropping Attack Power, and with it the Memories line, on precisely the characters that have the most of it. Raised on that evidence. The gate exists to reject a read in the thousands, not to tell 98 from 99.
Note the Memories arithmetic UNDERCOUNTS at the cap, because Attack Power stops rising while the kills do not. It is a floor, which is what the line already says.
| sl2.sdt.SDT_NG_MAX |
Ceilings that make a field a field rather than noise: a value past either means the read landed somewhere it should not have, and the field is dropped.
Attack power was 98 here and that was one too low — a real journey-9 save reads exactly 99, so the tool was silently dropping Attack Power, and with it the Memories line, on precisely the characters that have the most of it. Raised on that evidence. The gate exists to reject a read in the thousands, not to tell 98 from 99.
Note the Memories arithmetic UNDERCOUNTS at the cap, because Attack Power stops rising while the kills do not. It is a floor, which is what the line already says.
| tuple sl2.sdt.SDT_LISTS |
One inventory-style list: where it starts and how long it runs.
Records are 16 bytes, [u32 handle][u32 item id][u32 quantity][u32 index]. key items get their own region in Sekiro (every other game needs them filtered out of a bucket), and the storage box is kept apart because an item in the box is owned but not carried — a distinction the report can only make if the read makes it too.
| dict sl2.sdt.SDT_CAT = {0x8: "weapons", 0x9: "armors", 0xB: "goods"} |
| int sl2.sdt.SDT_ID_MASK = 0x00FFFFFF |
| int sl2.sdt.SDT_ARTS_MAX = 9999 |
Category refinement for the weapons table, applied at load time.
Sekiro keeps combat arts, prosthetic tools and a pile of engine-internal rows in one param table, so one heading for all of them would be useless. The split is by id block, read off the table sorted rather than guessed: the prosthetic tools are exactly the ids in prosthetics.json (the 7xxxx range, one id per upgrade tier), the combat arts are the five-digit ids below them, and everything above is the skill tree plus the "Virtual Weapon:" / "Upgrade Menu:" internals.