解包学习贴3——天天酷跑资源提取之目标文件解码

芜湖,这里是第三篇啦,针对上期那个抽象的结果,咱家已经搞定了~、

嘿嘿嘿,在各大AI以及网上的免费样例的对比下,这期基本能够将目标资源提取出来喽~

这是历史记录贴,这里继续第二篇帖子继续。

一、npMoudule文件解码

上一篇不是提到了这个几K的抽象npModule文件嘛,这里在PS、大D老师(DS-R1)、哈基米(Gemini-3-Pro)、牢大(Claude – 4.6)等AI的协助下,也是给这玩意儿反解出来了。这个算是还原的核心文件之一,挺重要的。

这个抽象的文件是这样的:

看这个文件的16进制内容来看,前面应该是一个符合某种标准定义的文件头,这个文件应该是服务于开发团队的某一些自研组件的,而且还标识了素材引用路径,这个文件和PZD文件配套,上文中我们也知道了PZB文件实质上就是一个被包裹的精灵图PNG文件,那这个文件大概就是指导程序如何切割精灵图的文件,也就是索引或者清单文件,告诉程序如何切割这个PNG文件得到所需的资产碎片。

在通览整个文件的16进制结构后,我们能发现,这个文件似乎一直再用0x04作为一个分隔符或者标识符。

我截取了”feiyuan1“这个文件的二进制结构,大家可以看一下。

08 00 00 00

#组件名称(feiyuan1)的16进制ASCII码

04 0C 01 00 00 

04 3F 04 00 00 

04 29 00 00 00 

04 61 00 00 00 

04 01 00 00 00 

04 00 00 00 00 

04 00 00 00 00 

04 00 00 00 00 

04 00 00 00 00 

04 02 00 00 00 

04 01 00 00 00 

04 01 00 00 00 

09 00 00 00

#"feiyuan10"的ASCII码

对比上方的数据大家发现了一些规律不。大概就是这样的规律:

这些文本的排列顺序是这样的:

  • 组件名字符串长度,合集4个16进制数
  • 组件的X坐标
  • 组件的Y坐标
  • 组件的宽度
  • 组件的高度
  • 未知数据
  • 未知数据
  • 未知数据
  • 未知数据
  • 未知数据
  • 未知数据
  • 未知数据
  • 未知数据

而且在这个存储规则中,使用的是小端存储方案,简单来说就是,针对这个二进制字节的存放,读取数据是从右往左读,每个十六进制数当成一个字,和正常的文字阅读是相反的。

所以拆出来的最关键的核心坐标应该是

  • 0x010C
  • 0x043F
  • 0x0029
  • 0x0061

转换为10进制数是这个样子的

  • 268(X)
  • 1087(Y)
  • 41(W)
  • 97(H)

至于怎么验证,当然是请出我们万能的PS啦

在右侧的信息面板中确实可以看到,坐标起始原点为右下角,向负方向X轴和正方向Y轴扩展高度和宽度值。

正好能包裹这个组件碎片。

有了这个数据验证结果嘛,嘿嘿嘿。

接下来就是要搞定,到底是哪个软件在使用这个文件。

在一波网络冲浪和与AI的激烈(并非)讨论中,我们能大概得到一个雏形。

  • 这个东西是一个精灵图,而且在2D或3D游戏中极为常见。
  • 这个二进制文件指导精灵图该如何切分,且有一个专门的组件去批量的拆解精灵图
  • 根据索引路径,我们可以得到这个东西是Spine

那么就搜吧

一搜还真有,就是这东西。

看来是个专有的2D游戏动态工具。

那么还有个问题。

二、 素材重组核心文件介绍分析

我玩过虚幻引擎,只是简单的探索了一下,按理说,一个东西要想正常的工作,除去贴图资产,还得有个东西,这玩意儿叫骨骼,将模型或者资产贴图绑定到骨骼上,才能得到各种效果。

