Savegames from Disgaea 1 / 2 for PC

Reading, editing and everything related to the files created by games to contain savegames and configurations
HenryEx
Posts: 27
Joined: Wed Aug 13, 2014 6:43 pm

Savegames from Disgaea 1 / 2 for PC

Post by HenryEx »

Unlike on consoles, where the save files for these games are usually plain, on PC they are now compressed with a custom method that uses the YKCMP_V1 header, also seen in other NIS games. On top of that, they are obfuscated with a XOR operation on the whole thing.

I've written a script that gets rid of both the obfuscation as well as decompressing the save file. It works for save files from both Disgaea PC as well as Disgaea 2 PC.

In version 2 I implemented a kind of pseudo-compression which does not actually compress the file but in fact makes it bigger, but it at least allows us to re-pack a previously decompressed save into a format that the games accept. So it's now possible to load modified save games.

https://pastebin.com/R1ZHA6W2
Last edited by HenryEx on Wed Aug 09, 2017 5:29 pm, edited 1 time in total.
HenryEx
Posts: 27
Joined: Wed Aug 13, 2014 6:43 pm

Re: Savegames from Disgaea 1 / 2 for PC

Post by HenryEx »

Script V1:

Code: Select all

# Disgaea PC / Disgaea 2 PC save game decompression
# De-XORs and decompresses save files from these games.
# Leaves headers intact, so actual save data starts at offset 0x44.
#
# Written by HenryEx
# version 1
#
# script for QuickBMS http://quickbms.aluigi.org

################################################
# set up virtual memory file for save data
  print "Preparing..."
  get FILENAME filename 0
  get FILESIZE asize 0
  log MEMORY_FILE 0 FILESIZE 0  # MEMORY_FILE is the working copy for decryption
  log MEMORY_FILE2 0 0  # MEMORY_FILE2 will be the target for decompression
  putvarchr MEMORY_FILE2 0x100000 0
  log MEMORY_FILE2 0 0  # reset MF2

################################################
# Decrypt save

  print "Starting Decryption..."
  getDString HEADSTART 0x20
  get XORKEY long
  get XORUNK1 short
  get XORUNK2 short
  get XORCHUNKS long  # num of XOR'd integers in file
  get FSIZE_COMP_A long  # savedata size with YKCMP header minus padding (for XOR ints)
  SavePos FSTART

  print "XORing Memory File..."
# XOR save file with key
  for i = 0 < XORCHUNKS
    xmath POS "(i * 4) + FSTART"  # get current file position

    getvarchr DATA MEMORY_FILE POS long
    math DATA u^ XORKEY
    putvarchr MEMORY_FILE POS DATA long
  next i

################################################
# Decompress save

  print "Start Decompression..."
  goto FSTART MEMORY_FILE
  idstring MEMORY_FILE "YKCMP_V1"
  get ARCHIVE_VERSION long MEMORY_FILE
  if ARCHIVE_VERSION != 4
    print "[!] Unexpected archive version: %ARCHIVE_VERSION%! Exiting..."
    CleanExit
  endif
  get FSIZE_COMP_B long MEMORY_FILE  # comp. filesize without 0x30 decryption header, minus padding
  get FSIZE_TARGET long MEMORY_FILE  # target filesize without all headers after decomp.
  SavePos FSTART

# set up decompression and prepare MF2
  print "Setting up Memory File 2..."
  putDString HEADSTART 0x20 MEMORY_FILE2
  put XORKEY long MEMORY_FILE2
  put XORUNK1 short MEMORY_FILE2
  put XORUNK2 short MEMORY_FILE2
  put XORCHUNKS long MEMORY_FILE2
  put FSIZE_COMP_A long MEMORY_FILE2
  putct "YKCMP_V1" string -1 MEMORY_FILE2
  put ARCHIVE_VERSION long MEMORY_FILE2
  put FSIZE_COMP_B long MEMORY_FILE2
  put FSIZE_TARGET long MEMORY_FILE2
  set FBYTES long FSIZE_COMP_B
  math FBYTES + 0x30  # num. of compressed bytes + XOR & YKCMP header

  math POS = FSTART  # offset after the YKCMP_V1 header (save data start) for MF2

