SL2 Analyzer
Read a FromSoftware .sl2 save and report what is in it
Loading...
Searching...
No Matches
nr.py
Go to the documentation of this file.
1"""Elden Ring Nightreign.
2
3What is read here is identity, and the module says so rather than implying more. The
4save is fully decrypted (see @ref sl2.crypto.decrypt_nr) and every entry verifies its
5own MD5, so the bytes are not in question — the layout past the roster is. Nightreign
6does not persist a character level, attributes or souls the way the other games do:
7progression lives in relics, unlocked Nightfarers and Nightlord kills, and none of
8those has been pinned against a second save yet.
9"""
10
11from .reader import is_valid_name, read_utf16, u32
12
13## @brief Number of character slots, matching the ten slot entries in the container.
14NR_SLOT_COUNT = 10
15
16
17## @brief The four-byte marker that opens each profile's appearance block.
18# @details The roster is found by this rather than by a fixed offset. Nightreign
19# writes a variable-length block ahead of the profile table exactly as Elden Ring
20# does, so an offset measured on one save is an offset measured on one save. Ten of
21# these at a constant stride is a shape nothing else in the file reproduces.
22NR_FACE_MAGIC = b"FACE"
23
24
25## @brief Bytes between one profile and the next, and where a name sits relative to
26# the appearance marker that anchors it.
27# @details Measured: the ten markers in the test save are 632 bytes apart, and the
28# name ends 54 bytes before each one. The name is UTF-16 in a 32-byte field, so
29# @ref NR_NAME_CHARS is sixteen — @ref sl2.reader.read_utf16 counts characters, not
30# bytes, and reading it as 32 would run a name into the appearance block behind it.
31NR_PROFILE_STRIDE, NR_NAME_BACK_FROM_FACE, NR_NAME_CHARS = 632, 54, 16
32
33
34## @brief Which entry carries the account, and where in it.
35# @details Entry 10 is the menu block; the SteamID64 is a little-endian uint64 eight
36# bytes in. Checked against the folder the test save shipped in — the file reads
37# 76561197960272671 and the folder is named 76561197960272671.
38NR_STEAM_ENTRY, NR_STEAM_OFF = 10, 0x08
39
40
41## @brief A slot this empty holds no character.
42# @details An unused Nightreign slot is not all zero — it carries about thirty
43# nonzero bytes of scaffolding in a megabyte. A used one is half nonzero, so the
44# threshold is nowhere near anything, and counting is cheap next to decrypting.
45NR_EMPTY_SLOT_MAX_NONZERO = 4096
46
47
48##
49# @brief Locate the profile table in a decrypted menu entry.
50# @details Finds the run of @ref NR_SLOT_COUNT appearance markers spaced exactly
51# @ref NR_PROFILE_STRIDE apart and returns where the first profile's name starts. A
52# single marker proves nothing — character data elsewhere in the file could spell the
53# same four bytes — so the whole run has to hold before anything is returned.
54# @param menu The decrypted menu entry.
55# @return Offset of slot 0's name field, or None if no such run exists.
57 at = menu.find(NR_FACE_MAGIC)
58 while at >= 0:
59 if all(
60 menu[at + NR_PROFILE_STRIDE * i : at + NR_PROFILE_STRIDE * i + 4]
61 == NR_FACE_MAGIC
62 for i in range(1, NR_SLOT_COUNT)
63 ):
64 start = at - NR_NAME_BACK_FROM_FACE
65 return start if start >= 0 else None
66 at = menu.find(NR_FACE_MAGIC, at + 1)
67 return None
68
69
70##
71# @brief Read the Nightreign roster: one name per slot, empty where unused.
72# @param menu The decrypted menu entry.
73# @return A list of @ref NR_SLOT_COUNT names, each possibly None.
74def nr_roster(menu):
75 base = nr_find_profiles(menu)
76 if base is None:
77 return [None] * NR_SLOT_COUNT
78 out = []
79 for i in range(NR_SLOT_COUNT):
80 name = read_utf16(menu, base + NR_PROFILE_STRIDE * i, NR_NAME_CHARS)
81 out.append(name if name and is_valid_name(name) else None)
82 return out
83
84
85##
86# @brief Read the account this save belongs to.
87# @param menu The decrypted menu entry.
88# @return @c (low dword, high dword) of the SteamID64, or None.
89# @details Returned as two halves rather than one integer for the same reason every
90# other game here does it: a SteamID64 does not survive a JavaScript double, and the
91# two front ends have to agree byte for byte.
92def nr_steam_id(menu):
93 lo, hi = u32(menu, NR_STEAM_OFF), u32(menu, NR_STEAM_OFF + 4)
94 return None if lo is None or hi is None else (lo, hi)
95
96
97##
98# @brief Is this decrypted slot occupied?
99# @param slot The decrypted slot entry.
100# @return True if the slot holds a character.
101def nr_slot_used(slot):
102 return sum(1 for b in slot[:0x20000] if b) > NR_EMPTY_SLOT_MAX_NONZERO
103
104
105##
106# @brief Build the unified character dict for one Nightreign slot.
107# @details Identity only, and every other key is None on purpose so the writers can
108# treat this like any other character without inventing fields. The tier is
109# @c "roster", which is the whole claim: this save was opened, decrypted and its
110# character named, and nothing further is asserted.
111# @param name The roster name for this slot.
112# @return A unified character dict.
113def nr_parse(name):
114 return {
115 "tier": "roster",
116 "game": "nr",
117 "name": name if (name and is_valid_name(name)) else "(unnamed slot)",
118 "klass": None,
119 "stats": {},
120 "soul_memory": None,
121 "humanity": None,
122 "ng_plus": None,
123 "level": None,
124 "souls": None,
125 "stamina": None,
126 "hp": None,
127 "boss_souls": [],
128 "key_items": [],
129 "inv": {},
130 "unknown_count": 0,
131 }
nr_steam_id(menu)
Read the account this save belongs to.
Definition nr.py:92
nr_find_profiles(menu)
Locate the profile table in a decrypted menu entry.
Definition nr.py:56