~thestr4ng3r/mymcplus

82dcc71d79db72308e970a985c69852637d18d85 — Florian Märkl 3 years ago 3d427e6
Split up ps2save.py and move to save/
M mymcplus/gui/dirlist_control.py => mymcplus/gui/dirlist_control.py +1 -1
@@ 18,7 18,7 @@
import wx

from .. import ps2mc
from .. import ps2save
from ..save import ps2save

from . import utils


M mymcplus/gui/gui.py => mymcplus/gui/gui.py +1 -1
@@ 28,7 28,7 @@ if os.name == "nt" and hasattr(sys, "setdefaultencoding"):
import wx

from .. import ps2mc
from .. import ps2save
from ..save import ps2save
from .icon_window import IconWindow
from .dirlist_control import DirListControl
from . import utils

M mymcplus/mymc.py => mymcplus/mymc.py +12 -15
@@ 24,8 24,9 @@ import textwrap
from errno import EEXIST, EIO

from . import ps2mc
from . import ps2save
from .save import ps2save
from .ps2mc_dir import *
from .save import codebreaker, ems, max_drive, sharkport
from . import verbuild

class subopt_error(Exception):


@@ 211,28 212,24 @@ def do_import(cmd, mc, opts, args, opterr):
            ftype = ps2save.detect_file_type(f)
            f.seek(0)
            if ftype == "max":
                sf.load_max_drive(f)
                max_drive.load(sf, f)
            elif ftype == "psu":
                sf.load_ems(f)
                ems.load(sf, f)
            elif ftype == "cbs":
                sf.load_codebreaker(f)
                codebreaker.load(sf, f)
            elif ftype == "sps":
                sf.load_sharkport(f)
                sharkport.load(sf, f)
            elif ftype == "npo":
                raise io_error(EIO, "nPort saves"
                         " are not supported.",
                         filename)
                raise io_error(EIO, "nPort saves are not supported.", filename)
            else:
                raise io_error(EIO, "Save file format not"
                         " recognized", filename)
                raise io_error(EIO, "Save file format not recognized", filename)
        finally:
            f.close()
        dirname = opts.directory
        if dirname == None:
            dirname = sf.get_directory()[8].decode("ascii")
        print("Importing", filename, "to", dirname)
        if not mc.import_save_file(sf, opts.ignore_existing,
                        opts.directory):
        if not mc.import_save_file(sf, opts.ignore_existing, opts.directory):
            print (filename + ": already in memory card image,"
                   " ignored.")



@@ 281,9 278,9 @@ def do_export(cmd, mc, opts, args, opterr):
            print("Exporing", dirname, "to", filename)
            
            if opts.type == "max":
                sf.save_max_drive(f)
                max_drive.save(sf, f)
            else:
                sf.save_ems(f)
                ems.save(sf, f)
        finally:
            f.close()



@@ 800,7 797,7 @@ def main(argv=sys.argv):
        (ret,) = xxx_todo_changeme.args
        pass
    
    except (ps2mc.error, ps2save.error) as value:
    except (ps2mc.error, ps2save.Error) as value:
        fn = getattr(value, "filename", None)
        if fn == None:
            fn = mcname

M mymcplus/ps2mc.py => mymcplus/ps2mc.py +1 -1
@@ 28,7 28,7 @@ import traceback
from .round import *
from .ps2mc_ecc import *
from .ps2mc_dir import *
from . import ps2save
from .save import ps2save

PS2MC_MAGIC = b"Sony PS2 Memory Card Format "
PS2MC_FAT_ALLOCATED_BIT = 0x80000000

D mymcplus/ps2save.py => mymcplus/ps2save.py +0 -633
@@ 1,633 0,0 @@
#
# This file is part of mymc+, based on mymc by Ross Ridge.
#
# mymc+ is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# mymc+ is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with mymc+.  If not, see <http://www.gnu.org/licenses/>.
#

"""A simple interface for working with various PS2 save file formats."""

import sys
import os
import string
import struct
import binascii
import array
import zlib

from .round import div_round_up, round_up
from .ps2mc_dir import *
from .sjistab import shift_jis_normalize_table

from . import lzari

PS2SAVE_MAX_MAGIC = b"Ps2PowerSave"
PS2SAVE_SPS_MAGIC = b"\x0d\0\0\0SharkPortSave"
PS2SAVE_CBS_MAGIC = b"CFU\0"
PS2SAVE_NPO_MAGIC = b"nPort"

# This is the initial permutation state ("S") for the RC4 stream cipher
# algorithm used to encrpyt and decrypt Codebreaker saves.
PS2SAVE_CBS_RC4S = [0x5f, 0x1f, 0x85, 0x6f, 0x31, 0xaa, 0x3b, 0x18,
            0x21, 0xb9, 0xce, 0x1c, 0x07, 0x4c, 0x9c, 0xb4,
            0x81, 0xb8, 0xef, 0x98, 0x59, 0xae, 0xf9, 0x26,
            0xe3, 0x80, 0xa3, 0x29, 0x2d, 0x73, 0x51, 0x62,
            0x7c, 0x64, 0x46, 0xf4, 0x34, 0x1a, 0xf6, 0xe1,
            0xba, 0x3a, 0x0d, 0x82, 0x79, 0x0a, 0x5c, 0x16,
            0x71, 0x49, 0x8e, 0xac, 0x8c, 0x9f, 0x35, 0x19,
            0x45, 0x94, 0x3f, 0x56, 0x0c, 0x91, 0x00, 0x0b,
            0xd7, 0xb0, 0xdd, 0x39, 0x66, 0xa1, 0x76, 0x52,
            0x13, 0x57, 0xf3, 0xbb, 0x4e, 0xe5, 0xdc, 0xf0,
            0x65, 0x84, 0xb2, 0xd6, 0xdf, 0x15, 0x3c, 0x63,
            0x1d, 0x89, 0x14, 0xbd, 0xd2, 0x36, 0xfe, 0xb1,
            0xca, 0x8b, 0xa4, 0xc6, 0x9e, 0x67, 0x47, 0x37,
            0x42, 0x6d, 0x6a, 0x03, 0x92, 0x70, 0x05, 0x7d,
            0x96, 0x2f, 0x40, 0x90, 0xc4, 0xf1, 0x3e, 0x3d,
            0x01, 0xf7, 0x68, 0x1e, 0xc3, 0xfc, 0x72, 0xb5,
            0x54, 0xcf, 0xe7, 0x41, 0xe4, 0x4d, 0x83, 0x55,
            0x12, 0x22, 0x09, 0x78, 0xfa, 0xde, 0xa7, 0x06,
            0x08, 0x23, 0xbf, 0x0f, 0xcc, 0xc1, 0x97, 0x61,
            0xc5, 0x4a, 0xe6, 0xa0, 0x11, 0xc2, 0xea, 0x74,
            0x02, 0x87, 0xd5, 0xd1, 0x9d, 0xb7, 0x7e, 0x38,
            0x60, 0x53, 0x95, 0x8d, 0x25, 0x77, 0x10, 0x5e,
            0x9b, 0x7f, 0xd8, 0x6e, 0xda, 0xa2, 0x2e, 0x20,
            0x4f, 0xcd, 0x8f, 0xcb, 0xbe, 0x5a, 0xe0, 0xed,
            0x2c, 0x9a, 0xd4, 0xe2, 0xaf, 0xd0, 0xa9, 0xe8,
            0xad, 0x7a, 0xbc, 0xa8, 0xf2, 0xee, 0xeb, 0xf5,
            0xa6, 0x99, 0x28, 0x24, 0x6c, 0x2b, 0x75, 0x5d,
            0xf8, 0xd3, 0x86, 0x17, 0xfb, 0xc0, 0x7b, 0xb3,
            0x58, 0xdb, 0xc7, 0x4b, 0xff, 0x04, 0x50, 0xe9,
            0x88, 0x69, 0xc9, 0x2a, 0xab, 0xfd, 0x5b, 0x1b,
            0x8a, 0xd9, 0xec, 0x27, 0x44, 0x0e, 0x33, 0xc8,
            0x6b, 0x93, 0x32, 0x48, 0xb6, 0x30, 0x43, 0xa5]

