Diablo 2 1.11 Save File

Did some playing around with the single player save game file format for Diablo II recently. There’s some other documentation on it elsewhere but I’ll put what I’ve discovered here for another take on it.

When trying to simply do a hex-edit to the file, there’s 2 major obstacles.
The first is the checksum for the file. Starting at byte offset 12 (0x0C) there is a series of 4 bytes, ending at offset 15 (0x0F). These 4 bytes will be the checksum for the file. The checksum itself is not too complicated, but keep in mind that you must NOT include the checksum bytes themselves as part of the checksum. There are some references to the particular method of checksum but In my own words I’d call it a “256 Modulus-carryover”. Each new byte gets put into the first checksum byte, but before you do any operation on the checksum bytes, you must multiply it by 2 (each iteration). If the first checksum byte becomes greater than 256, it signalsfor a carry bit to be put into the second byte (and so on). Well I’m probably making it sound harder than it is… here’s the algorithm in AutoIt (assumes a byte-array $byteA with each byte of the file loaded sequentially)-

func FixCheckSum()
dim $checksum[4]
$byteA[12]=0x00
$byteA[13]=0x00
$byteA[14]=0x00
$byteA[15]=0x00
$boolCarry=0
for $i=0 to UBound($byteA)-1
$temp=Dec(Hex(Binary($byteA[$i])))+$boolCarry
$checksum[0]= $checksum[0]*2 +$temp
$checksum[1]=$checksum[1]*2
if $checksum[0] > 255 Then
$checksum[1] = $checksum[1] + ($checksum[0] - Mod($checksum[0],256)) / 256
$checksum[0] = Mod($checksum[0],256)
EndIf
$checksum[2]=$checksum[2]*2
if $checksum[1] > 255 Then
$checksum[2] = $checksum[2] + ($checksum[1] - Mod($checksum[1],256)) / 256
$checksum[1] = Mod($checksum[1],256)
EndIf
$checksum[3]=$checksum[3]*2
if $checksum[2] > 255 Then
$checksum[3] = $checksum[3] + ($checksum[2] - Mod($checksum[2],256)) / 256
$checksum[2] = Mod($checksum[2],256)
EndIf
If $checksum[3] > 255 Then
$checksum[3] = Mod($checksum[3], 256)
EndIf
if BitAND($checksum[3],0x80)<>0 Then
$boolCarry=1
Else
$boolCarry=0
EndIf
Next
$byteA[12]="0x"&Hex($checksum[0],2)
$byteA[13]="0x"&Hex($checksum[1],2)
$byteA[14]="0x"&Hex($checksum[2],2)
$byteA[15]="0x"&Hex($checksum[3],2)
EndFunc

Actions speak louder than words sometimes 🙂

The 2nd issue to deal with is that except for single byte entries, the section of the file dealing with character stats is back-asswords…double time! Starting at offset 765 (0x2FD), you should see a two bytes- 0x67 and 0x66, this marks the header for the character stats part of the file. Now take everything after the header (not including it) all the way to the footer tag marked by the bytes 0x69 and 0x66 (not including the footer tag). This is your binary-stream. To make sense of it you’ve gotta first reverse the whole thing from a binary stream perspective. That means- convert each byte to a binary number (eg 11011101 01011010 11101101 11111100 01110111 ect ect) then do a ‘string reverse on it’ so ‘11100010 10000000’ becomes ‘0000001 01000111’. This will be the new base for parsing out data.

Once you have the reversed binary, you’ll be looking at the 9-bit stat header to find out which stat comes in the next sequence of bytes. so the binary stream is in a format like this-
“xxxxxxxx xyyyyyyy yyyxxxxx xxxxyyyy yyyyyy…” where x is the 9bit stat id, and y is the value of the stat. Kepp in mind that although the stat id is always 9-bits, the value is not always 10 bits. Here is a function that returns the number of bits in the value for a given stat id-
 