所谓骨骼,其实就是骨架,就像人一样,骨架支撑着血肉皮肤,肌肉牵动骨架,皮肤又和骨架相连,骨骨架的运动带动皮肤的运动,而骨骼运动的肌肉牵引在这里就是各种的动画。

所以回忆一下,我们得到了什么?

  1. 资产组合大礼包
  2. 资产的分布图,也就是拆开礼包的钥匙

我们还缺什么?

骨架

缺少关键的骨架数据,我们无法将整个角色还原回去(当然你要是PS大佬对着游戏截图往回拼也是牛的)

骨架的绑定也顺带解决了还原角色资产碎片位置的任务。

所以接下来就要找到关键的骨骼数据。

还记得我们在前方的学习贴12中对文件的鉴别嘛。

通过这个鉴别法,我们可以快速(并非,也是笨办法)找到文件夹中的文件特征,这个骨架文件应该是什么样的?

答案很简单,它的16进制文件应当是一个完全不可读的乱码文件,也就是我们通过正文完全看不出来这是个什么东西。或者这个文件有极为明显的SKELION、BONE等重要标识,也就是这个文件可读。至于这个文件的后缀名,可能是一个特殊的后缀名,或者是一个npmodule文件名,这个无法确定,只能自己一个个找,但是一个文件夹里如果一个文件不是,那么这个文件夹中和这个文件命名法类似的其他文件也极大概率不会是,所以只用试一个就够了。

如果在其中看到了标准的WEBP/PNG等文件头,那么这个文件绝对不会或者极大概率不会是骨骼文件,因骨骼文件理论上是不包含资产的。WEBP/PNG都是图片,它们都属于资产。

三、 SPZ文件解码分析

在经过一番大战之后(搜索)

咱也是终于找到了这个特殊的文件

它在应用解压后的这个目录下

\Payload\BreezeGame.app\Spine\UI_Mounts

其后缀名为.SPZ

文件内容是这个样子的

特殊后缀名,完全不可读的内容,以及挂着角色名的命名,还有SPINE这个父文件夹名称,以及我们手里的已知数据,结合这个文件的大小约100K左右

这个文件极大概率就是我们要找的目标骨骼文件没跑。

BUT

看不懂怎么办呢。

丢给AI,或者我们有对于文件头的敏感性。

我们关注这个文件的文件头。

30 30 30 30 30 30 30 30 30 30 30 42 42 41 33 35 78 DA EC BD 5B 8F 34 B9 91 25 F8 2C FD 0A A1 9E

emmm

更抽象了,借助文本编辑器的显示,我们能看到前16个字符是这个东西

00000000000BBA35

emmm

这个东西理论上来说不是任何一个文件的文件头,因为没有文件的文件头这么抽象,搜着也是,这个文件头里没有什么意义,所以我们能大概推断一下,这个文件头应该是一个被特殊包裹的文件头,换句话说就是,这串字符在我们的反解过程中是没用的。

而接下来的文件二进制是这样的

78 DA EC BD 5B 8F 34 B9 91 25 F8 2C FD 0A A1 9E

这里我们可以丢给AI分析,或者我们如果有点经验接触的比较多,应该能看出来,这个文件头78 DA是一个标准的ZLIB文件的文件头。

换句话说,这是一个ZLIB压缩包,由于ZLIB在我们的正常办公中鲜少用到,所以不认得倒也正常。

接下来,PY神力,我们需要一个脚本来帮助我们解压这个ZLIB文件,后缀名啥的无所谓,记得把原始的哪个字符串0000啥的删掉,要不解压库可能读不到。

使用方法同以前的,这里是解压代码。

import zlib
import sys
import os