class error(Exception):
    """Base for all exceptions specific to this module."""
    pass

class corrupt(error):
    """Corrupt save file."""

    def __init__(self, msg, f = None):
        fn = None
        if f != None:
            fn = getattr(f, "name", None)
        self.filename = fn
        error.__init__(self, "Corrupt save file: " + msg)

class eof(corrupt):
    """Save file is truncated."""

    def __init__(self, f = None):
        corrupt.__init__(self, "Unexpected EOF", f)

class subdir(corrupt):
    def __init__(self, f = None):
        corrupt.__init__(self, "Non-file in save file.", f)

#
# Table of graphically similar ASCII characters that can be used
# as substitutes for Unicode characters.
#
char_substs = {
    u'\u00a2': u"c",
    u'\u00b4': u"'",
    u'\u00d7': u"x",
    u'\u00f7': u"/",
    u'\u2010': u"-",
    u'\u2015': u"-",
    u'\u2018': u"'",
    u'\u2019': u"'",
    u'\u201c': u'"',
    u'\u201d': u'"',
    u'\u2032': u"'",
    u'\u2212': u"-",
    u'\u226a': u"<<",
    u'\u226b': u">>",
    u'\u2500': u"-",
    u'\u2501': u"-",
    u'\u2502': u"|",
    u'\u2503': u"|",
    u'\u250c': u"+",
    u'\u250f': u"+",
    u'\u2510': u"+",
    u'\u2513': u"+",
    u'\u2514': u"+",
    u'\u2517': u"+",
    u'\u2518': u"+",
    u'\u251b': u"+",
    u'\u251c': u"+",
    u'\u251d': u"+",
    u'\u2520': u"+",
    u'\u2523': u"+",
    u'\u2524': u"+",
    u'\u2525': u"+",
    u'\u2528': u"+",
    u'\u252b': u"+",
    u'\u252c': u"+",
    u'\u252f': u"+",
    u'\u2530': u"+",
    u'\u2533': u"+",
    u'\u2537': u"+",
    u'\u2538': u"+",
    u'\u253b': u"+",
    u'\u253c': u"+",
    u'\u253f': u"+",
    u'\u2542': u"+",
    u'\u254b': u"+",
    u'\u25a0': u"#",
    u'\u25a1': u"#",
    u'\u3001': u",",
    u'\u3002': u".",
    u'\u3003': u'"',
    u'\u3007': u'0',
    u'\u3008': u'<',
    u'\u3009': u'>',
    u'\u300a': u'<<',
    u'\u300b': u'>>',
    u'\u300a': u'<<',
    u'\u300b': u'>>',
    u'\u300c': u'[',
    u'\u300d': u']',
    u'\u300e': u'[',
    u'\u300f': u']',
    u'\u3010': u'[',
    u'\u3011': u']',
    u'\u3014': u'[',
    u'\u3015': u']',
    u'\u301c': u'~',
    u'\u30fc': u'-',
}

def shift_jis_conv(src, encoding = None):
    """Convert Shift-JIS strings to a graphically similar representation.

    If encoding is "unicode" then a Unicode string is returned, otherwise
    a string in the encoding specified is returned.  If necessary,
    graphically similar characters are used to replace characters not
    exactly    representable in the desired encoding.
    """
    
    if encoding == None:
        encoding = sys.getdefaultencoding()
    if encoding == "shift_jis":
        return src
    u = src.decode("shift_jis", "replace")
    if encoding == "unicode":
        return u
    a = []
    for uc in u:
        try:
            uc.encode(encoding)
            a.append(uc)
        except UnicodeError:
            for uc2 in shift_jis_normalize_table.get(uc, uc):
                a.append(char_substs.get(uc2, uc2))
    
    return "".join(a)

def rc4_crypt(s, t):
    """RC4 encrypt/decrypt the string t using the permutation s.

    Returns a byte array."""
    
    s = array.array('B', s)
    t = array.array('B', t)
    j = 0
    for ii in range(len(t)):
        i = (ii + 1) % 256
        j = (j + s[i]) % 256
        (s[i], s[j]) = (s[j], s[i])
        t[ii] ^= s[(s[i] + s[j]) % 256]
    return t

# def sps_check(s):
#     """Calculate the checksum for a SharkPort save."""
#
#     h = 0
#     for c in array.array('B', s):
#         h += c << (h % 24)
#         h &= 0xFFFFFFFF
#     return h

def unpack_icon_sys(s):
    """Unpack an icon.sys file into a tuple."""
    
    # magic, title offset, ...
    # [14] title, normal icon, copy icon, del icon
    a = struct.unpack("<4s2xH4x"
              "L" "16s16s16s16s" "16s16s16s" "16s16s16s" "16s"
              "68s64s64s64s512x", s)
    a = list(a)
    for i in range(3, 7):
        a[i] = struct.unpack("<4L", a[i])
        a[i] = list(map(hex, a[i]))
    for i in range(7, 14):
        a[i] = struct.unpack("<4f", a[i])
    a[14] = zero_terminate(a[14])
    a[15] = zero_terminate(a[15])
    a[16] = zero_terminate(a[16])
    a[17] = zero_terminate(a[17])
    return a

def icon_sys_title(icon_sys, encoding = None):
    """Extract the two lines of the title stored in an icon.sys tuple."""
    
    offset = icon_sys[1]
    title = icon_sys[14]
    title2 = shift_jis_conv(title[offset:], encoding)
    title1 = shift_jis_conv(title[:offset], encoding)
    return (title1, title2)

def _read_fixed(f, n):
    """Read a string of a fixed length from a file."""
    
    s = f.read(n)
    if len(s) != n:
        raise eof(f)
    return s

def _read_long_string(f):
    """Read a string prefixed with a 32-bit length from a file."""
    
    length = struct.unpack("<L", _read_fixed(f, 4))[0]
    return _read_fixed(f, length) 

