|
SL2 Analyzer
Read a FromSoftware .sl2 save and report what is in it
|
Functions | |
| er_roster (menu) | |
| Read the ER character roster (active flag, name, level per slot). | |
| er_gaitems (buf) | |
| Walk the ER GaItem array and yield every owned item id. | |
| er_find_stats (buf, level) | |
| Locate the ER stat block by content, or None if none validates. | |
| er_parse (buf, iddb, name, level) | |
| Parse one ER slot into the unified dict (full where stats validate). | |
| load_er_db (db_dir) | |
Load the ER id tables, category-scoped: {category: {id: name}}. | |
| er_resolve (iid, db) | |
| Resolve an ER item id to (name, category), type-scoped by its nibble. | |
Variables | |
| int | ER_GAITEM_START = 0x30 |
| Offset of the GaItem array inside an ER slot: past the 16-byte checksum, the version and map-id words, and the 16 bytes after them. | |
| int | ER_GAITEM_COUNT = 0x1400 |
| Number of GaItem entries in the array. | |
| ER_MENU_LEN_OFF | |
| In the menu (header) entry: offset of the variable-length menu-system block's length field, the byte after which its data begins, the number of character slots, the size of one profile summary, and the profile field offsets for name and level. | |
| ER_MENU_DATA_OFF | |
| In the menu (header) entry: offset of the variable-length menu-system block's length field, the byte after which its data begins, the number of character slots, the size of one profile summary, and the profile field offsets for name and level. | |
| ER_SLOT_COUNT | |
| ER_PROFILE_STRIDE | |
| ER_PROFILE_NAME_LEN | |
| ER_PROFILE_LEVEL_OFF | |
| ER_STAT_D | |
| ER stat block as signed distances from the Vigor field (the anchor). | |
| ER_HP_D | |
| ER max HP, stamina, rune level and runes held, same anchor-relative scheme. | |
| ER_STAM_D | |
| ER max HP, stamina, rune level and runes held, same anchor-relative scheme. | |
| ER_LEVEL_D | |
| ER max HP, stamina, rune level and runes held, same anchor-relative scheme. | |
| ER_RUNES_D | |
| ER max HP, stamina, rune level and runes held, same anchor-relative scheme. | |
| int | ER_LEVEL_BASE = 79 |
| ER's rune-level identity: level == (sum of the eight attributes) - 79. | |
| int | ER_LEVEL_MAX = 8 * 99 - ER_LEVEL_BASE |
| The highest rune level the identity can produce: eight attributes at 99 sum to 792, minus ER_LEVEL_BASE. | |
| ER_EMPTY_SLOT_IDS | |
| The ids that mean "this slot is empty", not "the character owns this". | |
| dict | ER_CAT = {0x0: "weapons", 0x1: "armors", 0x2: "talismans", 0x4: "goods", 0x8: "ashes"} |
| ER item category by id top nibble (the ItemGib type code), and the render category each maps to. | |
| ER_DB_FILES = tuple(ER_CAT.values()) | |
ER db category files (one per type), each {8-hex-id: name}. | |
| int | ER_WEAPON_BASE_STEP = 10000 |
Weapon ids bake affinity+reinforcement into the low digits: the id is base + affinity*100 + level, base spaced by ER_WEAPON_BASE_STEP. | |
| int | ER_WEAPON_AFFINITY_STEP = 100 |
| int | ER_MAX_REINFORCE = 25 |
| Highest reinforcement ER allows (+25 on the standard path; somber stops at +10). | |
Elden Ring.
| sl2.er.er_roster | ( | menu | ) |
Read the ER character roster (active flag, name, level per slot).
Walks past the fixed header and the variable-length menu-system block to reach the active-slot bytes and the fixed-stride profile summaries. Names and levels here are reliable; they are the load screen's own data.
| menu | The header entry blob (from its start, checksum included). |
(active, name, level) tuples, one per slot. | sl2.er.er_gaitems | ( | buf | ) |
Walk the ER GaItem array and yield every owned item id.
Each GaItem is 8 bytes (handle + id) plus a variable tail decided by the id's category nibble: weapons (0x0) carry 13 more bytes, armour (0x1) 8 more, everything else none. Getting that tail right is what keeps the walk aligned across all 0x1400 entries.
| buf | The ER slot data, checksum and all — ER_GAITEM_START skips it. |
Definition at line 123 of file er.py.
Referenced by sl2.er.er_parse().
| sl2.er.er_find_stats | ( | buf, | |
| level | |||
| ) |
Locate the ER stat block by content, or None if none validates.
The block sits at a different offset in every slot, so it is found rather than read from a fixed spot. The search is anchored on the level field: every place the slot stores level as a little-endian uint32 is a candidate, the block is read back from there, and it is accepted only where each of the eight attributes is 1..99 and their sum minus ER_LEVEL_BASE equals that level. ER's own level formula, so a coincidental match is not credible.
The block is NOT 4-aligned. Variable-length data precedes it — the character name among it — so its offset is whatever that data leaves it at. Scanning on a 4-byte stride finds only the quarter of characters that happen to land on it: across a 182-character corpus a strided scan found 29 blocks where this finds all 182.
The roster level is required, and it is what makes the answer trustworthy. The identity alone false-positives: on that same corpus five slots matched a run of bytes megabytes past the real block and reported levels of 2, 13, 22 and 25 for characters the roster puts at 125, 150 and 160. Checking against the independently stored roster level kills all five. Without a roster level there is nothing to check against, so the slot keeps its inventory tier rather than printing a number that might be junk.
| buf | The ER slot data. |
| level | The character's rune level from the roster. |
Definition at line 162 of file er.py.
Referenced by sl2.er.er_parse().
| sl2.er.er_parse | ( | buf, | |
| iddb, | |||
| name, | |||
| level | |||
| ) |
Parse one ER slot into the unified dict (full where stats validate).
Owned items come from the GaItem walk resolved against the id table; ids may carry category bits, so a direct hit is tried first, then the masked id. Bosses are inferred from Remembrances held. Attributes are located by content (er_find_stats) — the block's slot offset varies, so it is found by the rune-level identity, not a fixed offset. When it validates the slot is full tier; otherwise stats drop and it stays inventory tier (the roster level still stands). Quantities and the reinforced-weapon base ids are still not read.
| buf | The ER slot data. |
| iddb | Flat {id: name} table. |
| name | The character name from the roster, or None. |
| level | The character level from the roster, or None. |
Definition at line 195 of file er.py.
References sl2.er.er_find_stats(), sl2.er.er_gaitems(), and sl2.er.er_resolve().
| sl2.er.load_er_db | ( | db_dir | ) |
| sl2.er.er_resolve | ( | iid, | |
| db | |||
| ) |
Resolve an ER item id to (name, category), type-scoped by its nibble.
The category comes from the id's top nibble (ER_CAT); the name is looked up ONLY in that category's table, so an armour id can never resolve to a weapon of the same base number. A weapon miss then strips the reinforcement level to land on the affinity row the table actually names ("Sacred Butchering Knife"), and only failing that falls back to the plain base. The level is appended, so a reinforced weapon reads as itself rather than as its unupgraded twin.
(name, category); name is None when unresolved, category None when the nibble is not a known type. Definition at line 287 of file er.py.
Referenced by sl2.er.er_parse().
| int sl2.er.ER_GAITEM_START = 0x30 |
Offset of the GaItem array inside an ER slot: past the 16-byte checksum, the version and map-id words, and the 16 bytes after them.
Measured, and it is 0x30 rather than the 0x20 this once used. A 16-byte error here does not read 16 bytes of nonsense and recover — the entries are variable-length (a weapon is 21 bytes, armour 16, everything else 8), so the walk takes its tail length from a misread id and drifts for the rest of the array. The anchor that pins it is the handle column: from 0x30 the handles run strictly sequential for hundreds of entries (0xc080008b, 0x8c, 0x8d, ...), which is not something a misaligned read produces.
The check that settles it is the category nibble. Only 0x0/0x1/0x2/0x4/0x8 are real categories and 0xF is the empty-slot marker, so any other nibble is proof the walk has lost its place. Across 182 characters: 148 of them carried illegal nibbles at 0x20, and none at all at 0x30 — with naming going 89.0% to 98.8% at the same time.
| int sl2.er.ER_GAITEM_COUNT = 0x1400 |
| sl2.er.ER_MENU_LEN_OFF |
In the menu (header) entry: offset of the variable-length menu-system block's length field, the byte after which its data begins, the number of character slots, the size of one profile summary, and the profile field offsets for name and level.
Layout per ClayAmore/ER-Save-Editor.
| sl2.er.ER_MENU_DATA_OFF |
In the menu (header) entry: offset of the variable-length menu-system block's length field, the byte after which its data begins, the number of character slots, the size of one profile summary, and the profile field offsets for name and level.
Layout per ClayAmore/ER-Save-Editor.
| sl2.er.ER_STAT_D |
ER stat block as signed distances from the Vigor field (the anchor).
Eight attributes in the game's storage order, read against a real level-266 save (offsets checked on a second character in the same file).
| sl2.er.ER_HP_D |
| sl2.er.ER_STAM_D |
| sl2.er.ER_LEVEL_D |
| sl2.er.ER_RUNES_D |
| int sl2.er.ER_LEVEL_BASE = 79 |
ER's rune-level identity: level == (sum of the eight attributes) - 79.
Wretch (all 10, sum 80) is level 1, and it holds at every level — the content check that pins the stat block, whose slot offset varies from character to character (variable-length data precedes it, so a fixed offset will not do).
| int sl2.er.ER_LEVEL_MAX = 8 * 99 - ER_LEVEL_BASE |
| sl2.er.ER_EMPTY_SLOT_IDS |
The ids that mean "this slot is empty", not "the character owns this".
Elden Ring fills an unused equipment slot with a real row rather than a zero, and the game's own name tables name them: weapon row 110000 is Unarmed, and armour rows 10000/10100/10200/10300 are the bare Head/Body/Arms/Legs. They are in the GaItem array of every character alive, so listing them as owned gear says "carries: Unarmed, Arms, Legs" about someone wearing a full set. Skipped, and not counted as unrecognised either — they are recognised perfectly well, they just are not items. Stored as save ids, category nibble included.
| dict sl2.er.ER_CAT = {0x0: "weapons", 0x1: "armors", 0x2: "talismans", 0x4: "goods", 0x8: "ashes"} |
ER item category by id top nibble (the ItemGib type code), and the render category each maps to.
Weapon (0x0), Protector/armour (0x1), Accessory/talisman (0x2), Goods (0x4), Gem/Ash of War (0x8). The nibble the GaItem walk already trusts for its tail length is the item TYPE, so it also scopes name resolution — the fix for the old flat lookup that collided base ids across types (~20% wrong).
| sl2.er.ER_DB_FILES = tuple(ER_CAT.values()) |
| int sl2.er.ER_WEAPON_BASE_STEP = 10000 |
Weapon ids bake affinity+reinforcement into the low digits: the id is base + affinity*100 + level, base spaced by ER_WEAPON_BASE_STEP.
So id % 100 is the reinforcement level, id - id % 100 is the affinity row (which is what the table names), and id - id % 10000 is the plain base.