SL2 Analyzer
Read a FromSoftware .sl2 save and report what is in it
Loading...
Searching...
No Matches
er.py
Go to the documentation of this file.
1"""Elden Ring."""
2
3import json
4import os
5from collections import OrderedDict, defaultdict
6
7from .reader import is_valid_name, read_utf16, u8, u32
8
9## @brief Offset of the GaItem array inside an ER slot: past the 16-byte checksum,
10# the version and map-id words, and the 16 bytes after them.
11#
12# **Measured, and it is 0x30 rather than the 0x20 this once used.** A 16-byte error
13# here does not read 16 bytes of nonsense and recover — the entries are
14# variable-length (a weapon is 21 bytes, armour 16, everything else 8), so the walk
15# takes its tail length from a misread id and drifts for the rest of the array. The
16# anchor that pins it is the handle column: from 0x30 the handles run **strictly
17# sequential** for hundreds of entries (0xc080008b, 0x8c, 0x8d, ...), which is not
18# something a misaligned read produces.
19#
20# The check that settles it is the category nibble. Only 0x0/0x1/0x2/0x4/0x8 are
21# real categories and 0xF is the empty-slot marker, so any other nibble is proof the
22# walk has lost its place. Across 182 characters: **148 of them carried illegal
23# nibbles at 0x20, and none at all at 0x30** — with naming going 89.0% to 98.8% at
24# the same time.
25ER_GAITEM_START = 0x30
26
27
28## @brief Number of GaItem entries in the array.
29ER_GAITEM_COUNT = 0x1400
30
31
32## @brief In the menu (header) entry: offset of the variable-length menu-system
33# block's length field, the byte after which its data begins, the number
34# of character slots, the size of one profile summary, and the profile
35# field offsets for name and level. Layout per ClayAmore/ER-Save-Editor.
36ER_MENU_LEN_OFF, ER_MENU_DATA_OFF = 352, 356
37
38
39ER_SLOT_COUNT, ER_PROFILE_STRIDE = 10, 588
40
41
42ER_PROFILE_NAME_LEN, ER_PROFILE_LEVEL_OFF = 16, 34
43
44
45## @brief ER stat block as signed distances from the Vigor field (the anchor).
46# Eight attributes in the game's storage order, read against a real level-266
47# save (offsets checked on a second character in the same file).
48ER_STAT_D = OrderedDict(
49 [
50 ("Vigor", 0),
51 ("Mind", 4),
52 ("Endurance", 8),
53 ("Strength", 12),
54 ("Dexterity", 16),
55 ("Intelligence", 20),
56 ("Faith", 24),
57 ("Arcane", 28),
58 ]
59)
60
61
62## @brief ER max HP, stamina, rune level and runes held, same anchor-relative scheme.
63# (The block also carries FP just before stamina; not surfaced.)
64ER_HP_D, ER_STAM_D, ER_LEVEL_D, ER_RUNES_D = -40, -12, 44, 48
65
66
67## @brief ER's rune-level identity: level == (sum of the eight attributes) - 79.
68# Wretch (all 10, sum 80) is level 1, and it holds at every level — the content
69# check that pins the stat block, whose slot offset varies from character to
70# character (variable-length data precedes it, so a fixed offset will not do).
71ER_LEVEL_BASE = 79
72
73
74## @brief The highest rune level the identity can produce: eight attributes at 99
75# sum to 792, minus @ref ER_LEVEL_BASE. A roster level past it is not a level.
76ER_LEVEL_MAX = 8 * 99 - ER_LEVEL_BASE
77
78
79##
80# @brief Read the ER character roster (active flag, name, level per slot).
81# @details Walks past the fixed header and the variable-length menu-system block
82# to reach the active-slot bytes and the fixed-stride profile summaries. Names and
83# levels here are reliable; they are the load screen's own data.
84# @param menu The header entry blob (from its start, checksum included).
85# @return A list of @c (active, name, level) tuples, one per slot.
86def er_roster(menu):
87 length = u32(menu, ER_MENU_LEN_OFF)
88 if length is None:
89 return []
90 active_base = ER_MENU_DATA_OFF + length
91 pbase = active_base + ER_SLOT_COUNT
92 out = []
93 for i in range(ER_SLOT_COUNT):
94 active = bool(u8(menu, active_base + i))
95 base = pbase + i * ER_PROFILE_STRIDE
96 name = read_utf16(menu, base, ER_PROFILE_NAME_LEN)
97 level = u32(menu, base + ER_PROFILE_LEVEL_OFF)
98 out.append((active, name, level))
99 return out
100
101
102## @brief The ids that mean "this slot is empty", not "the character owns this".
103# @details Elden Ring fills an unused equipment slot with a real row rather than a
104# zero, and the game's own name tables name them: weapon row 110000 is **Unarmed**,
105# and armour rows 10000/10100/10200/10300 are the bare **Head/Body/Arms/Legs**. They
106# are in the GaItem array of every character alive, so listing them as owned gear says
107# "carries: Unarmed, Arms, Legs" about someone wearing a full set. Skipped, and not
108# counted as unrecognised either — they are recognised perfectly well, they just are
109# not items. Stored as save ids, category nibble included.
110ER_EMPTY_SLOT_IDS = frozenset(
111 {0x0001ADB0, 0x10002710, 0x10002774, 0x100027D8, 0x1000283C}
112)
113
114
115##
116# @brief Walk the ER GaItem array and yield every owned item id.
117# @details Each GaItem is 8 bytes (handle + id) plus a variable tail decided by
118# the id's category nibble: weapons (0x0) carry 13 more bytes, armour (0x1) 8
119# more, everything else none. Getting that tail right is what keeps the walk
120# aligned across all 0x1400 entries.
121# @param buf The ER slot data, checksum and all — @ref ER_GAITEM_START skips it.
122# @return A generator of nonzero item ids.
123def er_gaitems(buf):
124 o = ER_GAITEM_START
125 for _ in range(ER_GAITEM_COUNT):
126 if o + 8 > len(buf):
127 return
128 iid = u32(buf, o + 4)
129 o += 8
130 if iid:
131 cat = iid & 0xF0000000
132 if cat == 0x00000000:
133 o += 13
134 elif cat == 0x10000000:
135 o += 8
136 yield iid
137
138
139##
140# @brief Locate the ER stat block by content, or None if none validates.
141# @details The block sits at a different offset in every slot, so it is found rather
142# than read from a fixed spot. The search is anchored on the level field: every place
143# the slot stores @p level as a little-endian uint32 is a candidate, the block is read
144# back from there, and it is accepted only where each of the eight attributes is 1..99
145# and their sum minus @ref ER_LEVEL_BASE equals that level. ER's own level formula, so
146# a coincidental match is not credible.
147#
148# **The block is NOT 4-aligned.** Variable-length data precedes it — the character name
149# among it — so its offset is whatever that data leaves it at. Scanning on a 4-byte
150# stride finds only the quarter of characters that happen to land on it: across a
151# 182-character corpus a strided scan found 29 blocks where this finds all 182.
152#
153# **The roster level is required, and it is what makes the answer trustworthy.** The
154# identity alone false-positives: on that same corpus five slots matched a run of bytes
155# megabytes past the real block and reported levels of 2, 13, 22 and 25 for characters
156# the roster puts at 125, 150 and 160. Checking against the independently stored roster
157# level kills all five. Without a roster level there is nothing to check against, so
158# the slot keeps its inventory tier rather than printing a number that might be junk.
159# @param buf The ER slot data.
160# @param level The character's rune level from the roster.
161# @return The Vigor-field offset (the anchor), or None.
162def er_find_stats(buf, level):
163 if level is None or not (1 <= level <= ER_LEVEL_MAX):
164 return None
165 dists = list(ER_STAT_D.values())
166 pat = level.to_bytes(4, "little")
167 at = buf.find(pat)
168 while at >= 0:
169 v = at - ER_LEVEL_D
170 if v >= 0:
171 vals = [u32(buf, v + d) for d in dists]
172 if (
173 all(x is not None and 1 <= x <= 99 for x in vals)
174 and sum(vals) - ER_LEVEL_BASE == level
175 ):
176 return v
177 at = buf.find(pat, at + 1)
178 return None
179
180
181##
182# @brief Parse one ER slot into the unified dict (full where stats validate).
183# @details Owned items come from the GaItem walk resolved against the id table;
184# ids may carry category bits, so a direct hit is tried first, then the masked id.
185# Bosses are inferred from Remembrances held. Attributes are *located by content*
186# (@ref er_find_stats) — the block's slot offset varies, so it is found by the
187# rune-level identity, not a fixed offset. When it validates the slot is full
188# tier; otherwise stats drop and it stays inventory tier (the roster level still
189# stands). Quantities and the reinforced-weapon base ids are still not read.
190# @param buf The ER slot data.
191# @param iddb Flat @c {id: name} table.
192# @param name The character name from the roster, or None.
193# @param level The character level from the roster, or None.
194# @return A unified character dict, or None.
195def er_parse(buf, iddb, name, level):
196 buckets, unknown = defaultdict(set), 0
197 for iid in er_gaitems(buf):
198 if iid in ER_EMPTY_SLOT_IDS:
199 continue
200 nm, cat = er_resolve(iid, iddb)
201 if nm:
202 buckets[cat].add(nm)
203 elif cat:
204 unknown += 1
205 if not any(buckets.values()):
206 return None
207 inv = {c: [(n, None) for n in sorted(v)] for c, v in buckets.items()}
208 remembrances = [
209 (n, None) for c in buckets for n in sorted(buckets[c]) if "Remembrance" in n
210 ]
211 v = er_find_stats(buf, level)
212 stats = (
213 OrderedDict((k, u32(buf, v + d)) for k, d in ER_STAT_D.items())
214 if v is not None
215 else OrderedDict()
216 )
217 return {
218 "tier": "full" if stats else "inventory",
219 "game": "er",
220 "name": name if (name and is_valid_name(name)) else "(unnamed slot)",
221 "klass": None,
222 "stats": stats,
223 "soul_memory": None,
224 "humanity": None,
225 "ng_plus": None,
226 "level": u32(buf, v + ER_LEVEL_D) if v is not None else level,
227 "souls": u32(buf, v + ER_RUNES_D) if v is not None else None,
228 "stamina": u32(buf, v + ER_STAM_D) if v is not None else None,
229 "hp": u32(buf, v + ER_HP_D) if v is not None else None,
230 "boss_souls": remembrances,
231 "key_items": [],
232 "inv": inv,
233 "unknown_count": unknown,
234 }
235
236
237## @brief ER item category by id top nibble (the ItemGib type code), and the render
238# category each maps to. Weapon (0x0), Protector/armour (0x1), Accessory/talisman
239# (0x2), Goods (0x4), Gem/Ash of War (0x8). The nibble the GaItem walk already
240# trusts for its tail length is the item TYPE, so it also scopes name resolution —
241# the fix for the old flat lookup that collided base ids across types (~20% wrong).
242ER_CAT = {0x0: "weapons", 0x1: "armors", 0x2: "talismans", 0x4: "goods", 0x8: "ashes"}
243
244
245## @brief ER db category files (one per type), each @c {8-hex-id: name}.
246ER_DB_FILES = tuple(ER_CAT.values())
247
248
249## @brief Weapon ids bake affinity+reinforcement into the low digits: the id is
250# @c base + affinity*100 + level, base spaced by @ref ER_WEAPON_BASE_STEP. So
251# `id % 100` is the reinforcement level, `id - id % 100` is the affinity row (which
252# is what the table names), and `id - id % 10000` is the plain base.
253ER_WEAPON_BASE_STEP = 10000
254ER_WEAPON_AFFINITY_STEP = 100
255
256
257## @brief Highest reinforcement ER allows (+25 on the standard path; somber stops at
258# +10). A low-digit remainder above this is not a level, so no level is claimed.
259ER_MAX_REINFORCE = 25
260
261
262##
263# @brief Load the ER id tables, category-scoped: @c {category: {id: name}}.
264# @param db_dir Folder holding one JSON per category (weapons/armors/…).
265# @return The lookup, or {} if none present.
266def load_er_db(db_dir):
267 db = {}
268 for cat in ER_DB_FILES:
269 try:
270 with open(os.path.join(db_dir, cat + ".json"), encoding="utf-8") as f:
271 db[cat] = {int(k, 16): v for k, v in json.load(f).items()}
272 except (OSError, ValueError):
273 continue
274 return db
275
276
277##
278# @brief Resolve an ER item id to (name, category), type-scoped by its nibble.
279# @details The category comes from the id's top nibble (@ref ER_CAT); the name is
280# looked up ONLY in that category's table, so an armour id can never resolve to a
281# weapon of the same base number. A weapon miss then strips the reinforcement level
282# to land on the affinity row the table actually names ("Sacred Butchering Knife"),
283# and only failing that falls back to the plain base. The level is appended, so a
284# reinforced weapon reads as itself rather than as its unupgraded twin.
285# @return @c (name, category); name is None when unresolved, category None when the
286# nibble is not a known type.
287def er_resolve(iid, db):
288 cat = ER_CAT.get((iid >> 28) & 0xF)
289 if cat is None:
290 return None, None
291 table = db.get(cat, {})
292 name = table.get(iid)
293 if name is not None or cat != "weapons":
294 return name, cat
295 level = iid % ER_WEAPON_AFFINITY_STEP
296 if level > ER_MAX_REINFORCE:
297 level = 0
298 for step in (ER_WEAPON_AFFINITY_STEP, ER_WEAPON_BASE_STEP):
299 name = table.get(iid - iid % step)
300 if name is not None:
301 return (f"{name} +{level}" if level else name), cat
302 return None, cat
er_gaitems(buf)
Walk the ER GaItem array and yield every owned item id.
Definition er.py:123
er_find_stats(buf, level)
Locate the ER stat block by content, or None if none validates.
Definition er.py:162
er_resolve(iid, db)
Resolve an ER item id to (name, category), type-scoped by its nibble.
Definition er.py:287