class ps2_save_file(object):
    """The state of a PlayStation 2 save file."""
    
    def __init__(self):
        self.file_ents = None
        self.file_data = None
        self.dirent = None
        self._defer_load_max = False

    def set_directory(self, ent, defer = False):
        self._defer_load_max = defer
        self._compressed = None
        self.file_ents = [None] * ent[2]
        self.file_data = [None] * ent[2]
        self.dirent = list(ent)

    def set_file(self, i, ent, data):
        self.file_ents[i] = ent
        self.file_data[i] = data

    def get_directory(self):
        return self.dirent

    def get_file(self, i):
        if self._defer_load_max:
            self._defer_load_max = False
            self._load_max_drive_2()
        return (self.file_ents[i], self.file_data[i])

    def __len__(self):
        return self.dirent[2]

    def __getitem__(self, index):
        return self.get_file(index)
    
    def get_icon_sys(self):
        for i in range(self.dirent[2]):
            (ent, data) = self.get_file(i)
            if ent[8].decode("ascii") == "icon.sys" and len(data) >= 964:
                return unpack_icon_sys(data[:964])
        return None

    def load_ems(self, f):
        """Load EMS (.psu) save files."""
        
        cluster_size = 1024

        dirent = unpack_dirent(_read_fixed(f, PS2MC_DIRENT_LENGTH))
        dotent = unpack_dirent(_read_fixed(f, PS2MC_DIRENT_LENGTH))
        dotdotent = unpack_dirent(_read_fixed(f, PS2MC_DIRENT_LENGTH))
        if (not mode_is_dir(dirent[0])
            or not mode_is_dir(dotent[0])
            or not mode_is_dir(dotdotent[0])
            or dirent[2] < 2):
            raise corrupt("Not a EMS (.psu) save file.", f)

        dirent[2] -= 2
        self.set_directory(dirent)

        for i in range(dirent[2]):
            ent = unpack_dirent(_read_fixed(f,
                            PS2MC_DIRENT_LENGTH))
            if not mode_is_file(ent[0]):
                raise subdir(f)
            flen = ent[2]
            self.set_file(i, ent, _read_fixed(f, flen))
            _read_fixed(f, round_up(flen, cluster_size) - flen)


    def save_ems(self, f):
        cluster_size = 1024

        dirent = self.dirent[:]
        dirent[2] += 2
        f.write(pack_dirent(dirent))
        f.write(pack_dirent((DF_RWX | DF_DIR | DF_0400 | DF_EXISTS,
                     0, 0, dirent[3],
                     0, 0, dirent[3], 0, b".")))
        f.write(pack_dirent((DF_RWX | DF_DIR | DF_0400 | DF_EXISTS,
                     0, 0, dirent[3],
                     0, 0, dirent[3], 0, b"..")))
                     
        for i in range(dirent[2] - 2):
            (ent, data) = self.get_file(i)
            f.write(pack_dirent(ent))
            if not mode_is_file(ent[0]):
                # print ent
                # print hex(ent[0])
                raise error("Directory has a subdirectory.")
            f.write(data)
            f.write(b"\0" * (round_up(len(data), cluster_size)
                    - len(data)))
        f.flush()

    def _load_max_drive_2(self):
        (length, s) = self._compressed
        self._compressed = None
        
        if lzari == None:
            raise error("The lzari module is needed to "
                      " decompress MAX Drive saves.")
        s = lzari.decode(s, length, "decompressing " + self.dirent[8].decode("ascii") + ": ")
        dirlen = self.dirent[2]
        timestamp = self.dirent[3]
        off = 0
        for i in range(dirlen):
            if len(s) - off < 36:
                raise eof(f)
            (l, name) = struct.unpack("<L32s", s[off : off + 36])
            name = zero_terminate(name)
            # print "%08x %08x %s" % (off, l, name)
            off += 36
            data = s[off : off + l]
            if len(data) != l:
                raise eof(f)
            self.set_file(i,
                      (DF_RWX | DF_FILE | DF_0400 | DF_EXISTS,
                       0, l, timestamp, 0, 0, timestamp, 0,
                       name),
                      data)
            off += l
            off = round_up(off + 8, 16) - 8
        
    def load_max_drive(self, f, timestamp = None):
        s = f.read(0x5C)
        magic = None
        if len(s) == 0x5C:
            (magic, crc, dirname, iconsysname, clen, dirlen,
             length) = struct.unpack("<12sL32s32sLLL", s)
        if magic != PS2SAVE_MAX_MAGIC:
            raise corrupt("Not a MAX Drive save file", f)
        if clen == length:
            # some saves have the uncompressed size here
            # instead of the compressed size
            s = f.read()
        else:
            s = _read_fixed(f, clen - 4)
        dirname = zero_terminate(dirname)
        if timestamp == None:
            timestamp = tod_now()
        self.set_directory((DF_RWX | DF_DIR | DF_0400 | DF_EXISTS,
                    0, dirlen, timestamp, 0, 0, timestamp, 0,
                    dirname),
                   True)
        self._compressed = (length, s)
        
    def save_max_drive(self, f):
        if lzari == None:
            raise error("The lzari module is needed to "
                      " decompress MAX Drive saves.")
        iconsysname = ""
        icon_sys = self.get_icon_sys()
        if icon_sys != None:
            title = icon_sys_title(icon_sys, "ascii")
            if len(title[0]) > 0 and title[0][-1] != ' ':
                iconsysname = title[0] + " " + title[1].strip()
            else:
                iconsysname = title[0] + title[1].rstrip()
        s = b""
        dirent = self.dirent
        for i in range(dirent[2]):
            (ent, data) = self.get_file(i)
            if not mode_is_file(ent[0]):
                raise error("Non-file in save file.")
            s += struct.pack("<L32s", ent[2], ent[8])
            s += data
            s += b"\0" * (round_up(len(s) + 8, 16) - 8 - len(s))
        length = len(s)
        progress =  "compressing " + dirent[8].decode("ascii") + ": "
        compressed = lzari.encode(s, progress)
        hdr = struct.pack("<12sL32s32sLLL", PS2SAVE_MAX_MAGIC,
                  0, dirent[8], iconsysname.encode("ascii"),
                  len(compressed) + 4, dirent[2], length)
        crc = binascii.crc32(hdr)
        crc = binascii.crc32(compressed, crc)
        f.write(struct.pack("<12sL32s32sLLL", PS2SAVE_MAX_MAGIC,
                    crc & 0xFFFFFFFF, dirent[8], iconsysname.encode("ascii"),
                    len(compressed) + 4, dirent[2], length))
        f.write(compressed)
        f.flush()

    def load_codebreaker(self, f):
        magic = f.read(4)
        if magic != PS2SAVE_CBS_MAGIC:
            raise corrupt("Not a Codebreaker save file.", f)
        (d04, hlen) = struct.unpack("<LL", _read_fixed(f, 8))
        if hlen < 92 + 32:
            raise corrupt("Header lengh too short.", f)
        (dlen, flen, dirname, created, modified, d44, d48, dirmode,
         d50, d54, d58, title) \
               = struct.unpack("<LL32s8s8sLLLLLL%ds" % (hlen - 92),
                       _read_fixed(f, hlen - 12))
        dirname = zero_terminate(dirname)
        created = unpack_tod(created)
        modified = unpack_tod(modified)
        title = zero_terminate(title)

        # These fields don't always seem to be set correctly.
        if not mode_is_dir(dirmode):
            dirmode = DF_RWX | DF_DIR | DF_0400
        if tod_to_time(created) == 0:
            created = tod_now()
        if tod_to_time(modified) == 0:
            modified = tod_now()

        # flen can either be the total length of the file,
        # or the length of compressed body of the file
        body = f.read(flen)
        clen = len(body)
        if clen != flen and clen != flen - hlen:
            raise eof(f)
        body = rc4_crypt(PS2SAVE_CBS_RC4S, body)
        dcobj = zlib.decompressobj()
        body = dcobj.decompress(body, dlen)

        files = []
        while body != b"":
            if len(body) < 64:
                raise eof(f)
            header = struct.unpack("<8s8sLHHLL32s", body[:64])
            size = header[2]
            data = body[64 : 64 + size]
            if len(data) != size:
                raise eof(f)
            body = body[64 + size:]
            files.append((header, data))
            
        self.set_directory((dirmode, 0, len(files), created, 0, 0,
                    modified, 0, dirname))
        for i in range(len(files)):
            (header, data) = files[i]
            (created, modified, size, mode, h06, h08, h0C, name) \
                = header
            name = zero_terminate(name)
            created = unpack_tod(created)
            modified = unpack_tod(modified)
            if not mode_is_file(mode):
                raise subdir(f)
            if tod_to_time(created) == 0:
                created = tod_now()
            if tod_to_time(modified) == 0:
                modified = tod_now()
            self.set_file(i, (mode, 0, size, created, 0, 0,
                      modified, 0, name), data)

    def load_sharkport(self, f):
        magic = f.read(17)
        if magic != PS2SAVE_SPS_MAGIC:
            raise corrupt("Not a SharkPort/X-Port save file.", f)
        (savetype,) = struct.unpack("<L", _read_fixed(f, 4))
        dirname = _read_long_string(f)
        datestamp = _read_long_string(f)
        comment = _read_long_string(f)
        
        (flen,) = struct.unpack("<L", _read_fixed(f, 4))
        
        (hlen, dirname, dirlen, dirmode, created, modified) \
            = struct.unpack("<H64sL8xH2x8s8s", _read_fixed(f, 98))
        _read_fixed(f, hlen - 98)

        dirname = zero_terminate(dirname)
        created = unpack_tod(created)
        modified = unpack_tod(modified)

        # mode values are byte swapped
        dirmode = dirmode // 256 % 256 + dirmode % 256 * 256
        dirlen -= 2
        if not mode_is_dir(dirmode) or dirlen < 0:
            raise corrupt("Bad values in directory entry.", f)
        self.set_directory((dirmode, 0, dirlen, created, 0, 0,
                    modified, 0, dirname))

        for i in range(dirlen):
            (hlen, name, flen, mode, created, modified) \
                   = struct.unpack("<H64sL8xH2x8s8s",
                           _read_fixed(f, 98))
            if hlen < 98:
                raise corrupt("Header length too short.", f)
            _read_fixed(f, hlen - 98)
            name = zero_terminate(name)
            created = unpack_tod(created)
            modified = unpack_tod(modified)
            mode = mode // 256 % 256 + mode % 256 * 256
            if not mode_is_file(mode):
                raise subdir(f)
            self.set_file(i, (mode, 0, flen, created, 0, 0,
                      modified, 0, name),
                      _read_fixed(f, flen))
            
        # ignore 4 byte checksum at the end