Func_StatIDtoBitCount($in)
$seekBits=0
Switch $in
Case 0 to 4
$seekBits=10
Case 5
$seekBits=8
Case 6 to 11
$seekBits=21
Case 12
$seekBits=7
Case 13
$seekBits=32
Case 14 to 15
$seekBits=25
EndSwitch
Return $seekBits
EndFunc

$in is the statID. But which ID is for which stat? We’ll here’s another function-

func_statIDToName($in)
Switch $in
Case 0x00
Return "Strength"
Case 0x01
Return "Eng"
Case 0x02
Return "Dex"
Case 0x03
Return "Vit"
Case 0x04
Return "Stat Left"
Case 0x05
Return "Skill Left"
Case 0x06
Return "Life"
Case 0x07
Return "Max Life"
Case 0x08
Return "Mana"
Case 0x09
Return "Max Mana"
Case 0x0A
Return "Stam"
Case 0x0B
Return "Max Stam"
Case 0x0C
Return "level"
Case 0x0D
Return "Exp"
Case 0x0E
Return "Gold"
Case 0x0F
Return "Stash Gold"
EndSwitch
Return "Unknown"
EndFunc

Now, the interesting twist to this goes back to what I said initially. It’s backwards twice, so once you have your revese binary stream. Grab the first 9 bits from it. Now take those 9 bits, and reverse them also. This new binary stream is the statID. Use it to look up the bits for the value. Once you have that, grab that many more bits starting from the end of the statID offset. Reverse the bits you grab again and you have your value.

A troublesome method to store the data but it quite efficient. That’s essentially all there is to the file besides the skill data, which is a story for another day perhaps… it starts after the stat data, and its really not to hard to figure out with some trial and error 🙂

Edit:
Some folks wanted some resources for this. Here’s the main pages I used when i was researching it-

These forums – http://phrozenkeep.planetdiablo.gamespy.com/forum/viewforum.php?f=8
in particular this post-
http://phrozenkeep.planetdiablo.gamespy.com/forum/viewtopic.php?t=9011
And these-
http://www.ladderhall.com/ericjwin/109/trevin/trevinfileformat.html
http://diablo2.my-spot.info/109fileformat.html
http://forums.diii.net/showthread.php?t=532037&page=41

Most of them apply to older version but not a lot changes usually with newer releases. There’s not been a drastic overall to the file structure in a long time and I don’t see why’d they’d do one now…Although I personally haven’t looked at the latest incarnation.

Edit 2: I came back and looked at this for 1.12a. Nothing has really changed. I’ll provide this picture though to help convey another method mentioned in jjscuds comment:
Exmaple
Using this method, you don’t reverse the whole binary stream, you just reverse the bits in each byte, then reverse the bits in each bit-field.

