GTA:VC罪吧汉化组存档不兼容原因解析及修复

写在开头

这里给出本文思路的 C#实现

BakaFT/sv2b: Remove illegal characters from GTA:VC savefiles (github.com)

存档的“单向兼容”现象

用过汉化的朋友们可能会注意到,汉化版的存档后缀名发生了变化,即变为了.sv

并且,原版存档可以改个后缀直接在汉化版使用,但是反过来却不行,游戏甚至会直接卡死

这是为什么?下面进行分析

逆向分析

代码逻辑参考 GTAModding/re3

由于 DMCA takedown,目前仓库无限期停止公开访问

游戏的存档/读档逻辑

下面是游戏存档时的操作:

1
2
3
4
5
6
7
8
// from GTAmodding/revc 
// src/save/PCSave.cpp

// 定义存档文件名,其中".b"就是后缀
sprintf(savename, "%s%i%s", DefaultPCSaveFileName, i + 1, ".b");
//打开对应文件,准备写入
int file = CFileMgr::OpenFile(savename, "rb");
//下略

可以看到,savename最终是.b结尾的

同理,读档的时候也是这样,并且只扫描使用该后缀的文件

汉化补丁的相关修改

这是gta_cn.asi的某个函数,实现了对bsv的替换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void *__cdecl sub_10001FD0(int a1)
{
void *result; // eax

if ( a1 )
{
if ( !dword_100168C4 )
{
memcpy(&unk_1001696C, (const void *)0x6D863C, 4u);
memcpy(&unk_100168D4, (const void *)0x6D8AB8, 4u);
memcpy(&unk_10016970, (const void *)0x6D8AC8, 4u);
}
//程序正常运行的情况下会执行该分支,即对内存操作,修改字符
strcpy((char *)0x6D863C, ".sv");
strcpy((char *)0x6D8AB8, ".sv");
result = strcpy((char *)0x6D8AC8, ".sv");
}
else
{
memcpy((void *)0x6D863C, &unk_1001696C, 4u);
memcpy((void *)0x6D8AB8, &unk_100168D4, 4u);
result = memcpy((void *)0x6D8AC8, &unk_10016970, 4u);
}
return result;
}

所以,原版与汉化会互相忽略掉对方的存档

那为什么原版的可以给汉化用,反过来就崩溃?因为问题在于存档文件内容,而非后缀名

存档的二进制内容

存档名

根据 Saves (GTA VC) - GTAMods Wiki 可以了解到

文件中,[0x0004,0x0033]这长为48字节的区域为存档名

你可以在src\save\GenericGameStorage.cpp中看到这部分的文件结构与操作逻辑

可能是出于日本市场的原因,R*在游戏中提供了 Unicode 的支持,GXT/Japanese - GTAMods Wiki 中给出的字符表似乎就是SHIFT_JIS编码表

可以看到存档的名字其实是一个wchar类型的数组,这与存档中2个字节表示一个字符相符合

1
2
3
4
5
6
// 最后一次完成的任务名
wchar *lastMissionPassed;
// 存档名
wchar saveName[24];
// 后缀,其实反映到游戏里就是存档名后面的"..."
wchar suffix[6];

接下来是存档名的写入过程,在这之前,你可能需要了解一下什么是 GXT

GXT 格式是一种加密的文本,通过类似于字典(Key - Value)的方式,实现了游戏文本的映射,其初衷是方便游戏的本地化工作

可由类似如下的形式表达

1
2
[ITBEG]
In the beginning

也就是说 TheText.Get("ITBEG") == "In the beginning"

想深入了解可以查阅 GXT - GTAMods Wiki

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 如果完成过任务,则获取最后一个完成的任务的名字,否则获取[ITBEG]对应的字符,即`In the beginning`
// 从已经加载到内存的GXT内容中搜索任务名
lastMissionPassed = TheText.Get(CStats::LastMissionPassedName[0] ? CStats::LastMissionPassedName : "ITBEG");
// 使 suffix '.','.','\0',野值,野值]
// 野值会被写入存档中,但是并无影响
AsciiToUnicode("...'", suffix);
suffix[3] = L'\0';
TextCopy(saveName, lastMissionPassed);
int len = UnicodeStrlen(saveName);
saveName[len] = '\0';

// 如果存档名长度超过22,即超过11个字符
if (len > ARRAY_SIZE(saveName)-2)
// 将suffix复制到saveName的最后
TextCopy(&saveName[ARRAY_SIZE(saveName)-ARRAY_SIZE(suffix)], suffix);
// 将最后一个野值替换为 '\0',但由于suffix[3]已经是'\0'所以这句话没有实质性作用
saveName[ARRAY_SIZE(saveName)-1] = '\0';

编码

而引起游戏崩溃的关键就在于获取任务名这一步

游戏的字库是通过一张保存了所有字符的大贴图实现的,每个字符都有自己的唯一编号,存档文件中即使用这个编号表示

比如英文原版中,The Party在存档中是这样保存的,其实就是 ASCII 编码

1
2
5400 6800 6500 2000 5000 6100 7200 7400 7900
T h e P a r t y

对于英文版来说,使用 ASCII 码是十分方便的,而对于拉丁语系的其他语言,也仅仅是多加一些符号的问题

但是对于日语和汉语,字符实在太多了,这个时候编号就会变得多起来,但是这个编码表最开始的部分必须和英文版的一样,不然可能会出现应该显示字母A但显示汉字的情况

现实的计算机操作系统中的编码也是如此,编码不兼容 ASCII 就会出现各种想不到的问题

到这里,就可以解释最初的“单向兼容”现象了

问题解析

在使用TheText.Get(*const wchar)获取任务名时,是基于当前的游戏的语言设定的。如果使用了汉化补丁,那么获取到的字符编码很大概率对于英文原版来说都是不认识的(除非有纯英文,数字的任务名),这个时候游戏又没有做对应的处理(毕竟汉化是第三方作品),那么游戏就炸掉了

反过来,汉化编码是兼容英文的,所以无论如何都是可以正常显示存档名,然后正确读取的

如何修复存档

知道原理就很轻松的可以想到,将存档名中的非 ASCII 编码去掉就行

但需要注意的是,游戏存档中有一个校验和,储存在存档的最后 4 个字节,使用小端储存方式

算法如下:

设置一个unsigned int型变量,以单个字节为基本单元,将[0,201824)区间的所有字节对应值相加,即为校验和,并从201824开始写入

此处给出 C#实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void FixCheckSum(string path)
{
uint checkSum = 0;
using (BinaryReader reader = new BinaryReader(File.Open(path, FileMode.Open)))
{
while (reader.BaseStream.Position < reader.BaseStream.Length && reader.BaseStream.Position < 201824)
{
checkSum += reader.ReadByte();
}
}
using (BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Open)))
{
writer.BaseStream.Seek(201824, SeekOrigin.Begin);
writer.Write(checkSum);
}
}
Author

BakaFT

Posted on

2021-09-02

Updated on

2023-12-28

Licensed under

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×