01 前言

作为一个80后的游戏老玩家,PS2游戏机在我心中一直有着特殊的地位。时至今日,已经过去了20多年,然而,最近我因为模拟器的缘故重新接触到了它。在重温了一段时间游戏后,我突发奇想,能否通过现在的知识来回忆年少时的自己?于是,我开始了这一系列文章的创作,从分析PS2存储卡的文件系统开始,逐步深入的解析其文件存储机制及每个游戏的存档文件。我的目标是,最终通过Python和OpenGL,模拟出游戏存档中经典的3D人物旋转效果,以此来纪念这个曾经陪伴我度过青春时光的经典游戏机。

这是系列的第一篇作品,解析PS2存储卡的文件系统。

02 词汇表

  • 存储卡
    • 指PS2实体机使用的专用记忆卡介质,使用时插在主机上,与主机是相互独立的两个设备。
  • NAND闪存
    • PS2存储卡使用的内部芯片,一种非易失性存储设备。
  • 存储卡文件
    • 指PS2模拟器使用的存储卡镜像文件,保存在模拟器所在的电脑磁盘上,以.ps2为后缀。是我们这篇文章解析的目标。
  • SuperBlock
    • “超级块”,位于文件系统开头的固定部分,由存储卡格式化时写入,不可更改,记录了存储卡的基本硬件指标。
  • page
    • “页”,文件系统的最小读写单元,页的大小定义于超级块中。
  • cluster
    • “簇”,文件系统中的最小分配单位,要保存一个文件至少需要一个簇。簇的大小定义于超级块中。
  • block
    • “块”,文件系统的最小擦除单位,块的大小定义于超级块中。
  • 擦除
    • 闪存初始化时页中的每一个bit都为1,写操作可以将bit置为0,但无法恢复为1。擦除是将bit恢复成1的唯一途径,但缺点是擦除以块为单位,哪怕只是修改一位数据,也得先擦除一个块,然后再用写操作把块的每一页恢复。这也是PS2游戏存档时普遍较慢的原因。
  • FAT
    • “文件分配表”,与FAT16FAT32文件系统中的文件分配表类似。由于文件会保存在多个簇上,而簇可以是不连续的,为了确保在存取文件时能够检索到所有连续或不连续的簇地址,文件分配表采用了“簇链”这种链表的记录方式。
  • ifc(indirect FAT cluster)
    • “间接FAT簇”,是一个簇,其中保存有存储卡上FAT簇的列表。
  • ifc_list
    • ifc的数组,定义于超级块中。通过它可以找到ifc簇。
  • ECC(Error Correction Code)
    • “纠错码”,闪存特性,写入page时需要对每一页进行纠错码计算,并写入spare area中。
  • spare area
    • “备用区域”,为每一个页保存ECC的一段空间。
  • entry
    • “条目”,存储卡上保存的文件或目录的基本信息单元,比如:文件(目录)名、大小、第一个簇编号等。

03 文件系统结构

注:这里用标准的8M存储卡举例。

3.1 数据结构

从"超级块"中可得知"页"的大小是512字节,“簇"的大小是2个"页”。spare area可以根据公式(page_len / 128) * 4得到,是16字节,则文件系统基本数据结构如图:

3.2 逻辑结构

了解了最基本的数据结构,接下来我们划分一下存储卡的逻辑结构。如下图,一块存储卡大致能分为以下几个逻辑区块。(黑白部分本文不涉及,可以忽略。)注意:组成逻辑区块的最小单位是簇。

超级块

位于整个文件开头(也就是第一个簇)的前340个字节,这是文件系统中唯一具有固定位置的部分。下图示意了一个存储卡文件的超级块。

注:PS2存储卡的字节序是小端序Little-endian。

OffsetNameLengthDefaultDescription
0magicbyte[28]-固定字符串"Sony PS2 Memory Card Format", 表明该卡已成功初始化
28versionbyte[12]1.X.0.0版本号
40page_lenuint16512page的大小(以字节为单位)
42pages_per_clusteruint162簇中的页数
44pages_per_blockuint1616块中的页数
46-uint160xFF00未知
48clusters_per_carduint328192卡的总大小(以簇为单位)
52alloc_offsetuint3241第一个可分配簇
56alloc_enduint328135最后一个可分配簇
60rootdir_clusteruint320根目录的第一个簇,相对于alloc_offset
64backup_block1uint321023本文无用
68backup_block2uint321022本文无用
80ifc_listuint32[32]8间接 FAT 簇列表,在标准 8M 卡上只有一个间接 FAT 簇
208bad_block_listuint32[32]-1本文无用
336card_typebyte2必须是2,说明这是一张PS2存储卡
337card_flagsbyte0x52存储卡的物理特性

字段page_lenpages_per_clusterpages_per_blockcluster_per_card定义文件系统的基本几何结构。可以使用ifc_list访问FATrootdir_cluster给出根目录的第一个簇。FAT和目录项中的簇偏移量都与alloc_offset相关。

FAT

