Diablo II stores your game character on disk as a .d2s file. This is a binary file format that encodes all of the stats, items, name, and other pieces of data.
Integers are stored in little endian byte order, which is the native byte ordering on a x86 architecture Diablo II is based on.
Each .d2s file starts with a 765 byte header, after which data is of variable length.
Hex | Byte | Length | Desc |
---|---|---|---|
0x00 | 0 | 4 | Signature (0xaa55aa55) |
0x04 | 4 | 4 | Version ID |
0x08 | 8 | 4 | File size |
0x0C | 12 | 4 | Checksum |
0x10 | 16 | 4 | Active Weapon |
0x14 | 20 | 16 | Character Name |
0x24 | 36 | 1 | Character Status |
0x25 | 37 | 1 | Character Progression |
0x26 | 38 | 2 | ? |
0x28 | 40 | 1 | Character Class |
0x29 | 41 | 2 | ? |
0x2B | 43 | 1 | Level |
0x2C | 44 | 4 | ? |
0x30 | 48 | 4 | Time |
0x34 | 52 | 4 | ? |
0x38 | 56 | 64 | Hotkeys |
0x78 | 120 | 4 | Left Mouse |
0x7C | 124 | 4 | Right Mouse |
0x80 | 128 | 4 | Left Mouse (weapon switch) |
0x84 | 132 | 4 | Right Mouse (weapon switch) |
0x88 | 136 | 32 | Character Menu Appearance |
0xA8 | 168 | 3 | Difficulty |
0xAB | 171 | 4 | Map |
0xAF | 175 | 2 | ? |
0xB1 | 177 | 2 | Merc dead? |
0xB3 | 179 | 4 | Merc seed? |
0xB7 | 183 | 2 | Merc Name ID |
0xB9 | 185 | 2 | Merc Type |
0xBB | 187 | 4 | Merc Experience |
0xBF | 191 | 144 | ? |
0x14F | 335 | 298 | Quest |
0x279 | 633 | 81 | Waypoint |
0x2CA | 714 | 51 | NPC |
0x2FD | 765 | Stats | |
Items |
File version. The following values are known:
71
is 1.00 through v1.0687
is 1.07 or Expansion Set v1.0889
is standard game v1.0892
is v1.09 (both the standard game and the Expansion Set.)96
is v1.10+
To calculate the checksum set the value of it in the .d2s data to be zero and iterate through all the bytes in the data calculating a 32-bit checksum:
code in C
sum = (sum << 1) + data[i];
code in JavaScript
source: #5
const fs = require("fs");
const path = require("path");
const file = path.join(process.cwd(), "path_to_save.d2s");
function calculateSum(data) {
let sum = 0;
for (let i = 0; i < data.length; i++) {
let ch = data[i];
if (i >= 12 && i < 16) {
ch = 0;
}
ch += sum < 0;
sum = (sum << 1) + ch;
}
return sum;
}
function littleToBigEndian(number) {
return new DataView(
Int32Array.of(
new DataView(Int32Array.of(number).buffer).getUint32(0, true)
).buffer
);
}
function ashex(buffer) {
return buffer.getUint32(0, false).toString(16);
}
async function readSafeFile() {
return await new Promise((resolve, reject) => {
fs.readFile(file, (err, data) => {
if (err) return reject(err);
return resolve(data);
});
});
}
async function writeCheckSumToSafeFile(data) {
return await new Promise((resolve, reject) => {
fs.writeFile(file, data, err => {
if (err) reject(err);
resolve();
});
});
}
readSafeFile().then(data => {
const sum = calculateSum(data);
const bufferSum = littleToBigEndian(sum);
const hex = ashex(bufferSum);
const newData = data;
for (let i = 0; i < 4; i++) {
newData[12 + i] = bufferSum.getInt8(i);
}
writeCheckSumToSafeFile(newData).then(() => console.log(hex));
});
code in golang
source: https://github.com/gucio321/d2d2s/blob/66f91e2af7b3949ca7f279aae397bd8904519e2d/pkg/d2s/d2s.go#L397
// CalculateChecksum calculates a checksum and saves in a byte slice
func CalculateChecksum(data *[]byte) {
var sum uint32
for i := range *data {
sum = ((sum << 1) % math.MaxUint32) | (sum >> (int32Size*byteLen - 1))
sum += uint32((*data)[i])
}
sumBytes := make([]byte, int32Size)
binary.LittleEndian.PutUint32(sumBytes, sum)
const (
int32Size = 4
checksumPosition = 12
)
for i := 0; i < int32Size; i++ {
(*data)[checksumPosition+i] = sumBytes[i]
}
}
If the checksum is invalid, Diablo II will not open the save file.
TODO
Character names are store as an array of 16 characters which contain
a null terminated string padded with 0x00
for the remaining bytes.
Characters are stored as 8-bit ASCII, but remember that valid must
follow these rules:
- Must be 2-15 in length
- Must begin with a letter
- May contain up to one hyphen (
-
) or underscore (_
) - May contain letters
This is a 8-bit field:
Bit | Desc |
---|---|
0 | ? |
1 | ? |
2 | Hardcore |
3 | Died |
4 | ? |
5 | Expansion |
6 | ? |
7 | ? |
TODO
ID | Class |
---|---|
0 | Amazon |
1 | Sorceress |
2 | Necromancer |
3 | Paladin |
4 | Barbarian |
5 | Druid |
6 | Assassin |
This level value is visible only in character select screen and must be the same as this in Stats section.
TODO
32 byte structure which defines how the character looks in the menu Does not change in-game look
3 bytes of data that indicates which of the three difficulties the character has unlocked. Each byte is representitive of one of the difficulties. In this order: Normal, Nightmare, and Hell. Each byte is a bitfield structured like this:
7 | 6 | 5 | 4 | 3 | 2, 1, 0 |
---|---|---|---|---|---|
Active? | Unknown | Unknown | Unknown | Unknown | Which act (0-4)? |
TODO
TODO
Waypoint data starts with 2 chars "WS" and 6 unknown bytes, always = {0x01, 0x00, 0x00, 0x00, 0x50, 0x00}
Three structures are in place for each difficulty, at offsets 641, 665 and 689.
The contents of this structure are as follows
byte | bytesize | contents |
---|---|---|
0 | 2 bytes | {0x02, 0x01} Unknown purpose |
2 | 5 bytes | Waypoint bitfield in order of least significant bit |
7 | 17 bytes | unknown |
In the waypoint bitfield, a bit value of 1 means that the waypoint is enabled It is in an order from lowest to highest, so 0 is Rogue encampment (ACT I) etc. The first waypoint in each difficulty is always activated.
TODO
TODO (9-bit encoding)
Items are stored in lists described by this header:
Byte | Size | Desc |
---|---|---|
0 | 2 | "JM" |
2 | 2 | Item Count |
After this come N items. Each item starts with a basic 14-byte structure. Many fields in this structure are not "byte-aligned" and are described by their bit position and sizes.
Bit | Size | Desc |
---|---|---|
0 | 16 | "JM" (separate from the list header) |
16 | 4 | ? |
20 | 1 | Identified |
21 | 6 | ? |
27 | 1 | Socketed |
28 | 1 | ? |
29 | 1 | Picked up since last save |
30 | 2 | ? |
32 | 1 | Ear |
33 | 1 | Starter Gear |
34 | 3 | ? |
37 | 1 | Compact |
38 | 1 | Ethereal |
39 | 1 | ? |
40 | 1 | Personalized |
41 | 1 | ? |
42 | 1 | Runeword |
43 | 15 | ? |
58 | 3 | Parent |
61 | 4 | Equipped |
65 | 4 | Column |
69 | 3 | Row |
72 | 1 | ? |
73 | 3 | Stash |
76 | 4 | ? |
80 | 24 | Type code (3 letters) |
108 | Extended Item Data |
If the item is marked as Compact
(bit 37 is set) no extended
item information will exist and the item is finished.
Items with extended information store bits based on information in the item header. For example, an item marked as Socketed
will store an
extra 3-bit integer encoding how many sockets the item has.
Bit | Size | Desc |
---|---|---|
108 | Sockets | |
Custom Graphics | ||
Class Specific | ||
Quality | ||
Mods |
Custom graphics are denoted by a single bit, which if set means a 3-bit number for the graphic index follows. If the bit is not set the 3-bits are not present.
Bit | Size | Desc |
---|---|---|
0 | 1 | Item has custom graphics |
1 | 3 | Alternate graphic index |
Class items like Barbarian helms or Amazon bows have special properties specific to those kinds of items. If the first bit is empty the remaining 11 bits will not be present.
Bit | Size | Desc |
---|---|---|
0 | 1 | Item has class specific data |
1 | 11 | Class specific bits |
Item quality is encoded as a 4-bit integer.
After each item is a list of mods. The list is a series of key value
pairs where the key is a 9-bit number and the value depends on the key.
The list ends when key 511
(0x1ff
) is found which is all 9-bits
being set.
Using the file ItemStatCost.txt
as a tab-delimited CSV file you can
extract the ID
column which maps to the 9-bit keys. The columns
Save Bits
and Param Bits
describe how large the mod is.
The only exception is min-max style modifiers which use the next row in the CSV to store the "max" portion of the mod. The bit sizes of these two can be different and you should sum them to get the total size.
TODO
All items are located somewhere and have a "parent" which can be another item, such as when inserting a jewel.
Value | Desc |
---|---|
0 | Stored |
1 | Equipped |
2 | Belt |
4 | Cursor |
6 | Item |
For items that are "stored" a 3-bit integer encoded starting at bit 73 describes where to store the item:
Value | Desc |
---|---|
1 | Inventory |
4 | Horadric Cube |
5 | Stash |
Items that are equipped describe their slot:
Value | Slot |
---|---|
1 | Helmet |
2 | Amulet |
3 | Armor |
4 | Weapon (Right) |
5 | Weapon (Left) |
6 | Ring (Right) |
7 | Ring (Left) |
8 | Belt |
9 | Boots |
10 | Gloves |
11 | Alternate Weapon (Right) |
12 | Alternate Weapon (Left) |