def detect_file_type(f):
    """Detect the type of PS2 save file.

    The file-like object f should be positioned at the start of the file.
    """
    
    hdr = f.read(PS2MC_DIRENT_LENGTH * 3)
    if hdr[:12] == PS2SAVE_MAX_MAGIC:
        return "max"
    if hdr[:17] == PS2SAVE_SPS_MAGIC:
        return "sps"
    if hdr[:4] == PS2SAVE_CBS_MAGIC:
        return "cbs"
    if hdr[:5] == PS2SAVE_NPO_MAGIC:
        return "npo"
    #
    # EMS (.psu) save files don't have a magic number.  Check to
    # see if it looks enough like one.
    #
    if len(hdr) != PS2MC_DIRENT_LENGTH * 3:
        return None
    dirent = unpack_dirent(hdr[:PS2MC_DIRENT_LENGTH])
    dotent = unpack_dirent(hdr[PS2MC_DIRENT_LENGTH
                   : PS2MC_DIRENT_LENGTH * 2])
    dotdotent = unpack_dirent(hdr[PS2MC_DIRENT_LENGTH * 2:])
    if (mode_is_dir(dirent[0]) and mode_is_dir(dotent[0])
        and mode_is_dir(dotdotent[0]) and dirent[2] >= 2
        and dotent[8] == b"." and dotdotent[8] == b".."):
        return "psu"
    return None

#
# Set up tables of illegal and problematic characters in file names.
#
_bad_filename_chars = ("".join(map(chr, list(range(32))))
               + "".join(map(chr, list(range(127, 256)))))
_bad_filename_repl = "_" * len(_bad_filename_chars)

if os.name in ["nt", "os2", "ce"]:
    _bad_filename_chars += '<>:"/\\|'
    _bad_filename_repl +=  "()_'___"
    _bad_filename_chars2 = _bad_filename_chars + "?* "
    _bad_filename_repl2 = _bad_filename_repl +   "___"
else:
    _bad_filename_chars += "/"
    _bad_filename_repl += "_"
    _bad_filename_chars2 = _bad_filename_chars + "?*'&|:[<>] \\\""
    _bad_filename_repl2 = _bad_filename_repl +   "______(())___"

_filename_trans = str.maketrans(_bad_filename_chars, _bad_filename_repl);
_filename_trans2 = str.maketrans(_bad_filename_chars2, _bad_filename_repl2);

def fix_filename(filename):
    """Replace illegal or problematic characters from a filename."""
    return filename.translate(_filename_trans)

def make_longname(dirname, sf):
    """Return a string containing a verbose filename for a save file."""

    icon_sys = sf.get_icon_sys()
    title = ""
    if icon_sys is not None:
        title = icon_sys_title(icon_sys, "ascii")
        title = title[0] + " " + title[1]
        title = " ".join(title.split())
    crc = binascii.crc32(b"")
    for (ent, data) in sf:
        crc = binascii.crc32(data, crc)
    if len(dirname) >= 12 and (dirname[0:2] in ("BA", "BJ", "BE", "BK")):
        if dirname[2:6] == "DATA":
            title = ""
        else:
            #dirname = dirname[2:6] + dirname[7:12]
            dirname = dirname[2:12]

    return fix_filename("%s %s (%08X)" % (dirname, title, crc & 0xFFFFFFFF))


A mymcplus/save/__init__.py => mymcplus/save/__init__.py +11 -0
@@ 0,0 1,11 @@

__all__ = [
    "codebreaker",
    "ems",
    "lzari",
    "max_drive",
    "ps2save",
    "sharkport",
    "utils"
]


A mymcplus/save/codebreaker.py => mymcplus/save/codebreaker.py +120 -0
@@ 0,0 1,120 @@

import array
import zlib