# start decompressing into MF2
  print "Decompressing save data..."
  for i = 68 < FBYTES  # i works as byte offset, start at offset 0x44
    goto i MEMORY_FILE
    get A_BYTE byte MEMORY_FILE
   
    if A_BYTE >= 0xE0    # read data like XX XY YY
      get B_BYTE byte MEMORY_FILE
      get C_BYTE byte MEMORY_FILE
      set READLEN long A_BYTE
      math READLEN & 0x1F  # remove 0xE0 from X
      math READLEN < 4
      xmath READLEN "READLEN + (B_BYTE > 4)"
      math READLEN + 3
      set SEEKBACK long B_BYTE
      math SEEKBACK & 0x0F
      math SEEKBACK < 8
      math SEEKBACK + C_BYTE
      math SEEKBACK + 1
    # print "Offset %i|h4%: byte %A_BYTE|2h% is >= 0xE0! Next bytes %B_BYTE|2h% %C_BYTE|2h%. Look back by %SEEKBACK% and copy %READLEN% bytes to %POS|6h%!"
      math i + 2  # advance counter of processed bytes
    elif A_BYTE >= 0xC0  # read data like XX YY
      get B_BYTE byte MEMORY_FILE
      math READLEN = A_BYTE
      math READLEN & 0x3F  # remove 0xC0 from X
      math READLEN + 2
      math SEEKBACK = B_BYTE
      math SEEKBACK + 1
    # print "Offset %i|h4%: byte %A_BYTE|2h% is >= 0xC0! Next byte %B_BYTE|2h%. Look back by %SEEKBACK% and copy %READLEN% bytes to %POS|6h%!"
      math i + 1  # advance counter of processed bytes
    elif A_BYTE >= 0x80  # read data like XY
      math READLEN = A_BYTE
      math READLEN > 4
      math READLEN & 3  # remove 0x80 from X
      math READLEN + 1
      math SEEKBACK = A_BYTE
      math SEEKBACK & 0x0F
      math SEEKBACK + 1
    # print "Offset %i|h4%: byte %A_BYTE|2h% is >= 0x80! Look back by %SEEKBACK% and copy %READLEN% bytes to %POS|6h%!"
    else                 # byte is < 0x80, straight copy next bytes MF1 -> MF2
    # print "Offset %i|h4%: byte %A_BYTE|2h% is < 0x80! Straight copy %A_BYTE% bytes to %POS|6h%!"
      for j = 0 < A_BYTE
        math i + 1
        getvarchr DATA MEMORY_FILE1 i byte
        put DATA byte MEMORY_FILE2
      next j
    endif

    if A_BYTE >= 0x80  # Copy bytes within MF2 via lookback

      math POS - SEEKBACK

      for j = 0 < READLEN
        getvarchr DATA MEMORY_FILE2 POS byte
        put DATA byte MEMORY_FILE2
        math POS + 1
      next j
    endif

    get POS asize MEMORY_FILE2
  next i

# check if filesize matches?
  math ENDSIZE = POS
  math POS - 68
  if FSIZE_TARGET != POS
    print "WARNING! Target filesize doesn't match real filesize!"
  endif
 
################################################
# Save file to disk

string FILENAME P= "dec_%FILENAME%"
print "Exporting decompressed save to %FILENAME%"
log FILENAME 0 ENDSIZE MEMORY_FILE2

CleanExit
HenryEx
Posts: 27
Joined: Wed Aug 13, 2014 6:43 pm

Re: Savegames from Disgaea 1 / 2 for PC

Post by HenryEx »

Script V2, supporting re-packing saves:

Code: Select all

# Disgaea PC / Disgaea 2 PC save game decompression
# De-XORs and decompresses save files from these games.
# Leaves headers intact, so actual save data starts at offset 0x44.
#
# Saves decompressed with this script can be repacked as well with
# pseudo-compression to allow loading modified save files.
#
# Written by HenryEx
# version 2
#
# script for QuickBMS http://quickbms.aluigi.org

################################################
# set up virtual memory file for save data
  print "Preparing..."
  get FILENAME filename 0
  get FILESIZE asize 0
  if FILESIZE < 0x44
    print "[!] Error: File too small! Exiting..."
    CleanExit
  endif
  log MEMORY_FILE 0 0  # MEMORY_FILE is the working copy for de-/encryption
  log MEMORY_FILE2 0 0  # MEMORY_FILE2 will be the target for de-/recompression
  putvarchr MEMORY_FILE 0x100000 0
  putvarchr MEMORY_FILE2 0x100000 0
  log MEMORY_FILE 0 0  # reset MF1
  log MEMORY_FILE2 0 0  # reset MF2

  getDString HEADSTART 0x20
  get XORKEY long
  get XORUNK1 short
  get XORUNK2 short
  get XORCHUNKS long  # num of XOR'd integers in file
  get FSIZE_COMP_A long  # savedata size with YKCMP header minus padding (for XOR ints)
  SavePos FSTART 0
  getDString MAGIC 8 0   # check for YKCMP_V1 string if already de-crypted / -compressed

if MAGIC = "YKCMP_V1"
################################################
# Pseudo re-compress save
  print "File seems to be a decompressed save, will attempt to pseudo re-compress and encrypt it."

  get ARCHIVE_VERSION long 0
  if ARCHIVE_VERSION != 4
    print "[!] Unexpected archive version: %ARCHIVE_VERSION%! Exiting..."
    CleanExit
  endif
  get FSIZE_COMP_B long 0  # comp. filesize without 0x30 decryption header, minus padding
  get FSIZE_TARGET long 0  # target filesize without all headers after decomp.
  SavePos FSTART 0

# set up compression stuff and prepare MF2
  print "Setting up file in memory..."
  putct "YKCMP_V1" string -1 MEMORY_FILE2
  put ARCHIVE_VERSION long MEMORY_FILE2
  put FSIZE_COMP_B long MEMORY_FILE2  # place holder, needs to be updated after recompression
  math FSIZE_TARGET = FILESIZE
  math FSIZE_TARGET - 0x44
  put FSIZE_TARGET long MEMORY_FILE2
  set FBYTES long FSIZE_TARGET
  math POS = FSTART  # starting position to read bytes from, should be 0x44

# start pseudo compressing into MF2
  print "Pseudo re-compressing save data..."
  append  # append mode ON
  for FBYTES = FBYTES != 0  # loop while num of bytes to process is not 0
    if FBYTES > 0x7F
      set READLEN byte 0x7F
      math FBYTES - 0x7F
    else
      set READLEN byte FBYTES
      math FBYTES = 0
    endif

    put READLEN byte MEMORY_FILE2   # put byte length to straight copy
    log MEMORY_FILE2 POS READLEN 0  # append [READLEN] bytes to MF2
    math POS + READLEN              # increment read offset
  next
  append  # append mode OFF

  get FSIZE_COMP_B asize MEMORY_FILE2
  math FSIZE_COMP_A = FSIZE_COMP_B
  putvarchr MEMORY_FILE2 0xC FSIZE_COMP_B long  # update header value

################################################
# Encrypt save file in MF1

  print "Setting up encryption..."
  xmath PAD "4 - ( FSIZE_COMP_A % 4 )"  # num of bytes for padding
  if PAD > 0
    for i = 0 < PAD
      put 0 byte MEMORY_FILE2  # pad file with 0 for 32-bit alignment
    next i
  endif
  xmath FSIZE "FSIZE_COMP_A + PAD"
  xmath XORCHUNKS "FSIZE / 4"
# print "Padding needed: %PAD%. Padded filesize is %FSIZE%. Xor chunks: %XORCHUNKS%."

# Set up MF1
  putDString HEADSTART 0x20 MEMORY_FILE
  put XORKEY long MEMORY_FILE
  put XORUNK1 short MEMORY_FILE
  put XORUNK2 short MEMORY_FILE
  put XORCHUNKS long MEMORY_FILE
  put FSIZE_COMP_A long MEMORY_FILE
  SavePos FSTART MEMORY_FILE
  append  # append mode ON
  log MEMORY_FILE 0 FSIZE MEMORY_FILE2  # put recomp. save after encryption header
  append  # append mode OFF

  print "Encrypting File..."
# XOR save file with key
  for i = 0 < XORCHUNKS
    xmath POS "(i * 4) + FSTART"  # get current file position

    getvarchr DATA MEMORY_FILE POS long
    math DATA u^ XORKEY
    putvarchr MEMORY_FILE POS DATA long
  next i

  get ENDSIZE asize MEMORY_FILE

################################################
# Save file to disk

  string FILENAME $ "save"  # last occurrence + searched string
  print "Exporting re-compressed save to %FILENAME%"
  log FILENAME 0 ENDSIZE MEMORY_FILE

  CleanExit

else
################################################
# Decrypt save

  print "Decrypting File..."
# XOR save file with key
  log MEMORY_FILE 0 FILESIZE 0
  for i = 0 < XORCHUNKS
    xmath POS "(i * 4) + FSTART"  # get current file position

    getvarchr DATA MEMORY_FILE POS long
    math DATA u^ XORKEY
    putvarchr MEMORY_FILE POS DATA long
  next i

  goto FSTART MEMORY_FILE
  getDString MAGIC 8 MEMORY_FILE
  if MAGIC != "YKCMP_V1"
    print "[!] Unexpected magic string: %MAGIC%! Decryption might have failed. Exiting..."
    CleanExit
  endif


################################################
# Decompress save

  print "Begin Decompression..."
  get ARCHIVE_VERSION long MEMORY_FILE
  if ARCHIVE_VERSION != 4
    print "[!] Unexpected archive version: %ARCHIVE_VERSION%! Exiting..."
    CleanExit
  endif
  get FSIZE_COMP_B long MEMORY_FILE  # comp. filesize without 0x30 decryption header, minus padding
  get FSIZE_TARGET long MEMORY_FILE  # target filesize without all headers after decomp.
  SavePos FSTART 0