16 Responses to Diablo 2 1.11 Save File

  1. Martin says:

    Hi,

    Thank you for releasing some more information about this. I have not been able to get this working with Diablo v1.12, and I suspect that this information might be a bit out of date. How did you figure this information out? I can’t seem to decipher the new format.

    • evilertoaster says:

      Martin, Haven’t looked at 1.12 save files yet. I’d suspect they shouldn’t be too different other than the offsets but I don’t know. You can do simple experiments with your saved games to check. Start a new character, get him 5 gold and save and exit. Backup the save file, then go in and drop 1 gold. You can now compare the 2 files in a hex editor and see where the gold offset is which could give you a reference to the other things.

  2. Martin says:

    Alright, I’ll try my best 🙂

  3. Conrad says:

    I would think the offsets would be the easier thing to figure out. What about the checksums? Is trial and error the only method of figuring out the algorithm, or is there a better way to approach this (given the internet, a hex editor, some coding time, ingenuity, and a defined lack of a degree in mathematics)?

    • evilertoaster says:

      I did not discover the checksum myself. The best way to find it out without guessing would just be to look at the assembly code handling the checksum. I’ll update my post with some resources to help people along (if there’s still any interest in this).

  4. jjscud says:

    I think I’ve found a problem, or at least I was misled by the wording, with the stat part.

    This part:

    “To make sense of it you’ve gotta first reverse the whole thing from a binary stream perspective. That means- convert each byte to a binary number (eg 11011101 01011010 11101101 11111100 01110111 ect ect) then do a ’string reverse on it’ so ‘11100010 10000000′ becomes ‘0000001 01000111′.”

    makes it sound like you reverse the whole string. I believe you only reverse the bits in each byte but keep the byte order intact. So you’re example ‘11100010 10000000′ should become ‘01000111 0000001′

    It took me a lot of dropping gold and a good guess to get there but I’m fairly certain that this is correct.

    Also, just FYI, the char format hasn’t changed at all since 1.10 so this is still very good and up to date. (at least until 1.13 comes out)

    • evilertoaster says:

      Hum, this is an interesting point. I believe I’ve used my original method (reversing the bit and byte order) in a program a wrote a while ago to change character names and it worked quite well. I do remember having to reverse it ‘twice’ or something… Now you’ve got me curious. Thanks for the feedback jjscud, I’m inclined to revisit this soon as I’ve recently started playing again (casually) in anticipation of 1.13 and D3 :).

    • evilertoaster says:

      Humkay, Well I went back and looked at this again. I believe I see where the confusion is. There are multiple ways to go about decoding the stats and it kinda boils down to which ways’s easier to visualize for you. You CAN leave the byte order intact and only reverse the bit order in each byte like you said. If you do it this way, the bits will be ‘backwards’ when you read them which means your value bit-field of “010100000” would be 10. However if you do a full reverse of the bytes to start (the method used in the post) the 9bit bit-field for 10 would be “000001010”. I think I’ll amend the original post with a picture for clarification.

      • Anvar says:

        Good afternoon.

        I apologize that in so many years I lift this subject.
        It was absolutely tangled with a reverse of data.
        Here example of data. The version 1.14d, just created barbarian. We read between sivola of “gf” and “if”.

        00 3C 08 A0 80 00 0A 06 64 60 00 E0 06 1C 00 B8 01 08 00 14 40 02 00 05 A0 00 80 0B 2C 00 E0 02 0C 02 FF 01

        00000000 00111100 00001000 10100000 10000000 00000000 00001010 00000110 01100100 01100000 00000000
        11100000 00000110 00011100 00000000 10111000 00000001 00001000 00000000 00010100 01000000 00000010
        00000000 00000101 10100000 00000000 10000000 00001011 00101100 00000000 11100000 00000010 00001100
        00000010 11111111 00000001

        How it is correct to make a reverse?

        NOTE: And the picture is not displayed for a long time.

  5. benigntoaster says:

    I really don’t know how to use Autoit, so this may be a bit silly, or obvious, but it does work, so for people like me that have no clue has to how to use your code, just add this:

    $MyFile=”PutYourCharNameHere.d2s”
    $MyFileSize=FileGetSize($MyFile)

    dim $byteA[$MyFileSize]
    $MyFileHandle=FileOpen($Myfile,16)
    for $k = 0 to $MyFileSize – 1
    $byteA[$k]=FileRead($MyFileHandle,1)
    Next
    FileClose($MyFileHandle)

    $byteA[0x1AB]=”0x02″ ; this makes Akara willing to reset your skills when you talk to her.

    FixCheckSum()
    ;MsgBox(0, “Debug messege”,”Fixed in memory “)

    FileDelete($MyFile)
    $MyFileHandle=FileOpen($Myfile,17)
    for $k = 0 to $MyFileSize – 1
    FileWrite($MyFileHandle,$byteA[$k])
    Next
    FileClose($MyFileHandle)
    MsgBox(0, “Debug messege”,”Sum check fixed for file: ” & $MyFile)

    worked for 1.13c just fine for me.

    Thanks alot btw 🙂

    • HairlessToaster says:

      @benigntoaster

      Care to share the FixCheckSum() routine ???

    • HairlessToaster says:

      Care to share the FixCheckSum() routine ???

    • HairlessToaster says:

      NVM – I see what you did 😛

    • HairlessToaster says:

      Tried doing it (checksum) with AutoHotKey – can’t see where I went wrong – but its not giving the right checksum (D2 1.13c)

      file := FileOpen(“TestPally.d2s”, “r”)
      if IsObject(file) {
      length := file.Length()
      position := file.Position()
      checksum := Object()
      boolCarry = 0

      checksum[0] := 0
      checksum[1] := 0
      checksum[2] := 0
      checksum[3] := 0

      loop , %length%
      {
      if ((position 15))
      {
      temp := (file.ReadUChar() + boolCarry)
      checksum[0] := ((checksum[0] * 2) + temp)
      checksum[1] := (checksum[1]*2)
      if (checksum[0] > 255)
      {
      checksum[1] := Round(checksum[1] + (checksum[0] – Mod(checksum[0],256)) / 256)
      ;Round() compensates for AutoHotKeys need to
      ;return a float when dividing – no rounding errors,
      ;just a format conversion
      checksum[0] := Mod(checksum[0],256)
      }

      checksum[2] := checksum[2]*2
      if (checksum[1] > 255)
      {
      checksum[2] := Round(checksum[2] + (checksum[1] – Mod(checksum[1],256)) / 256)
      checksum[1] := Mod(checksum[1],256)
      }

      checksum[3] := checksum[3]*2
      if (checksum[2] > 255)
      {
      checksum[3] := Round(checksum[3] + (checksum[2] – Mod(checksum[2],256)) / 256)
      checksum[2] := Mod(checksum[2],256)
      }

      if (checksum[3] > 255)
      {
      checksum[3] := Mod(checksum[3], 256)
      }

      if ((checksum[3] & 0x80) != 0 )
      {
      boolCarry := 1
      }else{
      boolCarry := 0
      }
      }
      position += 1
      }
      file.Close
      } else {
      MsgBox %ErrorLevel%
      }
      SetFormat , Integer , D
      a := checksum[0] + 0
      b := checksum[1] + 0
      c := checksum[2] + 0
      d := checksum[3] + 0
      MsgBox %a% %b% %c% %d%

  6. Excluded Layman says:

    Because these other two checksum algorithms are harder to find than this page, I’ll dump them here. I’ve stripped off everything but the calculation code to keep it cleaner, and for completeness, included my incoherent ad hoc ramblings that somewhat resemble explanations.

    Facts current and algorithms tested as of 1.13d.

    Blizzard’s calculation code, written in C:

    unsigned int uiCS = 0;
    for ( int i = 0; i < iSize; ++i )
    uiCS = (uiCS<<1) + pucData[i] + ( uiCS & 0x80000000 ? 1 : 0 );

    Where p(ointer to) u(nsigned) c(har) Data is a pointer to the beginning of the buffered file, iSize is the filesize, and the ternary operation ( ? : ) is testing if the most significant bit of uiCS is set, then setting the carry (a temporary value in this case) to 1 if it is, or 0 if it isn’t. This is a direct translation of the assembly the game uses to compute the checksum.

    Bit rotation (transliterated into the same style of C for consistency):

    unsigned int uiCS = 0;
    for ( int i = 0; i < iSize; ++i )
    //this is bit rotation, aka _rotl in C
    uiCS = ( (uiCS <> 31) ) + pucData[i];

    The right shift by 31 puts the value of the most significant bit of uiCS into the least significant bit of a temporary, making it the carry. Since the left shift by 1 clears a space for this single bit, the bitwise or functions equivalently to addition.

    NOTE: Make sure you read bytes from the file as unsigned values (or failing that, cast them as unsigned values), because the game uses zero extension to type promote those bytes in its calculation. In other words, it treats the bytes it reads as unsigned when turning them into dwords before doing math on them. Don’t accidentally invoke sign extension!

Leave a reply to HairlessToaster Cancel reply