def decompress_zlib_file(input_path, output_path=None):
    """
    解压 zlib 压缩的文件(文件头为 78 DA)
    
    Args:
        input_path: 输入的压缩文件路径
        output_path: 输出文件路径(如果不指定,自动生成)
    
    Returns:
        bool: 是否成功
    """
    try:
        # 读取压缩数据
        with open(input_path, "rb") as f:
            compressed_data = f.read()
        
        # 检查文件头是否为 78 DA
        if len(compressed_data) < 2:
            print(f"错误: 文件太小,无法判断文件头")
            return False
        
        if compressed_data[0] != 0x78 or compressed_data[1] != 0xDA:
            print(f"警告: 文件头不是 78 DA,而是 {compressed_data[0]:02X} {compressed_data[1]:02X}")
            print(f"将尝试继续解压...")
        
        # 解压数据
        print(f"正在解压: {input_path}")
        print(f"压缩数据大小: {len(compressed_data)} 字节")
        
        decompressed_data = zlib.decompress(compressed_data)
        
        print(f"解压后大小: {len(decompressed_data)} 字节")
        
        # 确定输出路径
        if output_path is None:
            # 自动生成输出文件名:去掉 .zlib/.pzd/.bin 等后缀,加上 .decoded 或 .txt
            base_name = os.path.splitext(input_path)[0]
            # 检测常见扩展名
            if input_path.endswith('.pzd') or input_path.endswith('.spz'):
                output_path = base_name + ".png"
            elif input_path.endswith('.zlib'):
                output_path = base_name + ".decoded"
            else:
                output_path = base_name + "_decompressed"
        
        # 写入解压后的数据
        with open(output_path, "wb") as f:
            f.write(decompressed_data)
        
        print(f"解压成功: {output_path}")
        
        # 尝试判断文件类型
        detect_file_type(decompressed_data, output_path)
        
        return True
        
    except zlib.error as e:
        print(f"zlib 解压错误: {e}")
        return False
    except FileNotFoundError:
        print(f"文件不存在: {input_path}")
        return False
    except Exception as e:
        print(f"未知错误: {e}")
        return False

def detect_file_type(data, file_path):
    """
    尝试检测解压后文件的类型
    """
    if len(data) < 8:
        print("  文件太小,无法判断类型")
        return
    
    # 检查 PNG 文件头
    if data[:8] == b'\x89PNG\r\n\x1a\n':
        print("  检测到文件类型: PNG 图片")
        # 自动重命名为 .png
        new_path = os.path.splitext(file_path)[0] + ".png"
        if new_path != file_path:
            os.rename(file_path, new_path)
            print(f"  已重命名为: {new_path}")
    
    # 检查 JSON 文件头(以 { 或 [ 开头)
    elif data[0] == ord('{') or data[0] == ord('['):
        print("  检测到文件类型: JSON 文件")
        new_path = os.path.splitext(file_path)[0] + ".json"
        if new_path != file_path:
            os.rename(file_path, new_path)
            print(f"  已重命名为: {new_path}")
    
    # 检查 XML 文件头
    elif data[:5] == b'<?xml':
        print("  检测到文件类型: XML 文件")
        new_path = os.path.splitext(file_path)[0] + ".xml"
        if new_path != file_path:
            os.rename(file_path, new_path)
            print(f"  已重命名为: {new_path}")
    
    # 检查文本文件(可打印字符占比高)
    else:
        printable = sum(1 for b in data[:500] if 32 <= b < 127 or b in (9, 10, 13))
        if printable > 400:
            print("  检测到文件类型: 文本文件")
            new_path = os.path.splitext(file_path)[0] + ".txt"
            if new_path != file_path:
                os.rename(file_path, new_path)
                print(f"  已重命名为: {new_path}")