# set up decompression and prepare MF2
  print "Setting up file in memory..."
  putDString HEADSTART 0x20 MEMORY_FILE2
  put XORKEY long MEMORY_FILE2
  put XORUNK1 short MEMORY_FILE2
  put XORUNK2 short MEMORY_FILE2
  put XORCHUNKS long MEMORY_FILE2
  put FSIZE_COMP_A long MEMORY_FILE2
  putct "YKCMP_V1" string -1 MEMORY_FILE2
  put ARCHIVE_VERSION long MEMORY_FILE2
  put FSIZE_COMP_B long MEMORY_FILE2
  put FSIZE_TARGET long MEMORY_FILE2
  set FBYTES long FSIZE_COMP_B
  math FBYTES + 0x30  # num. of compressed bytes + XOR & YKCMP header

  math POS = FSTART  # offset after the YKCMP_V1 header (save data start) for MF2

# start decompressing into MF2
  print "Decompressing save data..."
  for i = 68 < FBYTES  # i works as byte offset, start at offset 0x44
    goto i MEMORY_FILE
    get A_BYTE byte MEMORY_FILE
   
    if A_BYTE >= 0xE0    # read data like XX XY YY
      get B_BYTE byte MEMORY_FILE
      get C_BYTE byte MEMORY_FILE
      set READLEN long A_BYTE
      math READLEN & 0x1F  # remove 0xE0 from X
      math READLEN < 4
      xmath READLEN "READLEN + (B_BYTE > 4)"
      math READLEN + 3
      set SEEKBACK long B_BYTE
      math SEEKBACK & 0x0F
      math SEEKBACK < 8
      math SEEKBACK + C_BYTE
      math SEEKBACK + 1
    # print "Offset %i|h4%: byte %A_BYTE|2h% is >= 0xE0! Next bytes %B_BYTE|2h% %C_BYTE|2h%. Look back by %SEEKBACK% and copy %READLEN% bytes to %POS|6h%!"
      math i + 2  # advance counter of processed bytes
    elif A_BYTE >= 0xC0  # read data like XX YY
      get B_BYTE byte MEMORY_FILE
      math READLEN = A_BYTE
      math READLEN & 0x3F  # remove 0xC0 from X
      math READLEN + 2
      math SEEKBACK = B_BYTE
      math SEEKBACK + 1
    # print "Offset %i|h4%: byte %A_BYTE|2h% is >= 0xC0! Next byte %B_BYTE|2h%. Look back by %SEEKBACK% and copy %READLEN% bytes to %POS|6h%!"
      math i + 1  # advance counter of processed bytes
    elif A_BYTE >= 0x80  # read data like XY
      math READLEN = A_BYTE
      math READLEN > 4
      math READLEN & 3  # remove 0x80 from X
      math READLEN + 1
      math SEEKBACK = A_BYTE
      math SEEKBACK & 0x0F
      math SEEKBACK + 1
    # print "Offset %i|h4%: byte %A_BYTE|2h% is >= 0x80! Look back by %SEEKBACK% and copy %READLEN% bytes to %POS|6h%!"
    else                 # byte is < 0x80, straight copy next bytes MF1 -> MF2
    # print "Offset %i|h4%: byte %A_BYTE|2h% is < 0x80! Straight copy %A_BYTE% bytes to %POS|6h%!"
      for j = 0 < A_BYTE
        math i + 1
        getvarchr DATA MEMORY_FILE1 i byte
        put DATA byte MEMORY_FILE2
      next j
    endif

    if A_BYTE >= 0x80  # Copy bytes within MF2 via lookback

      math POS - SEEKBACK

      for j = 0 < READLEN
        getvarchr DATA MEMORY_FILE2 POS byte
        put DATA byte MEMORY_FILE2
        math POS + 1
      next j
    endif

    get POS asize MEMORY_FILE2
  next i

# check if filesize matches?
  math ENDSIZE = POS
  math POS - 68
  if FSIZE_TARGET != POS
    print "WARNING! Target filesize doesn't match real filesize!"
  endif
 
################################################
# Save file to disk

  string FILENAME P= "dec_%FILENAME%"
  print "Exporting decompressed save to %FILENAME%"
  log FILENAME 0 ENDSIZE MEMORY_FILE2

  CleanExit

endif
aluigi
Site Admin
Posts: 12984
Joined: Wed Jul 30, 2014 9:32 pm

Re: Savegames from Disgaea 1 / 2 for PC

Post by aluigi »

I have the impression that the FSIZE_COMP_B / FBYTES field includes 20 additional bytes (the size of the header), in fact the output is larger than the declared decompressed size.
If you use "FSIZE_COMP_B -= 20" you will get an output matching the expected size.