author: caol64 title: Exporting Save Files From a PS2 Memory Card slug: exporting-file-from-ps2-memcard description: Dive into the exciting world of PS2 memory card filesystems and learn how to extract specific game saves using Python. Fully executable code provided. Step into our in-depth analysis! date: 2023-09-29 17:49:16 draft: false ShowToc: true TocOpen: true tags:
In the previous article, we analyzed the file system of the PS2 memory card. This time, we'll dive straight into practice and write Python code to export specific game saves. The complete code for this article can be found at: ps2mc-browser.
The structure of the
SuperBlock` is as follows, with a size of 340 bytes:
struct SuperBlock {
char magic[28];
char version[12];
uint16 page_size;
uint16 pages_per_cluster;
uint16 pages_per_block;
uint16 unknown; // ignore
uint32 clusters_per_card;
uint32 alloc_offset;
uint32 alloc_end;
uint32 rootdir_cluster;
uint32 backup_block1; // ignore
uint32 backup_block2; // ignore
uint32 unknown[2]; // ignore
uint32 ifc_list[32];
uint32 bad_block_list[32]; // ignore
byte card_type;
byte card_flags;
byte unknown; // ignore
byte unknown; // ignore
};
Use struct.unpack()
to unpack:
struct.Struct("<28s12sHHH2xLLLL4x4x8x128s128xbbxx").unpack(byte_val)
Obtain page_size
and pages_per_cluster
.
page
and cluster
Calculate the sizes of page
and cluster
using the formula:
self.spare_size = (self.page_size // 128) * 4 # Size of spare area in bytes
self.raw_page_size = self.page_size + self.spare_size # Total size of page including spare area in bytes
self.cluster_size = self.page_size * self.pages_per_cluster # Size of cluster in bytes
Read page
and cluster
, discarding the contents of the spare area
:
def read_page(self, n): # n is the page number
offset = self.raw_page_size * n
return self.byte_val[offset: offset + self.page_size]
def read_cluster(self, n): # n is the cluster number
page_index = n * self.pages_per_cluster
byte_buffer = bytearray()
for i in range(self.pages_per_cluster):
byte_buffer += self.read_page(page_index + i)
return bytes(byte_buffer)
FAT
MatrixFrom the previous article, we know the construction method of the FAT
matrix as follows:
def __build_fat_matrix(self):
# Build the indirect FAT from ifc_list
indirect_fat_matrix = self.__build_matrix(self.ifc_list)
# Indirect FAT is a one-dimensional array
indirect_fat_matrix = indirect_fat_matrix.reshape(indirect_fat_matrix.size)
# Exclude unallocated values like 0xFFFFFFFF
indirect_fat_matrix = [x for x in indirect_fat_matrix if x != Fat.UNALLOCATED]
# Build the direct FAT from the indirect FAT
fat_matrix = self.__build_matrix(indirect_fat_matrix)
return fat_matrix
def __build_matrix(self, cluster_list):
# Initialize the matrix
matrix = np.zeros((len(cluster_list), self.fat_per_cluster), np.uint32)
# Iterate through clusters
for index, v in enumerate(cluster_list):
# Read out 256 FAT values for each cluster
cluster_value = self.read_cluster(v)
cluster_value_unpacked = np.frombuffer(cluster_value, np.uint32)
for index0, v0 in enumerate(cluster_value_unpacked):
# Assign values to the matrix
matrix[index, index0] = v0
return matrix
# Given a cluster number n, find its corresponding FAT value
def get_fat_value(self, n):
value = self.fat_matrix[(n // self.fat_per_cluster) % self.fat_per_cluster,
n % self.fat_per_cluster]
# The highest bit being 8 represents an allocated cluster, other values represent unallocated clusters,
# when the highest bit is 8, the integer value of the lower 31 bits is taken
return value ^ Fat.ALLOCATED_BIT if value & Fat.ALLOCATED_BIT > 0 else value
An entry serves as metadata for all files and directories. The data structure of an entry is as follows:
struct Entry {
uint16 mode;
uint16 unknown; // ignore
uint32 length;
char created[8];
uint32 cluster;
uint32 dir_entry; // ignore
char modified[8];
uint32 attr; // ignore
char padding[28]; // ignore
char name[32];
char padding[416]; // ignore
};
Using struct.unpack()
to unpack:
struct.Struct("<H2xL8sL4x8s4x28x32s416x").unpack(byte_val)
Each entry is 512 bytes in size. The most important field in an entry is cluster
, which identifies the cluster number corresponding to the file or directory of that entry. If the entry represents a directory, the cluster number corresponds to the "entry cluster"; if the entry represents a file, the cluster number corresponds to the "file cluster". Another important field is length
, which represents the number of entries in a directory if the entry represents a directory, or the number of bytes in a file if the entry represents a file.
# Read entry, where each entry is 512 bytes and multiple entries can be contained in one cluster
def read_entry_cluster(self, cluster_offset):
cluster_value = self.read_cluster(cluster_offset + self.alloc_offset)
return Entry.build(cluster_value)
# Read data, starting from the first cluster until the end of the file
def read_data_cluster(self, entry):
byte_buffer = bytearray()
chain_start = entry.cluster
bytes_read = 0
while chain_start != Fat.CHAIN_END:
to_read = min(entry.length - bytes_read, self.cluster_size)
byte_buffer += self.read_cluster(chain_start + self.alloc_offset)[:to_read]
bytes_read += to_read
chain_start = self.get_fat_value(chain_start)
return bytes(byte_buffer)
def build(byte_val):
entry_count = len(byte_val) // Entry.__size
entries = []
for i in range(entry_count):
entries.append(Entry(byte_val[i * Entry.__size:
i * Entry.__size + Entry.__size]))
return entries
As mentioned in the previous article, the root directory does not have entries. Its first "entry cluster" is specified in the rootdir_cluster
field of the superblock, and the number of entries it contains is specified in the .
entry.
To read all files from the memory card, the first step is to parse all entries in the root directory and then parse all files under those entries. Therefore, all you need to do is loop through the following method:
def find_sub_entries(self, parent_entry):
chain_start = parent_entry.cluster
sub_entries = []
while chain_start != Fat.CHAIN_END:
entries = self.read_entry_cluster(chain_start)
for e in entries:
if len(sub_entries) < parent_entry.length:
sub_entries.append(e.unpack())
chain_start = self.get_fat_value(chain_start)
return [x for x in sub_entries if not x.name.startswith('.')]
The result is as follows:
BISCPS-15119sv01
GameData
BISCPS-15119sv01
icon00.ico
icon.sys
BISCPS-15116sv01
GameData
BISCPS-15116sv01
icon00.ico
icon.sys
BASLUS-21441DBZT2
icon.sys
dbzsn.ico
BASLUS-21441DBZT2
...
Now that all file entries have been read, all we need to do is write a method that can export all files in a directory based on the input game name.
def export(self, name, dest):
dir_path = dest + os.sep + name
if not os.path.exists(dir_path):
os.mkdir(dir_path)
entries = self.lookup_entry_by_name(name)
for e in entries:
if e.is_file():
with open(dir_path + os.sep + e.name, 'wb') as f:
f.write(self.ps2mc.read_data_cluster(e))
Now, we can successfully export a game's save files from the memory card. If you have a Python runtime environment, you can directly run the code provided in the GitHub link provided at the beginning of the article.
In the next article, we will analyze the icon.sys
and xxx.ico
files in each save file. These two files contain the data for the 3D effects in the save files.