SL2 Analyzer
Read a FromSoftware .sl2 save and report what is in it
Loading...
Searching...
No Matches
edit.py
Go to the documentation of this file.
1"""Copy-only archive edits that validate the save before writing it."""
2
3import hashlib
4
5from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
6
7from .bnd4 import checksum_ok, parse_bnd4
8from .convert import DS2_STEAM_ID_LEN, DS2_STEAM_ID_OFF, STEAM_ID64_HIGH, steam_owner
9from .detect import detect_game
10from .ds2 import DS2_GAMES
11from .er import (
12 ER_MENU_DATA_OFF,
13 ER_MENU_LEN_OFF,
14 ER_PROFILE_STRIDE,
15 ER_SLOT_COUNT,
16 er_roster,
17)
18from .keys import DS2_KEY, DS2_VANILLA_KEY, DS3_KEY
19from .reader import u32
20from .sdt import SDT_MENU_ENTRY, SDT_SLOT_COUNT, SDT_STEAM_OFF, sdt_active_slots
21
22## @brief Complete on-disk size of one ER character entry, including its MD5 prefix.
23ER_SLOT_ENTRY_SIZE = 0x280010
24
25
26## @brief Return a mutable, checksum-verified BND4 save and its identified game.
27def _checked_save(data):
28 entries = parse_bnd4(data)
29 game = detect_game(data, entries)
30 bad = [str(e.index) for e in entries if not checksum_ok(data, e)]
31 if bad:
32 raise ValueError(
33 "Refusing to edit a save with bad BND4 checksum(s): " + ", ".join(bad)
34 )
35 ordered = sorted(entries, key=lambda e: e.offset)
36 if any(a.offset + a.size > b.offset for a, b in zip(ordered, ordered[1:])):
37 raise ValueError("Refusing to edit a save with overlapping BND4 entries.")
38 return bytearray(data), entries, game
39
40
41## @brief Recalculate an entry's outer MD5 after its payload has changed.
42def _seal(data, entry):
43 start = entry.offset
44 data[start : start + 16] = hashlib.md5(
45 data[start + 16 : start + entry.size]
46 ).digest()
47
48
49## @brief Encrypt a complete AES-CBC plaintext using the entry's unchanged IV.
50def _encrypt(key, iv, plaintext):
51 return Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor().update(plaintext)
52
53
54## @brief Change the SteamID64 in a save format whose every required copy is known.
55def set_steam_id(data, steam_id):
56 """Return a verified edited copy; never changes @p data in place."""
57 try:
58 steam_id = int(str(steam_id), 10)
59 except ValueError as exc:
60 raise ValueError("SteamID64 must be a decimal integer.") from exc
61 if not 0 < steam_id < 1 << 64 or steam_id >> 32 != STEAM_ID64_HIGH:
62 raise ValueError("SteamID64 must be an individual Steam account ID.")
63
64 out, entries, game = _checked_save(data)
65 old = steam_owner(data, entries, game)
66 if old is None:
67 raise ValueError(f"{game} has no verified writable SteamID64 field.")
68 packed = steam_id.to_bytes(8, "little")
69
70 if game == "sdt":
71 active = sdt_active_slots(data, entries, lambda b: b[16:])
72 if active is None:
73 raise ValueError(
74 "Sekiro's occupancy array is unreadable; refusing to guess slots."
75 )
76 menu = entries[SDT_MENU_ENTRY]
77 out[menu.offset + 16 + 0x24 : menu.offset + 16 + 0x2C] = packed
78 _seal(out, menu)
79 for i in active:
80 entry = entries[i]
81 at = entry.offset + 16 + SDT_STEAM_OFF
82 if int.from_bytes(data[at : at + 8], "little") != int(old[1]):
83 raise ValueError(
84 f"Sekiro slot {i + 1} does not match the menu SteamID64."
85 )
86 out[at : at + 8] = packed
87 _seal(out, entry)
88 elif game == "ds3":
89 entry = entries[10]
90 blob = data[entry.offset : entry.offset + entry.size]
91 plain = bytearray(
92 Cipher(algorithms.AES(DS3_KEY), modes.CBC(blob[16:32]))
93 .decryptor()
94 .update(blob[16:])
95 )
96 if len(plain) < 28 or u32(plain, 16) is None:
97 raise ValueError("Dark Souls III menu block is not a complete AES payload.")
98 plain[24:32] = packed
99 out[entry.offset + 16 : entry.offset + entry.size] = _encrypt(
100 DS3_KEY, blob[16:32], plain
101 )
102 _seal(out, entry)
103 elif game in DS2_GAMES:
104 entry = entries[0]
105 blob = data[entry.offset : entry.offset + entry.size]
106 key = DS2_KEY if game == "ds2sotfs" else DS2_VANILLA_KEY
107 plain = bytearray(
108 Cipher(algorithms.AES(key), modes.CBC(blob[16:32]))
109 .decryptor()
110 .update(blob[32:])
111 )
112 length = u32(plain, 0)
113 if length is None or not 0 < length <= len(plain) - 4:
114 raise ValueError("Dark Souls II header is not a complete AES payload.")
115 at = 4 + DS2_STEAM_ID_OFF
116 if at + DS2_STEAM_ID_LEN > 4 + length:
117 raise ValueError("Dark Souls II SteamID64 field falls outside the header.")
118 plain[at : at + DS2_STEAM_ID_LEN] = f"{steam_id:016x}".encode("ascii")
119 out[entry.offset + 32 : entry.offset + entry.size] = _encrypt(
120 key, blob[16:32], plain
121 )
122 _seal(out, entry)
123 elif game == "er":
124 raise ValueError(
125 "Elden Ring repeats SteamID64 inside variable-length active slots; "
126 "this editor will not change only the menu copy."
127 )
128 else:
129 raise ValueError(f"SteamID64 editing is not supported for {game}.")
130
131 _verify(out, game, steam_id=steam_id, active=active if game == "sdt" else None)
132 return bytes(out)
133
134
135## @brief Clear ER slots absent from the roster, or every slot not explicitly retained.
136def trim_er_slots(data, keep_slots=None):
137 """Return a verified archive copy with selected ER profiles and slots zeroed."""
138 keep = None if keep_slots is None else set(keep_slots)
139 if keep is not None and (
140 not keep
141 or any(not isinstance(i, int) or not 1 <= i <= ER_SLOT_COUNT for i in keep)
142 ):
143 raise ValueError(
144 "--trim-slots needs one or more slot numbers from 1 through 10."
145 )
146 out, entries, game = _checked_save(data)
147 if game != "er" or len(entries) < ER_SLOT_COUNT + 1:
148 raise ValueError("--trim-slots currently supports Elden Ring saves only.")
149 if any(entries[i].size != ER_SLOT_ENTRY_SIZE for i in range(ER_SLOT_COUNT)):
150 raise ValueError("Elden Ring slot entries do not have the verified fixed size.")
151
152 menu = entries[10]
153 menu_data = data[menu.offset : menu.offset + menu.size]
154 length = u32(menu_data, ER_MENU_LEN_OFF)
155 profile = ER_MENU_DATA_OFF + (length if length is not None else -1) + ER_SLOT_COUNT
156 if length is None or profile + ER_SLOT_COUNT * ER_PROFILE_STRIDE > len(menu_data):
157 raise ValueError("Elden Ring roster layout is incomplete; refusing to trim it.")
158 roster = er_roster(menu_data)
159 if len(roster) != ER_SLOT_COUNT:
160 raise ValueError("Elden Ring roster is incomplete; refusing to trim it.")
161
162 empty = {
163 i
164 for i in range(ER_SLOT_COUNT)
165 if (keep is None and not roster[i][0])
166 or (keep is not None and i + 1 not in keep)
167 }
168
169 changed_menu = False
170 for i in empty:
171 entry = entries[i]
172 out[entry.offset + 16 : entry.offset + entry.size] = b"\0" * (entry.size - 16)
173 _seal(out, entry)
174 out[menu.offset + profile - ER_SLOT_COUNT + i] = 0
175 start = menu.offset + profile + i * ER_PROFILE_STRIDE
176 out[start : start + ER_PROFILE_STRIDE] = b"\0" * ER_PROFILE_STRIDE
177 changed_menu = True
178 if changed_menu:
179 _seal(out, menu)
180 _verify(out, game, empty_slots=empty)
181 return bytes(out)
182
183
184## @brief Canonicalise inactive slots without changing any listed character.
185def trim_inactive_slots(data):
186 """Return a verified copy with inactive Sekiro or ER slots cleared."""
187 out, entries, game = _checked_save(data)
188 if game == "er":
189 return trim_er_slots(data)
190 if game == "sdt":
191 active = sdt_active_slots(data, entries, lambda b: b[16:])
192 if active is None:
193 raise ValueError(
194 "Sekiro's occupancy array is unreadable; refusing to guess slots."
195 )
196 for i in set(range(SDT_SLOT_COUNT)) - active:
197 entry = entries[i]
198 out[entry.offset + 16 : entry.offset + entry.size] = b"\0" * (
199 entry.size - 16
200 )
201 _seal(out, entry)
202 else:
203 raise ValueError("--trim-inactive supports Sekiro and Elden Ring only.")
204 _verify(out, game)
205 return bytes(out)
206
207
208## @brief Check the edited archive's MD5 wrappers and operation-specific invariants.
209def _verify(data, game, steam_id=None, active=None, empty_slots=None):
210 entries = parse_bnd4(data)
211 if detect_game(data, entries) != game or not all(
212 checksum_ok(data, e) for e in entries
213 ):
214 raise ValueError("Edited save failed its BND4 integrity check.")
215 if steam_id is not None:
216 owner = steam_owner(data, entries, game)
217 if owner is None or int(owner[1]) != steam_id:
218 raise ValueError("Edited save did not retain the requested SteamID64.")
219 if active is not None:
220 for i in active:
221 e = entries[i]
222 at = e.offset + 16 + SDT_STEAM_OFF
223 if int.from_bytes(data[at : at + 8], "little") != steam_id:
224 raise ValueError(
225 f"Edited Sekiro slot {i + 1} has a mismatched SteamID64."
226 )
227 if empty_slots is not None:
228 menu = entries[10]
229 roster = er_roster(data[menu.offset : menu.offset + menu.size])
230 for i in empty_slots:
231 if i >= len(roster) or roster[i][0]:
232 raise ValueError(f"Edited Elden Ring slot {i + 1} is still active.")
233 e = entries[i]
234 if any(data[e.offset + 16 : e.offset + e.size]):
235 raise ValueError(
236 f"Edited Elden Ring slot {i + 1} still has payload data."
237 )
_seal(data, entry)
Recalculate an entry's outer MD5 after its payload has changed.
Definition edit.py:42
_encrypt(key, iv, plaintext)
Encrypt a complete AES-CBC plaintext using the entry's unchanged IV.
Definition edit.py:50
_checked_save(data)
Return a mutable, checksum-verified BND4 save and its identified game.
Definition edit.py:27
_verify(data, game, steam_id=None, active=None, empty_slots=None)
Check the edited archive's MD5 wrappers and operation-specific invariants.
Definition edit.py:209