def batch_decompress(folder_path, extension=None):
    """
    批量解压文件夹中所有 zlib 文件
    
    Args:
        folder_path: 文件夹路径
        extension: 指定扩展名(如 '.pzd', '.spz', '.bin'),不指定则解压所有
    """
    if not os.path.isdir(folder_path):
        print(f"文件夹不存在: {folder_path}")
        return
    
    files_to_decompress = []
    
    for filename in os.listdir(folder_path):
        file_path = os.path.join(folder_path, filename)
        if not os.path.isfile(file_path):
            continue
        
        if extension:
            if filename.endswith(extension):
                files_to_decompress.append(file_path)
        else:
            # 尝试检测是否为 zlib 文件
            with open(file_path, "rb") as f:
                header = f.read(2)
                if header == b'\x78\xDA':
                    files_to_decompress.append(file_path)
    
    if not files_to_decompress:
        print(f"未找到需要解压的文件")
        return
    
    print(f"找到 {len(files_to_decompress)} 个文件\n")
    
    success_count = 0
    for file_path in files_to_decompress:
        print("-" * 50)
        if decompress_zlib_file(file_path):
            success_count += 1
        print()
    
    print("=" * 50)
    print(f"解压完成: {success_count}/{len(files_to_decompress)} 成功")

def main():
    """
    主函数 - 命令行入口
    """
    print("zlib 解压工具 (支持 78 DA 文件头)")
    print("=" * 50)
    
    # 如果提供了命令行参数
    if len(sys.argv) > 1:
        input_path = sys.argv[1]
        
        # 批量解压模式(如果是文件夹)
        if os.path.isdir(input_path):
            extension = sys.argv[2] if len(sys.argv) > 2 else None
            batch_decompress(input_path, extension)
        else:
            # 单文件解压
            output_path = sys.argv[2] if len(sys.argv) > 2 else None
            decompress_zlib_file(input_path, output_path)
    else:
        # 交互模式
        print("请选择模式:")
        print("1. 解压单个文件")
        print("2. 批量解压文件夹")
        
        choice = input("请输入选项 (1/2): ").strip()
        
        if choice == "1":
            file_path = input("请输入文件路径: ").strip()
            decompress_zlib_file(file_path)
        elif choice == "2":
            folder_path = input("请输入文件夹路径: ").strip()
            ext = input("请输入扩展名筛选 (直接回车解压所有 78 DA 文件): ").strip()
            batch_decompress(folder_path, ext if ext else None)
        else:
            print("无效选项")

if __name__ == "__main__":
    main()

这个解压出来的会根据文件的类型来决定是还原为TXT还是JSON或者XML等其他格式,但是一般都是JSON偏多(这是SPINE嘛)。

运行完后我们应该会得到一个JSON文件,大概长这个样子:

四、 初步熟悉反解后的文件素材,初步了解Spine

接下来我们就明白了,这东西用的是SPINE这个软件做的,并且SPINE的版本号什么的都在这里,用的是2.1.27版本

额,我也不知道为什么要用这么一个版本,后边废了我半天事,至于怎么回事,我们后边再说,这个我觉得都可以单开一个帖子了,(后便索性单独复制一个改名帖子恰流量嘿嘿)这个版本很久了,而且SPINE在网上的主流学习版本都是3.8.75版本,购买正版又太贵了,有那钱我还不如约一张OC呢(骗你的,根本没钱约OC)。192 186

至于SPINE的下载嘛,不是重点,这里也不多说了。

打开SPINE,将我们在上方的文件解压出来的JSON文件保存好,点击左上角,导入数据,选择这个JSON文件,如果不出意外的话,我们能看到这个东西。

emmm,一坨奇怪的东西,一开始我还以为没问题呢,后边才发现不对劲。

我们可以通过SPINE-RUINTIME提供的VIEWER来查看这个文件,因为SPINE的版本和文件结构在版本升级时发生的改变,导致新版本不支持老版本….(草,学学人家微软)

这里如果我们通过查看器查看应该正确的是这个样子的

这就和游戏对上了嘛。

到这里所有的文件都已经被解码了,也能正确的查看了,接下来我们要做的内容就不是分析二进制文件了,而是研究一下这个SPINE怎么用,剩下的问题都是SPINE问题了。

至于后事如何,请看下期哦~

上一篇
下一篇