游戏开发实战Unity逆向怀旧经典游戏《寻秦OL》,解析二进制动画文件生成预设并播放(资源逆向 | 二进制 | C#)

Posted 林新发

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了游戏开发实战Unity逆向怀旧经典游戏《寻秦OL》,解析二进制动画文件生成预设并播放(资源逆向 | 二进制 | C#)相关的知识,希望对你有一定的参考价值。

一、前言

嗨,大家伙,我是新发。
有同学私信并给我发了封邮件,内容如下:

邮件内容:
林新发大哥你好,我叫**,是个四川98年的小伙,因为从小在山寨机上玩武侠网游,悠米游戏平台的天龙传奇,寻秦OL,冒泡平台降龙十八掌,笑傲江湖,傲剑ol等游戏,玩了很多游戏,最喜欢的还是天龙传奇和寻秦OL这2款 武侠回合制。
后来学了计算机应用,然后混到了毕业,被中介坑到天津当了1年5G督导,后来毕业很迷茫,最后贷款学了Unity,非常遗憾,学完后找了一个公司开发了3个月的益智类游戏,每天都很忙,但是并没有任何进步,然后我就明白了,有些东西不适合,它就是不适合,我每天写代码几乎都是 Transform 过去过来,我也知道全是浅显的东西,但是这浅显的东西我都需要花很久才能明白,每天都很煎熬。
后来转行快递行业,每天除了场地上的电脑硬件问题,这才感到学有所用,虽然有时也会觉得程序员前途很好,厂里面修电脑就是混日子,但是不会像以前那么煎熬了,或许我内心还是在给自己不努力找借口。
然后就是空闲时间老是想起这个小时候的游戏,知道有人用爱发电在复刻一直在期待,将近500多个人在期待,经过无数所谓的众筹请人开发,群友自己花钱找工作室开发(到规定时间他就说工作室出问题,2次后大家才明白他在和几百人开玩笑),各种被鸽之后,终于明白这个游戏不可能回来的了。
然后就想自己拿素材做单机小游戏,寻找一下回忆,但是能力有限,连一个文件的读取 数据的转换都弄不明白,最后问了几个人,也找同学弄了一下,还是不行,主要原因还是自己编程能力不足,最后经过忐忑的心情给你发了私信。

就是说,想在Unity中逆向寻秦OL的资源(序列帧动画),并可以在Unity中播放。
遗憾的是我小时候没玩过这个游戏,只看过寻秦记电视剧,还是小时候的电视剧好看呀,现在都很少看电视剧了。
嘛,话说回来,我还是先解决一下这个同学的问题,讲讲如何对二进制资源进行解析并逆向生成Unity预设文件。

本文最终效果如下



工程源码见文章末尾。

二、资源文件说明

1、二进制文件(pwd文件、aef文件)

邮件中发了一些资源文件,是二进制格式的,包括.pwd.aef文件等,

很多游戏都会自己构造二进制资源文件,目的有两个:
1、加大逆向的难度;
2、压缩资源大小。
我们如果只拿到了二进制资源文件,是比较难逆推出里面的具体内容的,一般还需要配合逆向游戏代码,通过代码的解析逻辑去逆推资源的数据格式,然后再写工具去把资源解析出来保存为我们可以用的资源格式。
所幸,邮件中提到有人已经整理了这些格式(.pwd.aef.mape)的数据规则,省去了我去逆向代码的过程,下面就先说明一下这些文件的数据格式吧~

2、数据格式

2.1、pwd格式

pwd文件,它是素材文件,本质上是png加一些自定义数据,自带分割png的数据。
数据格式如下:

长度含义
2字节当前文件的ID
4字节图片资源长度
前一个字段的值的字节数图片资源
2字节图片可被分成的小图数量

再往后循环读取以下字段,循环次数是图片可被分成的小图数量,

长度含义
2字节坐标x
2字节坐标y
2字节小图宽度width
2字节小图高度height

画个图方便大家理解,

2.2、aef格式

上面的pwd文件可以理解为是图集文件,而这里要讲的aef文件可以理解为序列帧动画文件aef记录了每一帧使用的小图文件和坐标信息等。

数据格式如下:

长度含义
2字节该文件包含的帧数量

后面的数据连续循环上面字段的值,每次循环读取以下的字段

长度含义
2字节帧ID
4字节该帧用到的小图数量

然后根据该帧用到的小图数量循环读取以下的字段

长度含义
2字节pwd文件的ID
2字节当前图片的ID
2字节坐标x
2字节坐标y

画个图方便大家理解,

三、C#读取二进制文件的API

我们要在Unity中去解析pwdaef文件,就要用到读取二进制文件的API,有必要单独拿出来讲一下。

1、打开二进制文件:FileStream文件流

我们要打开一个二进制文件,可以使用FileStream类,需要引入命名空间:

using System.IO;

使用方法:

string filePath = "要打开的文件路径";
using (FileStream fs = new FileStream(filePath , FileMode.Open))

	// TODO 文件流操作

上面我们是通过FileStream自身的构造函数来构建一个FileStream对象的,我们也可以通过File.Open来构建FileStream对象,如下

string filePath = "要打开的文件路径";
using(var fs = File.Open(filePath, FileMode.Open))

	// TODO 文件流操作


注:可能有同学会问,这个using是干嘛的?
我们把创建的文件流对象的过程写在using中,在离开using作用域时会自动帮助我们释放流所占用的资源,否则我们需要手动调用FileStreamDispose方法来释放资源。

2、二进制读取:BinaryReader

上面我们得到FileStream对象,接下来就可以使用BinaryReader来对流进行二进制读取了,例:

string filePath = "要打开的文件路径";
using (FileStream fs = new FileStream(filePath , FileMode.Open))

	using (BinaryReader br = new BinaryReader(fs))
	
		// 读取1个字节
		byte a0 = br.ReadByte();
		
		// 读取2个字节,并以小端字节序转为short,需要特别小心!
		short a1 = br.ReadInt16();
		
		// 读取4个字节,并以小端字节序转为int,需要特别小心!
		int a2 = br.ReadInt32();
		
		// 读取800个字节
		byte[] a3 = br.ReadBytes(800);

	

3、字节序问题:大端小端

上面代码中ReadInt16ReadInt32需要特别小心字节序问题,什么是字节序呢?为什么要搞字节序这个东西呢?我来给你讲清楚。
我们的计算机内存是以字节为存储单元的,画个图,

我们知道,一个short2个字节,一个int4个字节,现在我问你,假设用0x000000000x00000001这两个地址对应的2个字节来表示一个short,那么这个short的值是多少?

你可能会回答0x1C09,因为低地址是0x09,高地址是0x1C,组合起来就是0x1C09,转为十进制就是7177

但是,为什么不能是0x091C呢?谁规定高地址就是高位,低地址就一定是低位呢?
这个,就是字节序问题。
如果是高地址放高位,低地址放低位,就是小端字节序,这个符合我们人类的思维习惯。(口诀:高高低低为小端)。
反过来就是大端字节序。虽然说小端字节序符合人类的思维习惯,但却反而不直观,为什么?比如下面这个二进制文件,我圈出来的4个字节的值你是不是第一反应是0x00000065(大端字节序),如果你真按小端字节序来思考的话,应该是0x65000000,因为0x65的地址是最高的,按小端字节序的话0x65是放在最高位。不过,这里的二进制文件是按大端字节序存储的,所以答案是0x00000065

现在问题又来了,我们如果使用BinaryReaderReadInt32()方法一次性读取4字节,它是以什么字节序去构造一个int的呢?C#默认的字节序是小端字节序,所以如果你用ReadInt32()会得出错误的答案。
那我们如何正确的读取这4个字节呢?可以先使用ReadBytes(4)方法读取四个字节:

// 读取4个字节
byte[] intBytes = br.ReadBytes(4);

这个时候读出来的字节数据是这样的

我们使用Array.Reverse方法对数据进行反序,

Array.Reverse(intBytes );

反序后变成这样

此时我们在使用BitConverter.ToInt32方法即可得到正确的值0x00000065啦(即十进制的101),

int i = BitConverter.ToInt32(intBytes, 0);
// i的值为0x00000065,即即十进制的101

画个图总结一下,

四、实战

接下来我们就来实战吧,使用C#的二进制读取的API来解析寻秦OL的二进制资源文件并生成Unity可用的资源。

1、创建Unity工程

Unity工程名就叫UnityXunqinOL吧~

2、导入pwd和aef文件

NPCpwdaef导入工程目录中,比如导入10002这只怪的资源文件,

如下

3、使用十六进制查看器(Hex Editor)

我一般是使用VS Code码代码,想要使用VS Code查看二进制文件,可以安装Hex Editor插件,

安装完毕后,点击你要查看的文件,然后点击Do you want to open it anyway

然后点击Hex Editor

这样我们就可以以十六进制的方式查看这个二进制文件了,

4、挨个字节分析

现在我们根据上文中讲的pwd文件的数据格式来分析一下。
2个字节是文件ID,可见10002_1.pwd文件ID0

接下来是4个字节,表示png数据长度,为0x000006F5,转为十进制即1781字节,

我们推算一下,读完这1781个字节,就到了2 + 4 + 1781 - 1的位置(注意字节从0字节数起,所以这里减1),即第1786字节的位置,转为十六进制就是0x000006FA的位置,我们跳到这里,

再往下2个字节是小图数量,为0x0013,即有19张小图,

再往后就是解析这19张小图了,以第一张小图为例,可以得出第一张小图的坐标为:x: 0x0000,y: 0x0011,即:x: 0,y: 17,宽高为:0x0015 0x0011,即宽高为:21 x 17

后面以此类推。

5、写工具脚本:pwd生成png

5.1、创建FileRead脚本

现在,我们来写工具脚本,让它去读取pwd文件吧。
新建Editor文件夹,

新建一个C#脚本,重命名为FileReader,如下,

5.2、定义PWDInfo数据结构

先定义数据结构

// pwd数据结构
public struct PWDInfo

    public short id;	// pwd文件id
    public int pngLen;	// png数据长度
    public byte[] png;	// png数据
    public int splitCnt;	// 小图数量
    public SpriteInfo[] spriteInfoList;	// 小图信息数组


// 小图数据结构
public struct SpriteInfo

    public int index;	// 小图索引
    public int x;		// 坐标x
    public int y;		// 坐标y
    public int width;	// 宽度
    public int height;	// 高度

5.3、封装ReadInt16和ReadInt32方法

封装两个Read方法,里面实现字节反序,解决大小端问题,

/// <summary>
/// 读取2字节
/// </summary>
private static Int16 ReadInt16(BinaryReader br)

    byte[] bytes = br.ReadBytes(2);
    // 反字节序
    Array.Reverse(bytes);
    return BitConverter.ToInt16(bytes, 0);


/// <summary>
/// 读取4字节
/// </summary>
private static Int32 ReadInt32(BinaryReader br)

    byte[] bytes = br.ReadBytes(4);
    // 反字节序
    Array.Reverse(bytes);
    return BitConverter.ToInt32(bytes, 0);

5.4、封装ReadPWD方法

最后封装一个ReadPWD方法,只需传入pwd文件路径,即可解析并返回一个PWDInfo对象,

public static PWDInfo ReadPWD(string pwdFilePath)

    PWDInfo pwdInfo = new PWDInfo();
    using (FileStream fs = new FileStream(pwdFilePath, FileMode.Open))
    
        using (BinaryReader br = new BinaryReader(fs))
        
            pwdInfo.id = ReadInt16(br);
            pwdInfo.pngLen = ReadInt32(br);

            // PNG文件资源
            pwdInfo.png = br.ReadBytes(pwdInfo.pngLen);


            // 切片数量
            int spriteCnt = ReadInt16(br);
            SpriteInfo[] spriteInfoList = new SpriteInfo[spriteCnt];
            for (int i = 0; i < spriteCnt; ++i)
            
                // 每个切片的信息
                SpriteInfo spriteInfo = new SpriteInfo();
                spriteInfo.index = i;
                spriteInfo.x = ReadInt16(br);
                spriteInfo.y = ReadInt16(br);

                spriteInfo.width = ReadInt16(br);
                spriteInfo.height = ReadInt16(br);
                spriteInfoList[i] = spriteInfo;
            
            pwdInfo.spriteInfoList = spriteInfoList;
        
    
    return pwdInfo;

5.5、创建GenResTools脚本

我们再创建GenResTools脚本,

由它来暴露一个菜单项,去调用FileReader.ReadPWD

[MenuItem("工具/通过PWD生成PNG")]
public static void GeneratePngByPWD()

    // 扫描PWD文件
    var pwdFilePaths = Directory.GetFiles(Application.dataPath + "/NPC/", "*.pwd", SearchOption.AllDirectories);
    foreach (var pwdFilePath in pwdFilePaths)
    
        // 解析PWD文件
        PWDInfo pwdInfo = FileReader.ReadPWD(pwdFilePath);
        // TODO 根据PWDInfo生成png图片
    

我们要根据PWDInfo生成png图片。

5.6、封装保存png图片的方法

我们封装一个保存png图片的方法,

// GenResTools.cs

/// <summary>
/// 保存图片
/// </summary>
private static void SavePng(string savePath, byte[] data)

    if (File.Exists(savePath))
    
        File.Delete(savePath);
    

    File.WriteAllBytes(savePath, data);
    AssetDatabase.Refresh();

5.7、自动设置图片属性

图片保存后,需要设置图片的属性,比如图片格式设置为Sprite,过滤模式设置为Point等,我们封装一个方法来自动完成这些设置,

// GenResTools.cs

/// <summary>
/// 自动设置图集图片格式
/// </summary>
private static void FixSettings(string pngPath)

    pngPath = pngPath.Replace('\\\\', '/');
    var assetsPath = pngPath.Replace(Application.dataPath, "Assets");

    TextureImporter textureImporter = AssetImporter.GetAtPath(assetsPath) as TextureImporter;
    textureImporter.textureType = TextureImporterType.Sprite;
    textureImporter.spriteImportMode = SpriteImportMode.Single;
    textureImporter.wrapMode = TextureWrapMode.Clamp;
    textureImporter.filterMode = FilterMode.Point;
    textureImporter.isReadable = true;
    AssetDatabase.ImportAsset(assetsPath);
    AssetDatabase.Refresh();

5.8、生成精灵小图

另外,我们还需要根据图集生成精灵小图,再封装一个生成方法,

/// <summary>
/// 从图集中生成精灵图
/// </summary>
private static void GenSprites(string pwdDir, string atlasPath, PWDInfo pwdInfo)

    atlasPath = atlasPath.Replace('\\\\', '/');
    var assetsPath = atlasPath.Replace(Application.dataPath, "Assets");
    var atlasTexture = AssetDatabase.LoadAssetAtPath<Texture2D>(assetsPath);
    foreach (SpriteInfo spriteInfo in pwdInfo.spriteInfoList)
    
        // 精灵图
        var spriteName = Path.GetFileNameWithoutExtension(atlasPath) + "_" + spriteInfo.index + ".png";
        var spriteSaveDir = pwdDir + "/sprites/";
        if (!Directory.Exists(spriteSaveDir))
        
            Directory.CreateDirectory(spriteSaveDir);
        
        var spriteSavePath = spriteSaveDir + spriteName;

        var spriteTexture = new Texture2D(spriteInfo.width, spriteInfo.height, TextureFormat.RGBA32, false);
        for (int y = 0; y < spriteInfo.height; ++y)
        
            for (int x = 0; x < spriteInfo.width; ++x)
            
                var color = atlasTexture.GetPixel(spriteInfo.x + x, atlasTexture.height - spriteInfo.y - y - 1);
                spriteTexture.SetPixel(x, spriteInfo.height - y - 1, color);
            
        

        SavePng(spriteSavePath, spriteTexture.EncodeToPNG());
        AssetDatabase.Refresh();
        FixSettings(spriteSavePath);
    
    AssetDatabase.Refresh();

这里要注意坐标系的差异,他们是使用2D引擎制作的寻秦OL,使用的坐标系是y轴朝下的,与Unityy轴方向是相反的,所以读取像素的时候要使用高度减去y轴坐标。

5.9、遍历pwd文件执行生成

我们完善一下GeneratePngByPWD方法的逻辑,最终如下,

[MenuItem("工具/通过PWD生成PNG")]
public static void GeneratePngByPWD()

    // 扫描PWD文件
    var pwdFilePaths = Directory.GetFiles(Application.dataPath + "/NPC/", "*.pwd", SearchOption.AllDirectories);
    foreach (var pwdFilePath in pwdFilePaths)
    
        // 解析PWD文件
        PWDInfo pwdInfo = FileReader.ReadPWD(pwdFilePath);
        var pwdDir = Path.GetDirectoryName(pwdFilePath);
        var atlasName = Path.GetFileNameWithoutExtension(pwdFilePath) + ".png";
        var atlasDir = pwdDir + "/atlas/";
        if (!Directory.Exists(atlasDir))
        
            // 在pwd所在目录中创建atlas文件夹
            Directory.CreateDirectory(atlasDir);
        
        var atlasPath = Path.Combine(atlasDir,以上是关于游戏开发实战Unity逆向怀旧经典游戏《寻秦OL》,解析二进制动画文件生成预设并播放(资源逆向 | 二进制 | C#)的主要内容,如果未能解决你的问题,请参考以下文章

游戏开发实战Unity逆向怀旧经典游戏《寻秦OL》,解析二进制动画文件生成预设并播放(资源逆向 | 二进制 | C#)

安卓逆向Unity3D游戏层叠xx破解

unity-shader-游戏渲染效果逆向分析

unity-shader-游戏渲染效果逆向分析

unity-shader-游戏渲染效果逆向分析

unity-shader-游戏渲染效果逆向分析