from .. import ps2mc_dir
from .utils import *


PS2SAVE_CBS_MAGIC = b"CFU\0"

# This is the initial permutation state ("S") for the RC4 stream cipher
# algorithm used to encrpyt and decrypt Codebreaker saves.
PS2SAVE_CBS_RC4S = [0x5f, 0x1f, 0x85, 0x6f, 0x31, 0xaa, 0x3b, 0x18,
            0x21, 0xb9, 0xce, 0x1c, 0x07, 0x4c, 0x9c, 0xb4,
            0x81, 0xb8, 0xef, 0x98, 0x59, 0xae, 0xf9, 0x26,
            0xe3, 0x80, 0xa3, 0x29, 0x2d, 0x73, 0x51, 0x62,
            0x7c, 0x64, 0x46, 0xf4, 0x34, 0x1a, 0xf6, 0xe1,
            0xba, 0x3a, 0x0d, 0x82, 0x79, 0x0a, 0x5c, 0x16,
            0x71, 0x49, 0x8e, 0xac, 0x8c, 0x9f, 0x35, 0x19,
            0x45, 0x94, 0x3f, 0x56, 0x0c, 0x91, 0x00, 0x0b,
            0xd7, 0xb0, 0xdd, 0x39, 0x66, 0xa1, 0x76, 0x52,
            0x13, 0x57, 0xf3, 0xbb, 0x4e, 0xe5, 0xdc, 0xf0,
            0x65, 0x84, 0xb2, 0xd6, 0xdf, 0x15, 0x3c, 0x63,
            0x1d, 0x89, 0x14, 0xbd, 0xd2, 0x36, 0xfe, 0xb1,
            0xca, 0x8b, 0xa4, 0xc6, 0x9e, 0x67, 0x47, 0x37,
            0x42, 0x6d, 0x6a, 0x03, 0x92, 0x70, 0x05, 0x7d,
            0x96, 0x2f, 0x40, 0x90, 0xc4, 0xf1, 0x3e, 0x3d,
            0x01, 0xf7, 0x68, 0x1e, 0xc3, 0xfc, 0x72, 0xb5,
            0x54, 0xcf, 0xe7, 0x41, 0xe4, 0x4d, 0x83, 0x55,
            0x12, 0x22, 0x09, 0x78, 0xfa, 0xde, 0xa7, 0x06,
            0x08, 0x23, 0xbf, 0x0f, 0xcc, 0xc1, 0x97, 0x61,
            0xc5, 0x4a, 0xe6, 0xa0, 0x11, 0xc2, 0xea, 0x74,
            0x02, 0x87, 0xd5, 0xd1, 0x9d, 0xb7, 0x7e, 0x38,
            0x60, 0x53, 0x95, 0x8d, 0x25, 0x77, 0x10, 0x5e,
            0x9b, 0x7f, 0xd8, 0x6e, 0xda, 0xa2, 0x2e, 0x20,
            0x4f, 0xcd, 0x8f, 0xcb, 0xbe, 0x5a, 0xe0, 0xed,
            0x2c, 0x9a, 0xd4, 0xe2, 0xaf, 0xd0, 0xa9, 0xe8,
            0xad, 0x7a, 0xbc, 0xa8, 0xf2, 0xee, 0xeb, 0xf5,
            0xa6, 0x99, 0x28, 0x24, 0x6c, 0x2b, 0x75, 0x5d,
            0xf8, 0xd3, 0x86, 0x17, 0xfb, 0xc0, 0x7b, 0xb3,
            0x58, 0xdb, 0xc7, 0x4b, 0xff, 0x04, 0x50, 0xe9,
            0x88, 0x69, 0xc9, 0x2a, 0xab, 0xfd, 0x5b, 0x1b,
            0x8a, 0xd9, 0xec, 0x27, 0x44, 0x0e, 0x33, 0xc8,
            0x6b, 0x93, 0x32, 0x48, 0xb6, 0x30, 0x43, 0xa5]


def rc4_crypt(s, t):
    """RC4 encrypt/decrypt the string t using the permutation s.

    Returns a byte array."""

    s = array.array('B', s)
    t = array.array('B', t)
    j = 0
    for ii in range(len(t)):
        i = (ii + 1) % 256
        j = (j + s[i]) % 256
        (s[i], s[j]) = (s[j], s[i])
        t[ii] ^= s[(s[i] + s[j]) % 256]
    return t


def load(save, f):
    magic = f.read(4)
    if magic != PS2SAVE_CBS_MAGIC:
        raise ps2save.Corrupt("Not a Codebreaker save file.", f)
    (d04, hlen) = struct.unpack("<LL", read_fixed(f, 8))
    if hlen < 92 + 32:
        raise ps2save.Corrupt("Header lengh too short.", f)
    (dlen, flen, dirname, created, modified, d44, d48, dirmode,
     d50, d54, d58, title) = struct.unpack("<LL32s8s8sLLLLLL%ds" % (hlen - 92), read_fixed(f, hlen - 12))
    dirname = ps2mc_dir.zero_terminate(dirname)
    created = ps2mc_dir.unpack_tod(created)
    modified = ps2mc_dir.unpack_tod(modified)
    title = ps2mc_dir.zero_terminate(title)

    # These fields don't always seem to be set correctly.
    if not ps2mc_dir.mode_is_dir(dirmode):
        dirmode = ps2mc_dir.DF_RWX | ps2mc_dir.DF_DIR | ps2mc_dir.DF_0400
    if ps2mc_dir.tod_to_time(created) == 0:
        created = ps2mc_dir.tod_now()
    if ps2mc_dir.tod_to_time(modified) == 0:
        modified = ps2mc_dir.tod_now()

    # flen can either be the total length of the file,
    # or the length of compressed body of the file
    body = f.read(flen)
    clen = len(body)
    if clen != flen and clen != flen - hlen:
        raise ps2save.Eof(f)
    body = rc4_crypt(PS2SAVE_CBS_RC4S, body)
    dcobj = zlib.decompressobj()
    body = dcobj.decompress(body, dlen)

    files = []
    while body != b"":
        if len(body) < 64:
            raise ps2save.Eof(f)
        header = struct.unpack("<8s8sLHHLL32s", body[:64])
        size = header[2]
        data = body[64 : 64 + size]
        if len(data) != size:
            raise ps2save.Eof(f)
        body = body[64 + size:]
        files.append((header, data))

    save.set_directory((dirmode, 0, len(files), created, 0, 0, modified, 0, dirname))
    for i in range(len(files)):
        (header, data) = files[i]
        (created, modified, size, mode, h06, h08, h0C, name) = header
        name = ps2mc_dir.zero_terminate(name)
        created = ps2mc_dir.unpack_tod(created)
        modified = ps2mc_dir.unpack_tod(modified)
        if not ps2mc_dir.mode_is_file(mode):
            raise ps2save.Subdir(f)
        if ps2mc_dir.tod_to_time(created) == 0:
            created = ps2mc_dir.tod_now()
        if ps2mc_dir.tod_to_time(modified) == 0:
            modified = ps2mc_dir.tod_now()
        save.set_file(i, (mode, 0, size, created, 0, 0, modified, 0, name), data)

A mymcplus/save/ems.py => mymcplus/save/ems.py +58 -0
@@ 0,0 1,58 @@

from . import ps2save
from .. import ps2mc_dir
from .utils import *
from ..round import round_up

def load(save, f):
    """Load EMS (.psu) save files."""

    cluster_size = 1024

    dirent = ps2save.unpack_dirent(read_fixed(f, ps2mc_dir.PS2MC_DIRENT_LENGTH))
    dotent = ps2save.unpack_dirent(read_fixed(f, ps2mc_dir.PS2MC_DIRENT_LENGTH))
    dotdotent = ps2save.unpack_dirent(read_fixed(f, ps2mc_dir.PS2MC_DIRENT_LENGTH))
    if (not ps2save.mode_is_dir(dirent[0])
            or not ps2save.mode_is_dir(dotent[0])
            or not ps2save.mode_is_dir(dotdotent[0])
            or dirent[2] < 2):
        raise ps2save.Corrupt("Not a EMS (.psu) save file.", f)

    dirent[2] -= 2
    save.set_directory(dirent)

    for i in range(dirent[2]):
        ent = ps2mc_dir.unpack_dirent(read_fixed(f, ps2mc_dir.PS2MC_DIRENT_LENGTH))
        if not ps2mc_dir.mode_is_file(ent[0]):
            raise ps2save.Subdir(f)
        flen = ent[2]
        save.set_file(i, ent, read_fixed(f, flen))
        read_fixed(f, round_up(flen, cluster_size) - flen)


def save(save, f):
    cluster_size = 1024

    dirent = save.dirent[:]
    dirent[2] += 2
    f.write(ps2mc_dir.pack_dirent(dirent))
    f.write(ps2mc_dir.pack_dirent((ps2mc_dir.DF_RWX | ps2mc_dir.DF_DIR | ps2mc_dir.DF_0400 | ps2mc_dir.DF_EXISTS,
                                   0, 0, dirent[3],
                                   0, 0, dirent[3], 0, b".")))
    f.write(ps2mc_dir.pack_dirent((ps2mc_dir.DF_RWX | ps2mc_dir.DF_DIR | ps2mc_dir.DF_0400 | ps2mc_dir.DF_EXISTS,
                                   0, 0, dirent[3],
                                   0, 0, dirent[3], 0, b"..")))

    for i in range(dirent[2] - 2):
        (ent, data) = save.get_file(i)
        f.write(ps2mc_dir.pack_dirent(ent))
        if not ps2mc_dir.mode_is_file(ent[0]):
            # print ent
            # print hex(ent[0])
            raise ps2save.Error("Directory has a subdirectory.")
        f.write(data)
        f.write(b"\0" * (round_up(len(data), cluster_size) - len(data)))
    f.flush()




R mymcplus/lzari.py => mymcplus/save/lzari.py +0 -0
A mymcplus/save/max_drive.py => mymcplus/save/max_drive.py +100 -0
@@ 0,0 1,100 @@

import binascii

from .. import ps2mc_dir
from . import ps2save
from . import lzari
from ..round import round_up
from .utils import *


PS2SAVE_MAX_MAGIC = b"Ps2PowerSave"


def load2(save, f):
    (length, s) = save._compressed
    save._compressed = None

    if lzari is None:
        raise ps2mc_dir.Error("The lzari module is needed to decompress MAX Drive saves.")
    s = lzari.decode(s, length, "decompressing " + save.dirent[8].decode("ascii") + ": ")
    dirlen = save.dirent[2]
    timestamp = save.dirent[3]
    off = 0
    for i in range(dirlen):
        if len(s) - off < 36:
            raise ps2save.Eof(f)
        (l, name) = struct.unpack("<L32s", s[off: off + 36])
        name = ps2mc_dir.zero_terminate(name)
        # print "%08x %08x %s" % (off, l, name)
        off += 36
        data = s[off: off + l]
        if len(data) != l:
            raise ps2save.Eof(f)
        save.set_file(i,
                      (ps2mc_dir.DF_RWX | ps2mc_dir.DF_FILE | ps2mc_dir.DF_0400 | ps2mc_dir.DF_EXISTS,
                       0, l, timestamp, 0, 0, timestamp, 0,
                       name),
                      data)
        off += l
        off = round_up(off + 8, 16) - 8


def load(save, f, timestamp=None):
    s = f.read(0x5C)
    magic = None
    if len(s) == 0x5C:
        (magic, crc, dirname, iconsysname, clen, dirlen,
         length) = struct.unpack("<12sL32s32sLLL", s)
    if magic != PS2SAVE_MAX_MAGIC:
        raise ps2save.Corrupt("Not a MAX Drive save file", f)
    if clen == length:
        # some saves have the uncompressed size here
        # instead of the compressed size
        s = f.read()
    else:
        s = read_fixed(f, clen - 4)
    dirname = ps2mc_dir.zero_terminate(dirname)
    if timestamp == None:
        timestamp = ps2mc_dir.tod_now()
    save.set_directory((ps2mc_dir.DF_RWX | ps2mc_dir.DF_DIR | ps2mc_dir.DF_0400 | ps2mc_dir.DF_EXISTS,
                        0, dirlen, timestamp, 0, 0, timestamp, 0,
                        dirname),
                       f)
    save._compressed = (length, s)


def save(save, f):
    if lzari is None:
        raise ps2mc_dir.Error("The lzari module is needed to decompress MAX Drive saves.")

    iconsysname = ""
    icon_sys = save.get_icon_sys()
    if icon_sys != None:
        title = ps2save.icon_sys_title(icon_sys, "ascii")
        if len(title[0]) > 0 and title[0][-1] != ' ':
            iconsysname = title[0] + " " + title[1].strip()
        else:
            iconsysname = title[0] + title[1].rstrip()
    s = b""
    dirent = save.dirent
    for i in range(dirent[2]):
        (ent, data) = save.get_file(i)
        if not ps2mc_dir.mode_is_file(ent[0]):
            raise ps2mc_dir.Error("Non-file in save file.")
        s += struct.pack("<L32s", ent[2], ent[8])
        s += data
        s += b"\0" * (round_up(len(s) + 8, 16) - 8 - len(s))
    length = len(s)
    progress = "compressing " + dirent[8].decode("ascii") + ": "
    compressed = lzari.encode(s, progress)
    hdr = struct.pack("<12sL32s32sLLL", PS2SAVE_MAX_MAGIC,
                      0, dirent[8], iconsysname.encode("ascii"),
                      len(compressed) + 4, dirent[2], length)
    crc = binascii.crc32(hdr)
    crc = binascii.crc32(compressed, crc)
    f.write(struct.pack("<12sL32s32sLLL", PS2SAVE_MAX_MAGIC,
                        crc & 0xFFFFFFFF, dirent[8], iconsysname.encode("ascii"),
                        len(compressed) + 4, dirent[2], length))
    f.write(compressed)
    f.flush()

A mymcplus/save/ps2save.py => mymcplus/save/ps2save.py +325 -0
@@ 0,0 1,325 @@
#
# This file is part of mymc+, based on mymc by Ross Ridge.
#
# mymc+ is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# mymc+ is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with mymc+.  If not, see <http://www.gnu.org/licenses/>.
#

"""A simple interface for working with various PS2 save file formats."""

import sys
import os
import binascii

from mymcplus.ps2mc_dir import *
from mymcplus.sjistab import shift_jis_normalize_table


from . import codebreaker, ems, max_drive, sharkport



class Error(Exception):
    """Base for all exceptions specific to this module."""
    pass


class Corrupt(Error):
    """Corrupt save file."""

    def __init__(self, msg, f = None):
        fn = None
        if f != None:
            fn = getattr(f, "name", None)
        self.filename = fn
        Error.__init__(self, "Corrupt save file: " + msg)


class Eof(Corrupt):
    """Save file is truncated."""

    def __init__(self, f = None):
        Corrupt.__init__(self, "Unexpected EOF", f)


class Subdir(Corrupt):
    def __init__(self, f = None):
        Corrupt.__init__(self, "Non-file in save file.", f)

#
# Table of graphically similar ASCII characters that can be used
# as substitutes for Unicode characters.
#
char_substs = {
    u'\u00a2': u"c",
    u'\u00b4': u"'",
    u'\u00d7': u"x",
    u'\u00f7': u"/",
    u'\u2010': u"-",
    u'\u2015': u"-",
    u'\u2018': u"'",
    u'\u2019': u"'",
    u'\u201c': u'"',
    u'\u201d': u'"',
    u'\u2032': u"'",
    u'\u2212': u"-",
    u'\u226a': u"<<",
    u'\u226b': u">>",
    u'\u2500': u"-",
    u'\u2501': u"-",
    u'\u2502': u"|",
    u'\u2503': u"|",
    u'\u250c': u"+",
    u'\u250f': u"+",
    u'\u2510': u"+",
    u'\u2513': u"+",
    u'\u2514': u"+",
    u'\u2517': u"+",
    u'\u2518': u"+",
    u'\u251b': u"+",
    u'\u251c': u"+",
    u'\u251d': u"+",
    u'\u2520': u"+",
    u'\u2523': u"+",
    u'\u2524': u"+",
    u'\u2525': u"+",
    u'\u2528': u"+",
    u'\u252b': u"+",
    u'\u252c': u"+",
    u'\u252f': u"+",
    u'\u2530': u"+",
    u'\u2533': u"+",
    u'\u2537': u"+",
    u'\u2538': u"+",
    u'\u253b': u"+",
    u'\u253c': u"+",
    u'\u253f': u"+",
    u'\u2542': u"+",
    u'\u254b': u"+",
    u'\u25a0': u"#",
    u'\u25a1': u"#",
    u'\u3001': u",",
    u'\u3002': u".",
    u'\u3003': u'"',
    u'\u3007': u'0',
    u'\u3008': u'<',
    u'\u3009': u'>',
    u'\u300a': u'<<',
    u'\u300b': u'>>',
    u'\u300a': u'<<',
    u'\u300b': u'>>',
    u'\u300c': u'[',
    u'\u300d': u']',
    u'\u300e': u'[',
    u'\u300f': u']',
    u'\u3010': u'[',
    u'\u3011': u']',
    u'\u3014': u'[',
    u'\u3015': u']',
    u'\u301c': u'~',
    u'\u30fc': u'-',
}



PS2SAVE_NPO_MAGIC = b"nPort"


def detect_file_type(f):
    """Detect the type of PS2 save file.

    The file-like object f should be positioned at the start of the file.
    """

    hdr = f.read(PS2MC_DIRENT_LENGTH * 3)
    if hdr[:12] == max_drive.PS2SAVE_MAX_MAGIC:
        return "max"
    if hdr[:17] == sharkport.PS2SAVE_SPS_MAGIC:
        return "sps"
    if hdr[:4] == codebreaker.PS2SAVE_CBS_MAGIC:
        return "cbs"
    if hdr[:5] == PS2SAVE_NPO_MAGIC:
        return "npo"
    #
    # EMS (.psu) save files don't have a magic number.  Check to
    # see if it looks enough like one.
    #
    if len(hdr) != PS2MC_DIRENT_LENGTH * 3:
        return None
    dirent = unpack_dirent(hdr[:PS2MC_DIRENT_LENGTH])
    dotent = unpack_dirent(hdr[PS2MC_DIRENT_LENGTH
                               : PS2MC_DIRENT_LENGTH * 2])
    dotdotent = unpack_dirent(hdr[PS2MC_DIRENT_LENGTH * 2:])
    if (mode_is_dir(dirent[0]) and mode_is_dir(dotent[0])
            and mode_is_dir(dotdotent[0]) and dirent[2] >= 2
            and dotent[8] == b"." and dotdotent[8] == b".."):
        return "psu"

    return None


def unpack_icon_sys(s):
    """Unpack an icon.sys file into a tuple."""

    # magic, title offset, ...
    # [14] title, normal icon, copy icon, del icon
    a = struct.unpack("<4s2xH4x"
                      "L" "16s16s16s16s" "16s16s16s" "16s16s16s" "16s"
                      "68s64s64s64s512x", s)
    a = list(a)
    for i in range(3, 7):
        a[i] = struct.unpack("<4L", a[i])
        a[i] = list(map(hex, a[i]))
    for i in range(7, 14):
        a[i] = struct.unpack("<4f", a[i])
    a[14] = zero_terminate(a[14])
    a[15] = zero_terminate(a[15])
    a[16] = zero_terminate(a[16])
    a[17] = zero_terminate(a[17])
    return a


def icon_sys_title(icon_sys, encoding=None):
    """Extract the two lines of the title stored in an icon.sys tuple."""

    offset = icon_sys[1]
    title = icon_sys[14]
    title2 = shift_jis_conv(title[offset:], encoding)
    title1 = shift_jis_conv(title[:offset], encoding)
    return (title1, title2)



#
# Set up tables of illegal and problematic characters in file names.
#
_bad_filename_chars = ("".join(map(chr, list(range(32))))
               + "".join(map(chr, list(range(127, 256)))))
_bad_filename_repl = "_" * len(_bad_filename_chars)

if os.name in ["nt", "os2", "ce"]:
    _bad_filename_chars += '<>:"/\\|'
    _bad_filename_repl +=  "()_'___"
    _bad_filename_chars2 = _bad_filename_chars + "?* "
    _bad_filename_repl2 = _bad_filename_repl +   "___"
else:
    _bad_filename_chars += "/"
    _bad_filename_repl += "_"
    _bad_filename_chars2 = _bad_filename_chars + "?*'&|:[<>] \\\""
    _bad_filename_repl2 = _bad_filename_repl +   "______(())___"

_filename_trans = str.maketrans(_bad_filename_chars, _bad_filename_repl)
_filename_trans2 = str.maketrans(_bad_filename_chars2, _bad_filename_repl2)


def fix_filename(filename):
    """Replace illegal or problematic characters from a filename."""
    return filename.translate(_filename_trans)


