本文旨在说明修改游戏存档的思路、编程方法和一点技巧,上篇内容分别为前言、手工修改游戏存档文件的方法、自动检查游戏存档中的数值、改进1:对地址文件取得交集等内容。本期向读者介绍下篇部分内容。
改进2:相对值查找
游戏中的某些数值并没有明确告诉你多少,但是触发事件后会增加或者减少,比如仙剑3中间女主角的好感度。游戏中间有对话,根据对话的内容可以增加对男主角的好感度。好感度影响着结局,想玩出多种结局的家伙还是“玩弄”一下女主角的感情吧!
方法还是比较简单的,只要同时读取两个文件,每次读取一个字符后比较两者之差是否符合指定的相对值,假如符合的话,保留信息。
template<class T>
void RelativeValue()
{
typedef fstream::off_type AddressType;
EInputStream CIN(cin);
string FN;
cout<<"First binary file name:\t";
CIN>>FN;
ifstream Read1(FN.c_str(),ios::in ios::binary);
//读+二进制模式
if(!Read1)
{
cerr<<"Open "<<FN<<" failed.\n";
return;
}
cout<<"Second binary file name:\t";
CIN>>FN;
ifstream Read2(FN.c_str(),ios::in ios::binary);
if(!Read2)
{
cerr<<"Open "<<FN<<" failed.\n";
return;
}
int ByteNumber;
cout<<"Byte number:\t";
CIN>>ByteNumber;//字节数
int RV;//指定的相对值
cout<<"Relative value(value1-value2):\t";
cin>>RV;
const int MaxByte=sizeof(T);
const int CharSize=sizeof(char);
if(ByteNumber<1ByteNumber>MaxByte)
ByteNumber=MaxByte;
T Value1,Value2;//两个文件中的数值
char* P1=reinterPRet_cast<char*>(&Value1);
char* P2=reinterpret_cast<char*>(&Value2);
//先全部清0
memset(P1,0,MaxByte*CharSize);
memset(P2,0,MaxByte*CharSize);
AddressType Address=0;
//填布满P1
Read1.read(P1,CharSize*ByteNumber);
//填布满P2
Read2.read(P2,CharSize*ByteNumber);
//保存信息的链表
typedef list<pair<AddressType,pair<T,T> > > InfoList;
InfoList IL;
int Occurs=0;
//当两个文件都还没有读完
while(Read1 && Read2)
{
if(Value1-Value2==RV)//符合条件了
{
//保存信息
IL.push_back(make_pair(Address,make_pair(Value1, Value2)));
++Occurs;
}
//除旧
memcpy(P1,&P1[1],CharSize*(ByteNumber-1));
memcpy(P2,&P2[1],CharSize*(ByteNumber-1));
//迎新
Read1.read(&P1[ByteNumber-1],CharSize);
Read2.read(&P2[ByteNumber-1],CharSize);
++Address;
}//while(Read1 && Read2)
cout<<Occurs<<" different addresses were found.\n";
if(Occurs==0) return;
cout<<"Input save filename:\t";
CIN>>FN;
//保存至文件
ofstream SaveFile(FN.c_str());
if(!SaveFile)
{
cerr<<"Create "<<FN<<" failed.\n";
return;
}
SaveFile<<"Relative value is " <<RV<<"\nAddress\tValue1\tValue2\n";
for(InfoList::const_iterator Beg=IL.begin(), End=IL.end();Beg!=End;++Beg)
{
SaveFile<<(*Beg).first<<’\t’ <<(*Beg).second.first <<’\t’<<(*Beg).second.second <<’\n’;
}
}
这里的新面孔是pair。pair是一个相当简单的模板结构,一共就两个变量(first和second),都是公开的。
由于是模板,所以pair的构造必须指定两个变量所属类型,为了简单,设立了一个make_pair 的函数,函数可以根据参数自动推导出类型而不必显式指定。在这里,我需要保存三个值:地址、文件1中的数值、文件2中的数值。偷懒一点,我把两个文件中的数值作为一个pair
(A),再把地址和pair(A)作为一个pair(B)。pair(B).first就是地址,pair(B).second就是pair(A),pair(A).first就是文件1中的数值,pair(A).second就是文件2中的数值。
通过保存的文件我们可以得到地址,找到了藏身之地,事情就好办多了。
改进3 :手动批量修改
上面介绍的方法( “自动修改文件”),每次只修改一个地址。假如要对某个文件的5555,6666,12352地址修改,用上面的方法也很烦琐。假如能指定在哪些位置修改为哪些数值就方便多了。
修改的要素:地址、新的数值、新数值的字节数。上述三个要素都是整形,一旦输入数据不是整形(比如一个字母),就表示停止输入。 比如:
5555 500000 4
6666 600000 4
12352 700000 4 x
表示在5555地址上填写4字节的500000,在6666地址上填写4字节的600000,在12352地址上填写4字节的700000。x导致输入失败,在此表示停止输入。
假如输入的数据按照地址从小到大排列,那么编程就比较方便了,不过却为难了客户。还是善待你的上帝吧,对他们的要求越少越好。很多时候,自己就是自己程序的第一用户,益人也同样利己。
因此,下面的输入与上面的等价:
12352 700000 4
5555 500000 4
6666 600000 4 ~
template<class T>
class Modify
{
public:
typedef fstream::off_type AddressType;
static const int MaxByte=sizeof(T);
Modify();
void Run() const;
private:
strUCt ModifyInfoUnit
{
//修改要素——地址
AddressType Address;
//修改要素——新的数值
T NewValue;
//修改要素——新数值的字节数
int ByteNumber;
//排序原则
bool Operator<(const Modify<T>::ModifyInfoUnit& rhs) const;
//从输入流读取一个单元
void ReadFrom(istream&);
};
const int CharSize;
EInputStream CIN;
void Input();
bool InputIsOk;
mutable ifstream SourceFile;
mutable ofstream DestFile;
set<ModifyInfoUnit> ModifyInfoSet;//集合
};
template<class T>
const int Modify<T>::MaxByte;
下面逐一解释:
template<class T>,
AddressType,MaxByte;
同上述
struct ModifyInfoUnit;
修改要素——地址,新的数值,新数值的字节数。
比较原则——为从头到尾排序提供准则。
输入函数——从输入流中读取一个单元。
set<ModifyInfoUnit> ModifyInfoSet;
修改要素的集合,按照地址从小到大自动排序,排序原则由ModifyInfoUnit 的bool operator<>提供。
template<class T>
Modify<T>::Modify():CharSize(sizeof(char)),CIN(cin)
{
InputIsOk=true;
Input();
}
构造函数,设置输入状态。
template<class T>
void Modify<T>::Input()
{
string fn;
cout<<"Source binary file name:\t";
CIN>>fn;
SourceFile.open(fn.c_str(),ios::in ios::binary);
if(!SourceFile)
{
cerr<<"Open "<<fn<<" failed.\n";
InputIsOk=false;
return;
}
cout<<"Save file name:\t";
CIN>>fn;
DestFile.open(fn.c_str(),ios::out ios::binary);
if(!DestFile)
{
cerr<<"Create "<<fn<<" failed.\n";
InputIsOk=false;
return;
}
cout<<"Any character which makes format error will end the input progress.(etc a)\n";
cout<<"Address(Dec)\tNewValue\tByteNumber(1--" <<Modify<T>::MaxByte<<")\n";
while(true)
{
ModifyInfoUnit MIU;
//从真实的输入流中读取一个ModifyInfoUnit
MIU.ReadFrom(CIN.Actual());
//输入失败就退出循环
if(!CIN.Actual()) break;
//把输入成功的单元存入集合,自动排序
ModifyInfoSet.insert(MIU);
}
CIN.ClearAndIgnore();//清除流的失败状态
}
用户输入源文件名和目标文件名。有错误的话直接退出。从键盘输入ModifyInfoUnit,输入失败表示停止输入。CIN.Actual()返回真实的流。每次输入一个MIU 就放入集合中自动排序。
set在前面讲了一点,在这里终于现身了。set是一种关联式容器,其自动排序功能需要指定排序准则。对于内部数据类型(比如char 、int、double 等),比较大小的原则是内建的。但是对于用户自定义的类型,就必须指定比较的方法了。set默认的比较法则是“小于”。在ModifyInfoUnit中间提供“小于”的成员函数(bool operator<),就为set使用默认的排序原则提供了定义。
和上面对应,在此也能使用vector,不过使用vector意味着一旦重新分配内存,所有元素都要拷贝,对于int而言,开销可能比较小,但对于这里的ModifyInfoUnit,开销就要多一点了。set只是一种方法,还有一种常用的关联式容器是map。map由要害字key和值value组成。
根据key可以在对数时间内找到对应的value。map是按照key排序的,默认的排序准则是“小于”。假如在这里运用,key就是修改地址,value就是新值及其字节数。
set<ModifyInfoUnit>;就变成map<AddressType,pair<T,int> >;这样就不需要编写比较准则了,其他相应修改就不在这里赘述了,读者可以自己试一下。笔者认为,map和set相比在于能快捷的通过要害字来查找对应值,在这个应用场合还用不到通过地址来查看对应的新值和字节数,故没有采用map。
把ModifyInfoUnit设计为一个全公开的内嵌结构( 或者类), 作为一个私有成员也是常用的手法。
ModifyInfoUnit对外而言是无需了解(私有成员),但是可以被Modify毫无限制的存取。
template<class T>
void Modify<T>::Run() const
{
if(InputIsOk==false) return;
set<ModifyInfoUnit>::const_iterator Beg=
ModifyInfoSet.begin(),End=ModifyInfoSet.end();
AddressType Address=0;
char ch;
while(SourceFile && Beg!=End)
{
//SourceFile没有读完并且集合也没有遍历结束
if(Address==Beg->Address)
{
//到了指定的地址了
const char*P=reinterpret_cast<const char*>(&(Beg->NewValue));
for(int k=0;k<Beg->ByteNumber;++k)
{
//忽略源文件
SourceFile.read(&ch,CharSize);
}
//写新值
DestFile.write(P,CharSize*Beg->ByteNumber);
Address+=Beg->ByteNumber;
++Beg;
}
else
{
SourceFile.read(&ch,CharSize);
DestFile.write(&ch,CharSize);
++Address;
}
}
//源文件中可能还有剩余的内容
while(SourceFile.read(&ch,CharSize))DestFile.write(&ch,CharSize);
}
修改的核心函数。遍历源文件, 当地址等于ModifyInfoSet集合当前元素的地址时,忽略源文件,把新值写入目标文件。一旦源文件读取到了末尾或者集合全部走过了,就跳出循环。
这里的const_iterator,begin和end与前面讲述的作用相似,只是本来是指向链表的,现在指向集合了。
template<class T>
bool Modify<T>::ModifyInfoUnit::operator<(const Modify<T>::
ModifyInfoUnit& rhs) const
{
return Address<rhs.Address;
}
定义排序原则,应该是一个const成员函数。
template<class T>
void Modify<T>::ModifyInfoUnit::ReadFrom(istream& IS)
{
IS>>Address>>NewValue>>ByteNumber;
if(ByteNumber<1ByteNumber>Modify<T>::MaxByte)
ByteNumber=Modify<T>::MaxByte;
}
从输入流读取一个ModifyInfoUnit,假如ByteNumber不符合就修正。手工批量修改的代码也差不多了。这样,对于若干个已知的地址假如要一次性修改,就用这个吧!方便又省事。
实战《仙剑3 》
看到这里也不轻易了,头昏脑胀了吧?放松一下,来试试看我们的工具吧!实验对象是《仙剑3》。选择《仙剑3》是因为笔者正巧在玩这个游戏,虽然网上有《仙剑3》存档的修改器,不过我还是授人以渔吧。
修改存档前要做好备份。所有文件名中间不能有空格等空白符号。游戏第一个存档是pal00.arc,第二个存档是pal01.arc,以此类推。
选择两个文档比如pal01.arc和pal02.arc,都是景天、雪见、龙葵、紫萱四人的组合。记录下两个文档的如下数据:金钱,每个人的经验值,都是4字节。再记录下其中一个存档(比如pal02.arc)中的每个人的“精”、“气”、“神”的上限(4字节),“武”、“防”、“速”和“运” (字节数尚没确定)。注重:“武”、“防”、“速”和“运” 应该是没有使用武器和道具时候的数据。
对于每一个属性,比如景天的经验值,在pal01.arc和pal02.arc 中用CheckBinaryFile找到地址,分别保存为a.txt和b.txt。再用取得交集的方法得到c.txt。那么c.txt的内容就是景天的经验值在存档中的地址,一般是1-2个。
得到金钱和四个人的经验值地址后,用手工批量处理的方法得到新的存档文件,其中金钱可修改的大一点,四个人的经验值不必过大,保证可以升级即可。我得到的数据是:
把新的文档覆盖原文档,载入游戏后看看是否正确,然后打一仗后四个人全部升级。存档为pal03.arc,再次记录下每个人的“精”、“气”、“神”的上限,“武”、“防”、“速”和“运”。
由于pal02.arc 和pal03.arc之间存在升级,“精”、“气”、“神”的上限,“武”、“防”都会变化,“速”和“运” 倒是没有变,只能暂时放弃。
故技重演,需要通过pal02.arc和pal03.arc找到四个人的“精”、“气”、“神”的上限,“武”、“防”。于是先得到如下数据:
检测到这里,有两个发现:
A. “武”、“防”相差4个地址,那么极有可能:“速”的地址是“防”的地址+4,“运”是“速”+4,而且“武”、“防”、“速”和“运”都是4 字节。4 字节意味着我可以修改为百万,上亿甚至更大的数值。
B. 各属性的地址相对固定。比如经验值地址减去92就是“武”的地址,减去104 就是“精”的地址。这也是合情合理的,笔者猜测该游戏中每个人的属性是一个结构(struct),假如四个人都采用相同的结构,那么每个人的属性之间的地址差都是一样的。
有了上述推算,可以直接计算出剩下的地址了。从经验值地址得到其他属性的地址,笔者懒得自己用计算器重复劳动了,用Excel写个模板,填入经验值地址就能得到其他地址了。于是得到如左下表格所示的结果:
由于要修改的东西比较多,以后可能还要用到这些数据,我们可以把要修改的指令保存到一个文本文件(比如haha.txt),用输入重定向的方法执行程序。该文本文件的内容就是使用该工具时,从键盘输入的数据。为节省篇幅,我只列出修改金钱和男主角属性的内容:
2
pal15.arc
pal15.bak
148 987654321 4
8929 987654321 4
1456 3000000 4
1364 1000001 4
1368 1000002 4
1372 1000003 4
1376 1000004 4
1352 1000005 4
1640 1000005 4
1356 1000007 4
1644 1000007 4
1360 1000009 4
1648 1000009 4 X
-1
h
执行过程由于采用了输入重定向,所以本来通过键盘输入的数据现在从haha.txt读取:
覆盖原文档,载入游戏,战斗胜利后触发升级。给几张截图看看:
对于游戏中的龙精石,我还没有找到修改方法,或许不能修改。根据我自己的实践,“速”和“运”不要修改的太高(一两百就够了),或者干脆不要改了,否则会导致比较希奇的问题。当游戏中人物不同时候,存放地址可能不同,不过各个人物的属性的相对地址还是一样的。
经验值不必改得太高,只要可以触发战斗后的升级即可,一旦到了99级就不能升级了。对于每个人使用魔法的次数也能修改的,比如景天第一次使用“风咒”存盘一下,再使用一次再存盘。检查两个存盘文件的数值1和2,再用取得交集的方法得到一个唯一的地址,然后手动批量修改一下。我在网上看到说魔法次数最大显示为255,因此修改成2字节的200就够了。载入游戏再运用一次,就会变成级别4。像景天现在的“武”超过了一百万,攻击力起码几十万,大概没有人可以反抗了。
对于好感度,战斗中(景天处于阵中)可以通过援助来增加好感度、女战友死亡会降低好感度,对话会影响好感度(比如在网上看到对话结束后的长音代表好感度+5,中音+3)。网上有些功略指出了对话的好感度增加值。我是这样试验的:选一个包含全部女主角的组合存档(比
如pal01.arc),修改她们的生命值到比较低的值(比如1), 战斗中让她们全部死亡,唯独自己活着,然后存档(pal02.arc)。我估计死亡一次大概降低2个好感度,于是pal01.arc与pal02.arc的相对值为2,字节数也为2,运行程序找到好多个地址,打开一看,前面几行是:
Relative value is 2
Address Value1 Value2
696 45 43
700 34 32
704 31 29
15962 2 0
19341 2 0
估计这三个地址是696,700和704,于是批量修改为80、85、90,下面是一张截图:
这个游戏笔者第一次只玩到锁妖塔,觉得迷宫太复杂了没有继续下去,后来写了这个程序后又重新开头试了一下,AI进攻真是干脆利落,目前发现存在两个问题:
①.假如修改某人的“精气神”的上限,后来她离开队伍又加入了,这时“精气神”上限只有几百,这只出现在离开队伍后来又加入的情况。
②.在拿到土灵珠被大胡子抢走后,剧情要求战败的。我当时修改后四个人全部是AI进攻,每个人的“武”都超过了一百万,硬是打不死
大胡子,但我每个人的“精”、“防”也都很高,这样就造成了死循环,只能Ctrl+Alt+Del强制退出,看来只能把当前的“精”修改到最小才能战败。
对于数值明确的对象,通过相对值查找也是一种方法,非凡是游戏初期,数值比较小,内容相同的地址很多,既可以通过“取交集”,也能使用“相对值”。
总结
修改游戏主要分为两种:动态和静态。本文介绍的是静态法,在游戏存档中目的明确的修改;动态法就是直接在游戏过程中修改内存数据了,还能进行一些模糊查找,两者各有特点。笔者孤陋寡闻,没发现比较便利的修改游戏存档的工具,就自己写了一个(版权所有,哈哈)。程序主要是模拟UE手工修改游戏存档的原理来制作的,并陆续加入了一些自己想到的功能。一旦游戏对存档文件进行了加密或者校验就无能为力了(UE也应该如此吧!)。
程序的顺利完工很大程度上得益于C++的STL,比如笔者运用了vector、list、set 等容器,sort、unique、copy和set_itersection等算法以及一些常用迭代器。使用STL的好处就是安全、高效、简洁。编译器选择了Borland 的C++ Builder 6,这是Windows平台中对标准C++支持较好的一种。
笔者的C++是工作之余自学的,只有两年左右时间,这个程序是一时心血来潮编写的,不足之处肯定是有的。用编程来修改游戏算是学以致用,寓教于乐,还可以给自己玩游戏找借口。初衷是给自己用用,不怕丢脸,后来觉得还有点技术含量,就写了这篇文章,希望对C++初学者有点帮助,给玩游戏的带来点快乐。