@zebbedaja/er-save-parser 0.0.5 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/index.d.mts +1 -2
- package/dist/index.mjs +161 -13
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -62,7 +62,7 @@ async function parseFromFile(file: File): Promise<Save> {
|
|
|
62
62
|
### `Save` (root)
|
|
63
63
|
| Field | Type | Description |
|
|
64
64
|
|---|---|---|
|
|
65
|
-
| `magicBytes` | `string` | Hex string,
|
|
65
|
+
| `magicBytes` | `string` | Hex string, e.g. `424e4434` (`BND4`) |
|
|
66
66
|
| `checksum` | `string` | Save file checksum |
|
|
67
67
|
| `version` | `number` | Save file version |
|
|
68
68
|
| `steamId` | `string` | Associated Steam64 ID |
|
|
@@ -155,7 +155,7 @@ try {
|
|
|
155
155
|
```
|
|
156
156
|
|
|
157
157
|
The parser will throw if:
|
|
158
|
-
- The file magic bytes don't match `BND4`
|
|
158
|
+
- The file magic bytes don't match `BND4` or `SL2\x00`
|
|
159
159
|
- Event flags reference blocks not found in the BST map
|
|
160
160
|
- Calculated byte positions exceed save data bounds
|
|
161
161
|
|
package/dist/index.d.mts
CHANGED
|
@@ -139,7 +139,6 @@ interface EventFlag {
|
|
|
139
139
|
}
|
|
140
140
|
//#endregion
|
|
141
141
|
//#region src/parser.d.ts
|
|
142
|
-
declare function fn(): string;
|
|
143
142
|
declare function parse(buffer: ArrayBuffer): Save;
|
|
144
143
|
//#endregion
|
|
145
|
-
export { Character, EventFlag, ProfileSummary, Save, Settings, Slot,
|
|
144
|
+
export { type Character, type EventFlag, type ProfileSummary, type Save, type Settings, type Slot, parse };
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
//#region src/util.ts
|
|
2
|
+
/**
|
|
3
|
+
* Compare two ArrayBuffers for byte-by-byte equality.
|
|
4
|
+
*
|
|
5
|
+
* @param buf1 - The first ArrayBuffer to compare
|
|
6
|
+
* @param buf2 - The second ArrayBuffer to compare
|
|
7
|
+
* @returns True if both buffers have identical byte content
|
|
8
|
+
*/
|
|
2
9
|
function arrayBuffersEqual(buf1, buf2) {
|
|
3
10
|
if (buf1.byteLength !== buf2.byteLength) return false;
|
|
4
11
|
const view1 = new Uint8Array(buf1);
|
|
@@ -6,25 +13,58 @@ function arrayBuffersEqual(buf1, buf2) {
|
|
|
6
13
|
for (let i = 0; i < view1.length; i++) if (view1[i] !== view2[i]) return false;
|
|
7
14
|
return true;
|
|
8
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Convert a string into an array of byte values.
|
|
18
|
+
*
|
|
19
|
+
* @param string - The string to convert
|
|
20
|
+
* @returns An array of ASCII/Unicode byte values for each character
|
|
21
|
+
*/
|
|
9
22
|
function stringToBytes(string) {
|
|
10
23
|
return [...string].map((character) => character.charCodeAt(0));
|
|
11
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Convert an ArrayBuffer to a lowercase hexadecimal string.
|
|
27
|
+
*
|
|
28
|
+
* @param buffer - The ArrayBuffer to convert
|
|
29
|
+
* @returns A lowercase hexadecimal string representation of the buffer
|
|
30
|
+
*/
|
|
12
31
|
function toHexString(buffer) {
|
|
13
32
|
const bytes = new Uint8Array(buffer);
|
|
14
33
|
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
15
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Remove all null ('\x00') characters from a string.
|
|
37
|
+
*
|
|
38
|
+
* @param text - The string to clean
|
|
39
|
+
* @returns The input string with all null ('\x00') characters removed
|
|
40
|
+
*/
|
|
16
41
|
const trim = (text) => {
|
|
17
42
|
return text?.replaceAll("\0", "");
|
|
18
43
|
};
|
|
19
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Parse a string of delimiter-separated "key,value" pairs into a Map.
|
|
46
|
+
*
|
|
47
|
+
* @param text - A string of delimiter-separated pairs, one per line
|
|
48
|
+
* @param delimiter - The character separating key and value (default: ",")
|
|
49
|
+
* @returns A Map with numeric keys and values parsed from the input
|
|
50
|
+
*/
|
|
51
|
+
const parseToMap = (text, delimiter = ",") => {
|
|
20
52
|
const map = /* @__PURE__ */ new Map();
|
|
21
53
|
const lines = text.trim().split("\n");
|
|
22
54
|
for (const line of lines) {
|
|
23
|
-
const [key, value] = line.trim().split(
|
|
55
|
+
const [key, value] = line.trim().split(delimiter);
|
|
24
56
|
if (key && value !== void 0) map.set(Number(key), Number(value));
|
|
25
57
|
}
|
|
26
58
|
return map;
|
|
27
59
|
};
|
|
60
|
+
/**
|
|
61
|
+
* Determine whether a specific event flag is set.
|
|
62
|
+
*
|
|
63
|
+
* @param bstMap - A map of block IDs to their binary offsets
|
|
64
|
+
* @param eventFlags - The raw event_flags byte array from the save data
|
|
65
|
+
* @param eventId - The event ID to check
|
|
66
|
+
* @returns True if the event flag is set (active), false otherwise
|
|
67
|
+
*/
|
|
28
68
|
const getEventFlagState = (bstMap, eventFlags, eventId) => {
|
|
29
69
|
const FLAG_DIVISOR = 1e3;
|
|
30
70
|
const BLOCK_SIZE = 125;
|
|
@@ -42,6 +82,11 @@ const getEventFlagState = (bstMap, eventFlags, eventId) => {
|
|
|
42
82
|
//#endregion
|
|
43
83
|
//#region src/event-flags.ts
|
|
44
84
|
const eventFlags = [
|
|
85
|
+
{
|
|
86
|
+
name: "Playthrough Complete: Age of Fracture",
|
|
87
|
+
id: 20,
|
|
88
|
+
category: "ending"
|
|
89
|
+
},
|
|
45
90
|
{
|
|
46
91
|
name: "Playthrough Complete: Age of Stars",
|
|
47
92
|
id: 21,
|
|
@@ -52,6 +97,111 @@ const eventFlags = [
|
|
|
52
97
|
id: 22,
|
|
53
98
|
category: "ending"
|
|
54
99
|
},
|
|
100
|
+
{
|
|
101
|
+
name: "Story: Start",
|
|
102
|
+
id: 100,
|
|
103
|
+
category: "story"
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: "Story: Reached Tutorial",
|
|
107
|
+
id: 101,
|
|
108
|
+
category: "story"
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: "Story: Reached Limgrave",
|
|
112
|
+
id: 102,
|
|
113
|
+
category: "story"
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "Story: Reached Roundtable Hold",
|
|
117
|
+
id: 104,
|
|
118
|
+
category: "story"
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "Story: Roundtable Hold Introduction",
|
|
122
|
+
id: 105,
|
|
123
|
+
category: "story"
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: "Story: Received the Frenzied Flame",
|
|
127
|
+
id: 108,
|
|
128
|
+
category: "story"
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: "Story: Received the Frenzied Flame (back)",
|
|
132
|
+
id: 109,
|
|
133
|
+
category: "story"
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: "Story: Reached Forge of the Giants",
|
|
137
|
+
id: 110,
|
|
138
|
+
category: "story"
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "Story: Forge of the Giants - Melina A",
|
|
142
|
+
id: 111,
|
|
143
|
+
category: "story"
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: "Story: Forge of the Giants - Melina B",
|
|
147
|
+
id: 112,
|
|
148
|
+
category: "story"
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: "Story: Ranni",
|
|
152
|
+
id: 114,
|
|
153
|
+
category: "story"
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: "Story: Frenzied Flame Nullified",
|
|
157
|
+
id: 116,
|
|
158
|
+
category: "story"
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: "Story: Erdtree on Fire",
|
|
162
|
+
id: 118,
|
|
163
|
+
category: "story"
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
name: "Story: Watched Ending",
|
|
167
|
+
id: 120,
|
|
168
|
+
category: "story"
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
name: "Acquired Godrick's Great Rune",
|
|
172
|
+
id: 191,
|
|
173
|
+
category: "great-rune"
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: "Acquired Radahn's Great Rune",
|
|
177
|
+
id: 192,
|
|
178
|
+
category: "great-rune"
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: "Acquired Morgott's Great Rune",
|
|
182
|
+
id: 193,
|
|
183
|
+
category: "great-rune"
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: "Acquired Rykard's Great Rune",
|
|
187
|
+
id: 194,
|
|
188
|
+
category: "great-rune"
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: "Acquired Mohg's Great Rune",
|
|
192
|
+
id: 195,
|
|
193
|
+
category: "great-rune"
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: "Acquired Malenia's Great Rune",
|
|
197
|
+
id: 196,
|
|
198
|
+
category: "great-rune"
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
name: "Acquired Great Rune of the Unborn",
|
|
202
|
+
id: 197,
|
|
203
|
+
category: "great-rune"
|
|
204
|
+
},
|
|
55
205
|
{
|
|
56
206
|
id: 10000800,
|
|
57
207
|
name: "Godrick the Grafted",
|
|
@@ -754,12 +904,6 @@ const eventFlags = [
|
|
|
754
904
|
category: "boss",
|
|
755
905
|
location: "Church of Vows"
|
|
756
906
|
},
|
|
757
|
-
{
|
|
758
|
-
id: 1037510800,
|
|
759
|
-
name: "Ancient Dragon Lansseax",
|
|
760
|
-
category: "boss",
|
|
761
|
-
location: "Altus Plateau"
|
|
762
|
-
},
|
|
763
907
|
{
|
|
764
908
|
id: 1037530800,
|
|
765
909
|
name: "Demi-Human Queen Maggie",
|
|
@@ -850,6 +994,12 @@ const eventFlags = [
|
|
|
850
994
|
category: "boss",
|
|
851
995
|
location: "Altus Plateau"
|
|
852
996
|
},
|
|
997
|
+
{
|
|
998
|
+
id: 1041520800,
|
|
999
|
+
name: "Ancient Dragon Lansseax",
|
|
1000
|
+
category: "boss",
|
|
1001
|
+
location: "Altus Plateau"
|
|
1002
|
+
},
|
|
853
1003
|
{
|
|
854
1004
|
id: 1041530800,
|
|
855
1005
|
name: "Wormface",
|
|
@@ -13222,9 +13372,6 @@ const bstFile = `1045540,6223
|
|
|
13222
13372
|
const USER_10_DATA_START = 26215328;
|
|
13223
13373
|
const ACTIVE_PROFILES_START = 26221828;
|
|
13224
13374
|
const SLOT_COUNT = 10;
|
|
13225
|
-
function fn() {
|
|
13226
|
-
return "Hello, tsdown!";
|
|
13227
|
-
}
|
|
13228
13375
|
function parse(buffer) {
|
|
13229
13376
|
const dataView = new DataView(buffer);
|
|
13230
13377
|
const utf16leDecoder = new TextDecoder("utf-16le");
|
|
@@ -13233,7 +13380,7 @@ function parse(buffer) {
|
|
|
13233
13380
|
const save = {};
|
|
13234
13381
|
const magicBytes = buffer.slice(offset, offset + 4);
|
|
13235
13382
|
offset += 4;
|
|
13236
|
-
if (!arrayBuffersEqual(magicBytes, new Uint8Array(stringToBytes("BND4")).buffer)) throw new Error(`File type not supported, magic bytes: ${toHexString(magicBytes)} (${utf8Decoder.decode(magicBytes)})`);
|
|
13383
|
+
if (!arrayBuffersEqual(magicBytes, new Uint8Array(stringToBytes("BND4")).buffer) && !arrayBuffersEqual(magicBytes, new Uint8Array(stringToBytes("SL2\0")).buffer)) throw new Error(`File type not supported, magic bytes: ${toHexString(magicBytes)} (${utf8Decoder.decode(magicBytes)})`);
|
|
13237
13384
|
save.magicBytes = toHexString(magicBytes);
|
|
13238
13385
|
offset += 764;
|
|
13239
13386
|
save.slots = [];
|
|
@@ -13378,6 +13525,7 @@ function parse(buffer) {
|
|
|
13378
13525
|
}
|
|
13379
13526
|
slot.regions = {};
|
|
13380
13527
|
slot.regions.regionCount = regionCount;
|
|
13528
|
+
slot.regions.regionIds = regionIds;
|
|
13381
13529
|
offset += 40;
|
|
13382
13530
|
offset += 1;
|
|
13383
13531
|
offset += 68;
|
|
@@ -13494,4 +13642,4 @@ function parse(buffer) {
|
|
|
13494
13642
|
return save;
|
|
13495
13643
|
}
|
|
13496
13644
|
//#endregion
|
|
13497
|
-
export {
|
|
13645
|
+
export { parse };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zebbedaja/er-save-parser",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.7",
|
|
5
5
|
"description": "A parser for Elden Ring save files written in TypesScript.",
|
|
6
6
|
"author": "zebbedaja",
|
|
7
7
|
"license": "MIT",
|
|
@@ -33,15 +33,15 @@
|
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@eslint/js": "^10.0.1",
|
|
36
|
-
"@types/node": "^25.
|
|
36
|
+
"@types/node": "^25.9.1",
|
|
37
37
|
"@typescript/native-preview": "7.0.0-dev.20260509.2",
|
|
38
38
|
"bumpp": "^11.1.0",
|
|
39
|
-
"eslint": "^10.
|
|
39
|
+
"eslint": "^10.4.1",
|
|
40
40
|
"eslint-config-prettier": "^10.1.8",
|
|
41
41
|
"prettier": "^3.8.3",
|
|
42
|
-
"tsdown": "^0.22.
|
|
42
|
+
"tsdown": "^0.22.1",
|
|
43
43
|
"typescript": "^6.0.3",
|
|
44
|
-
"typescript-eslint": "^8.
|
|
45
|
-
"vitest": "^4.1.
|
|
44
|
+
"typescript-eslint": "^8.60.0",
|
|
45
|
+
"vitest": "^4.1.7"
|
|
46
46
|
}
|
|
47
47
|
}
|