def make_longname(dirname, sf):
    """Return a string containing a verbose filename for a save file."""

    icon_sys = sf.get_icon_sys()
    title = ""
    if icon_sys is not None:
        title = icon_sys_title(icon_sys, "ascii")
        title = title[0] + " " + title[1]
        title = " ".join(title.split())
    crc = binascii.crc32(b"")
    for (ent, data) in sf:
        crc = binascii.crc32(data, crc)
    if len(dirname) >= 12 and (dirname[0:2] in ("BA", "BJ", "BE", "BK")):
        if dirname[2:6] == "DATA":
            title = ""
        else:
            #dirname = dirname[2:6] + dirname[7:12]
            dirname = dirname[2:12]

    return fix_filename("%s %s (%08X)" % (dirname, title, crc & 0xFFFFFFFF))



def shift_jis_conv(src, encoding = None):
    """Convert Shift-JIS strings to a graphically similar representation.

    If encoding is "unicode" then a Unicode string is returned, otherwise
    a string in the encoding specified is returned.  If necessary,
    graphically similar characters are used to replace characters not
    exactly    representable in the desired encoding.
    """
    
    if encoding == None:
        encoding = sys.getdefaultencoding()
    if encoding == "shift_jis":
        return src
    u = src.decode("shift_jis", "replace")
    if encoding == "unicode":
        return u
    a = []
    for uc in u:
        try:
            uc.encode(encoding)
            a.append(uc)
        except UnicodeError:
            for uc2 in shift_jis_normalize_table.get(uc, uc):
                a.append(char_substs.get(uc2, uc2))
    
    return "".join(a)




class ps2_save_file(object):
    """The state of a PlayStation 2 save file."""
    
    def __init__(self):
        self.file_ents = None
        self.file_data = None
        self.dirent = None
        self._defer_load_max_file = None

    def set_directory(self, ent, defer_file = None):
        self._defer_load_max_file = defer_file
        self._compressed = None
        self.file_ents = [None] * ent[2]
        self.file_data = [None] * ent[2]
        self.dirent = list(ent)

    def set_file(self, i, ent, data):
        self.file_ents[i] = ent
        self.file_data[i] = data

    def get_directory(self):
        return self.dirent

    def get_file(self, i):
        if self._defer_load_max_file is not None:
            f = self._defer_load_max_file
            self._defer_load_max_file = None
            max_drive.load2(self, f)
        return (self.file_ents[i], self.file_data[i])

    def __len__(self):
        return self.dirent[2]

    def __getitem__(self, index):
        return self.get_file(index)
    
    def get_icon_sys(self):
        for i in range(self.dirent[2]):
            (ent, data) = self.get_file(i)
            if ent[8].decode("ascii") == "icon.sys" and len(data) >= 964:
                return unpack_icon_sys(data[:964])
        return None


A mymcplus/save/sharkport.py => mymcplus/save/sharkport.py +62 -0
@@ 0,0 1,62 @@

from .. import ps2mc_dir
from . import ps2save
from .utils import *

PS2SAVE_SPS_MAGIC = b"\x0d\0\0\0SharkPortSave"

def load(save, f):
    magic = f.read(17)
    if magic != PS2SAVE_SPS_MAGIC:
        raise ps2save.Corrupt("Not a SharkPort/X-Port save file.", f)
    (savetype,) = struct.unpack("<L", read_fixed(f, 4))
    dirname = read_long_string(f)
    datestamp = read_long_string(f)
    comment = read_long_string(f)

    (flen,) = struct.unpack("<L", read_fixed(f, 4))

    (hlen, dirname, dirlen, dirmode, created, modified) \
        = struct.unpack("<H64sL8xH2x8s8s", read_fixed(f, 98))
    read_fixed(f, hlen - 98)

    dirname = ps2mc_dir.zero_terminate(dirname)
    created = ps2mc_dir.unpack_tod(created)
    modified = ps2mc_dir.unpack_tod(modified)

    # mode values are byte swapped
    dirmode = dirmode // 256 % 256 + dirmode % 256 * 256
    dirlen -= 2
    if not ps2mc_dir.mode_is_dir(dirmode) or dirlen < 0:
        raise ps2save.Corrupt("Bad values in directory entry.", f)
    save.set_directory((dirmode, 0, dirlen, created, 0, 0, modified, 0, dirname))

    for i in range(dirlen):
        (hlen, name, flen, mode, created, modified) \
            = struct.unpack("<H64sL8xH2x8s8s",
                            read_fixed(f, 98))
        if hlen < 98:
            raise ps2save.Corrupt("Header length too short.", f)
        read_fixed(f, hlen - 98)
        name = ps2mc_dir.zero_terminate(name)
        created = ps2mc_dir.unpack_tod(created)
        modified = ps2mc_dir.unpack_tod(modified)
        mode = mode // 256 % 256 + mode % 256 * 256
        if not ps2mc_dir.mode_is_file(mode):
            raise ps2save.Subdir(f)
        save.set_file(i, (mode, 0, flen, created, 0, 0,
                          modified, 0, name),
                      read_fixed(f, flen))

    # ignore 4 byte checksum at the end



# def sps_check(s):
#     """Calculate the checksum for a SharkPort save."""
#
#     h = 0
#     for c in array.array('B', s):
#         h += c << (h % 24)
#         h &= 0xFFFFFFFF
#     return h
\ No newline at end of file

A mymcplus/save/utils.py => mymcplus/save/utils.py +18 -0
@@ 0,0 1,18 @@

import struct
from . import ps2save

def read_fixed(f, n):
    """Read a string of a fixed length from a file."""

    s = f.read(n)
    if len(s) != n:
        raise ps2save.Eof(f)
    return s


def read_long_string(f):
    """Read a string prefixed with a 32-bit length from a file."""

    length = struct.unpack("<L", read_fixed(f, 4))[0]
    return read_fixed(f, length)
\ No newline at end of file

M test/test_lzari.py => test/test_lzari.py +1 -1
@@ 1,6 1,6 @@

import array
from mymcplus import lzari
from mymcplus.save import lzari


def bits_to_str(bits):

M test/test_memorycard.py => test/test_memorycard.py +6 -2
@@ 290,8 290,10 @@ def test_import_psu(monkeypatch, capsys, data, mc02_copy):

def test_import_max(monkeypatch, capsys, data, mc02_copy):
    from mymcplus import ps2mc
    from mymcplus import ps2save
    from mymcplus import ps2mc_dir
    from mymcplus.save import ps2save
    patch_fixed_time(monkeypatch, ps2mc)
    patch_fixed_time(monkeypatch, ps2mc_dir)
    patch_fixed_time(monkeypatch, ps2save)

    mc_file = mc02_copy.join("mc02.ps2").strpath


@@ 345,8 347,10 @@ def test_import_xps(monkeypatch, capsys, data, mc02_copy):

def test_import_cbs(monkeypatch, capsys, data, mc02_copy):
    from mymcplus import ps2mc
    from mymcplus import ps2save
    from mymcplus import ps2mc_dir
    from mymcplus.save import ps2save
    patch_fixed_time(monkeypatch, ps2mc)
    patch_fixed_time(monkeypatch, ps2mc_dir)
    patch_fixed_time(monkeypatch, ps2save)

    mc_file = mc02_copy.join("mc02.ps2").strpath