文件分配表是一个链表,当你找到一个文件的起始簇时,你想象有两个线程,线程x用来读取这个簇里的内容(即数据),线程y去FAT里寻找下一个簇,交由x读取,然后不断循环,当然两个线程不是必须的。这里引用一张图说明一下这种工作方式:

  • 已知文件A,起始簇是8
  • 线程x去簇8读取第一块数据A0
  • 线程y去FAT查找8的下一个簇是13
  • 线程x继续读取簇13的数据A1
  • 线程y去FAT查找13的下一个簇是7
  • 不断循环

图片来源:https://www.slideserve.com/yahto/file-system-implementation

直接FAT

由前文可以得知,直接FAT和间接FAT都是保存在簇里的。簇里的数据必须有一个良好的结构,才能使我们简单的解析成FAT链表。FAT在簇里的结构可以想象成长这样:

这是一个矩阵M,行定义为FAT所在的簇,列定义为每个FAT簇里的数据。每个FAT簇,保存的都是4字节32位的整形数组,数量为1024 / 4 = 256个,因此矩阵有256列。FAT一共有多少个簇呢?这点可以在间接FAT的簇中解析出来,我们之后再讲。在这里FAT一共占据了32个簇,因此矩阵有32行。

M矩阵的大小为32 * 256 = 8192,意味着这个FAT可以管理8192个簇。假设现在要找簇n在矩阵中的位置rowcolumn,可以根据简单的计算得出:

1
2
row = (n // 256) % 256
column = n % 256

既然已经计算出了位置,那就可以取到对应的值了,没错,这个值?就是下一个簇。通过不断循环,直到取到的值为0xFFFFFFFF,表示簇链到结尾了,不需要再查找了。

注:FAT表里储存的值为32位,最高位为8代表正常使用的簇,其它值代表簇未分配,最高位为8时,取低31位的整形值。值为0xFFFFFFFF代表已是簇链末尾。

间接FAT

前文留了一个问题,为什么FAT占有了32个簇?

在超级块中有一个字段ifc_list,是一个4字节32位的整形数组,再想象一下上面出现的矩阵。ifc_list是一个只有一行的矩阵,虽然它有32个元素,但只有第一个有值,其值8即间接FAT簇ifc。将簇8的内容按照上文的方法解析出来,再形成一个矩阵,行是ifc_list的个数,理论上是32,但由于只有1个元素,因此这个矩阵的行也为1。矩阵的列依然是256。解析其中的值,可以得到FAT所在的簇为9到40,即32个。

可分配簇

是一个范围,从alloc_offset开始到alloc_end结束。除去超级块、FAT、保留簇等的位置,所有的游戏存档都位于可分配簇内。

04 文件和目录

接着我们要研究下可分配簇里,每个簇都保存了些什么东西?简单来说,可分配簇里只有两种簇:“条目簇”和“数据簇”。保存条目的簇称为“条目簇”,保存数据的簇称为“数据簇”。

4.1 条目

每个目录或文件都有一个“条目”,可以看作是元数据,保存有文件名、大小、创建和修改时间等属性。每个“条目”的长度为 512 字节,因此每个 1024 簇中只能容纳两个“条目”。“条目簇”不会保存文件数据,即使“条目簇”里只有一个“条目”。

除了根目录没有root这个“条目”外,每个目录都有以自己的目录名命名的“条目”,每个文件也有以自己的文件名命名的“条目”,“条目”的结构如下表:

OffsetNameLengthDescription
0modeuint16标识该文件的属性
4lengthuint32如果是文件,以字节为单位;如果是目录,以项为单位。
8createdbyte[8]创建时间
16clusteruint32条目对应的第一个簇,是相对于alloc_offset的相对值。
20dir_entryuint32无用
24modifiedbyte[8]修改时间
32attruint32用户属性
36namebyte[32]文件名,x00以后的需截断
  • mode字段请参考:https://www.ps2savetools.com/ps2memcardformat.html 。是一个4字节整形数,每个字节用对应的掩码比对,即可识别“条目”对应的文件类型。比如:0x8427代表一个目录,0x8497代表一个文件。
  • cluster字段代表了“条目”的第一个簇。如果本条目是目录,则这个簇指向的是当前目录的下一个“条目簇”;如果本条目是文件,则这个簇指向的是文件的第一个“数据簇”。
  • 每个目录下的第一个“条目簇”一定是名为...的两个目录,这两个目录项代表当前目录和父目录,就像在Unix中一样。
  • 目录下有几个“条目”以及文件有几个字节都是由length字段决定的,当你按照“簇链”读取文件的时候,需要自己记录最后一个簇的哪里是最后一个字节。

05 结尾

至此,相信大家对一个ps2存储文件有了大致认识了吧。有兴趣的可以自己写一个程序解析下了。稍后我也会创建一个项目,附上本篇文章涉及的源代码。

下一篇文章我们将开始把游戏存档从存储卡里导出来,看看每个游戏存档都有哪些文件。

06 参考文献

本文主要参考了如下文章,在此表示感谢🙏: