5from cryptography.hazmat.primitives.ciphers
import Cipher, algorithms, modes
8from .convert
import DS2_STEAM_ID_LEN, DS2_STEAM_ID_OFF, STEAM_ID64_HIGH, steam_owner
20from .sdt
import SDT_MENU_ENTRY, SDT_SLOT_COUNT, SDT_STEAM_OFF, sdt_active_slots
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)]
33 "Refusing to edit a save with bad BND4 checksum(s): " +
", ".join(bad)
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
55def set_steam_id(data, steam_id):
56 """Return a verified edited copy; never changes @p data in place."""
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.")
65 old = steam_owner(data, entries, game)
67 raise ValueError(f
"{game} has no verified writable SteamID64 field.")
68 packed = steam_id.to_bytes(8,
"little")
71 active = sdt_active_slots(data, entries,
lambda b: b[16:])
74 "Sekiro's occupancy array is unreadable; refusing to guess slots."
76 menu = entries[SDT_MENU_ENTRY]
77 out[menu.offset + 16 + 0x24 : menu.offset + 16 + 0x2C] = packed
81 at = entry.offset + 16 + SDT_STEAM_OFF
82 if int.from_bytes(data[at : at + 8],
"little") != int(old[1]):
84 f
"Sekiro slot {i + 1} does not match the menu SteamID64."
86 out[at : at + 8] = packed
90 blob = data[entry.offset : entry.offset + entry.size]
92 Cipher(algorithms.AES(DS3_KEY), modes.CBC(blob[16:32]))
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.")
99 out[entry.offset + 16 : entry.offset + entry.size] =
_encrypt(
100 DS3_KEY, blob[16:32], plain
103 elif game
in DS2_GAMES:
105 blob = data[entry.offset : entry.offset + entry.size]
106 key = DS2_KEY
if game ==
"ds2sotfs" else DS2_VANILLA_KEY
108 Cipher(algorithms.AES(key), modes.CBC(blob[16:32]))
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
125 "Elden Ring repeats SteamID64 inside variable-length active slots; "
126 "this editor will not change only the menu copy."
129 raise ValueError(f
"SteamID64 editing is not supported for {game}.")
131 _verify(out, game, steam_id=steam_id, active=active
if game ==
"sdt" else None)
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 (
141 or any(
not isinstance(i, int)
or not 1 <= i <= ER_SLOT_COUNT
for i
in keep)
144 "--trim-slots needs one or more slot numbers from 1 through 10."
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.")
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.")
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)
172 out[entry.offset + 16 : entry.offset + entry.size] = b
"\0" * (entry.size - 16)
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
180 _verify(out, game, empty_slots=empty)
185def trim_inactive_slots(data):
186 """Return a verified copy with inactive Sekiro or ER slots cleared."""
189 return trim_er_slots(data)
191 active = sdt_active_slots(data, entries,
lambda b: b[16:])
194 "Sekiro's occupancy array is unreadable; refusing to guess slots."
196 for i
in set(range(SDT_SLOT_COUNT)) - active:
198 out[entry.offset + 16 : entry.offset + entry.size] = b
"\0" * (
203 raise ValueError(
"--trim-inactive supports Sekiro and Elden Ring only.")
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
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:
222 at = e.offset + 16 + SDT_STEAM_OFF
223 if int.from_bytes(data[at : at + 8],
"little") != steam_id:
225 f
"Edited Sekiro slot {i + 1} has a mismatched SteamID64."
227 if empty_slots
is not None:
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.")
234 if any(data[e.offset + 16 : e.offset + e.size]):
236 f
"Edited Elden Ring slot {i + 1} still has payload data."
_verify(data, game, steam_id=None, active=None, empty_slots=None)
Check the edited archive's MD5 wrappers and operation-specific invariants.