解包学习贴2——天天酷跑资源提取之资源定位与初步提取

这是解包的第二篇帖子,上一篇帖子请查看这里。

在上一篇的文章中,我们完成了对IPA包的解密,也就是破解开了Apple加密,接下来我们就拿到了整个游戏程序的所有数据,包括资源与基本程序的二进制文件,并且这个二进制文件是可以被IDAPRO进行反编译的。

文章位招租:如何使用IDAPRO反编译C++二进制文件。

一、核心文件路径索引

在上文中的爱思助手导出文件夹中找到你的导出的IPA文件,之后将其后缀名修改为.zip,并将其解压出来,就在当前的文件夹即可。

接下来,我们先集中注意找资源文件的所在位置,一般来说,一个游戏中占用最大的一定是资产文件,所以我们将目标锁定最大的文件夹或者名为Asset或者Module的文件夹。

在天天酷跑中,这个存放主资源的文件夹是Module文件夹,游戏压缩包约为1.25G左右,单这个文件夹就占用了高达1.01GB。

进入该文件夹,我们可以通过文件夹名称定位所查找的资源文件位置。

上方的文件夹基本就是按照名字区分,从上到下分别是

  • 头像
  • 背景
  • Boss怪
  • 角色
  • 漫画
  • 酷飞(炫飞模式)
  • 设计
  • 特效
  • 字体
  • JP(应该是游戏开发的一些相关东西,不太清楚,但是不是主要资源)
  • 地图

基本就是这些东西,我们要提取的资源主要是角色,当然,如果提取宠物和精灵,它们的文件夹在下方(S开头)

我们先进行角色的展示界面提取。

但是这里我们需要注意一些事情,就是角色的UI未必在角色文件夹下。酷跑这个命名比较抽象,但是好在我们可以通过文件后缀名以及HEX查看器找到我们的目标文件。

这里插一个后边学到的知识,在很多2D游戏中,角色的动态化是依赖Spine这个软件的,包括COCOS、Unity、UnReal等一系列的引擎,在这方面很多都采用相似的形式。

插曲:提取npModule.bin文件

这里出现的npModule文件主要是各类WEBP文件被拆分后构成的组合,主要是用于存储各个角色的游戏内模型动态动作。其HEX文件特征是这样的。

我们能很明显的看到WEBP文件的文件头标识。

在删除文件头并修改后缀名后可以看到第一部分的WEBP文件

但是这里的文件都是被切分的,一张图片以等高度32PX横向切割为7-8张图片,一般来说一个模型大概有100个左右的文件。

如果大家想要将这些文件解析出来,可以使用这个Python脚本,但是这玩意儿的精灵表我没怎么研究,因为非常模糊,提取出来好像也意义不大。

#!/usr/bin/env python3
"""
GIF/WEBP 资源包提取工具
从游戏资源包中提取 WEBP 和 GIF 资源
"""

import struct
import os
import sys
from typing import List, Tuple, Optional

class ResourceExtractor:
    def __init__(self, filepath: str):
        self.filepath = filepath
        self.data = None
        self.output_dir = "extracted_resources"
        
    def load_file(self):
        """加载文件"""
        with open(self.filepath, 'rb') as f:
            self.data = f.read()
        print(f"文件大小: {len(self.data)} 字节")
        
    def create_output_dir(self):
        """创建输出目录"""
        if not os.path.exists(self.output_dir):
            os.makedirs(self.output_dir)
            
    def find_all_webp(self) -> List[Tuple[int, int]]:
        """
        查找所有 WEBP 资源
        返回: [(偏移, 大小), ...]
        """
        webp_list = []
        i = 0
        
        while i < len(self.data) - 12:
            # 查找 RIFF 头
            if self.data[i:i+4] == b'RIFF':
                # 读取大小
                size = struct.unpack('<I', self.data[i+4:i+8])[0]
                # 检查是否为 WEBP
                if self.data[i+8:i+12] == b'WEBP':
                    # WEBP 文件总大小 = 8 + size (RIFF头8字节 + 数据大小)
                    webp_size = size + 8
                    webp_list.append((i, webp_size))
                    print(f"找到 WEBP: 偏移 0x{i:06X}, 大小 {webp_size} 字节")
                    i += webp_size
                    continue
            i += 1
            
        return webp_list
    
    def extract_webp(self, webp_list: List[Tuple[int, int]]):
        """提取所有 WEBP 文件"""
        print(f"\n提取 {len(webp_list)} 个 WEBP 文件...")
        
        for idx, (offset, size) in enumerate(webp_list):
            webp_data = self.data[offset:offset+size]
            output_file = os.path.join(self.output_dir, f"webp_{idx:03d}.webp")
            
            with open(output_file, 'wb') as f:
                f.write(webp_data)
            
            print(f"  -> 保存: {output_file} ({size} 字节)")
    
    def find_pzd_resource(self) -> Optional[Tuple[int, int]]:
        """
        查找 .pzd 资源
        返回: (偏移, 大小) 或 None
        """
        # 在文件中搜索 .pzd 字符串
        pzd_pattern = b'.pzd'
        pos = self.data.find(pzd_pattern)
        
        if pos == -1:
            return None
        
        # 向前查找可能的资源头
        # 通常在字符串前面有长度和偏移信息
        # 从 pos 向前查找 4 字节长度字段
        for offset in range(pos - 4, max(0, pos - 20), -4):
            # 尝试解析为小端整数
            try:
                length = struct.unpack('<I', self.data[offset:offset+4])[0]
                # 合理性检查:长度应该合理(几十到几万)
                if 10 < length < 100000:
                    # 检查字符串是否匹配
                    str_start = offset + 4
                    str_end = str_start + length
                    if self.data[str_start:str_end].endswith(b'.pzd'):
                        # 再向前查找数据偏移
                        data_offset = offset - 8
                        if data_offset >= 0:
                            # 检查前面的数据是否是偏移量
                            return (data_offset, length + 4 + length)  # 简化
            except:
                continue
        
        # 如果找不到,尝试直接提取包含 .pzd 的数据块
        # 根据你之前的数据,.pzd 资源在偏移 0x50 附近
        return (0x50, 0x5000)  # 猜测的范围
    
    def extract_pzd_data(self, offset: int, size: int):
        """提取并尝试解压 .pzd 数据"""
        print(f"\n处理 .pzd 资源 (偏移 0x{offset:06X}, 大小 {size} 字节)")
        
        if offset + size > len(self.data):
            print(f"警告: 资源超出文件范围!")
            size = len(self.data) - offset
        
        pzd_data = self.data[offset:offset+size]
        
        # 保存原始 .pzd 文件
        pzd_file = os.path.join(self.output_dir, "gif_00.BIG.pzd")
        with open(pzd_file, 'wb') as f:
            f.write(pzd_data)
        print(f"  -> 保存原始 .pzd: {pzd_file}")
        
        # 尝试解压
        self.try_decompress_pzd(pzd_data)
    
    def try_decompress_pzd(self, data: bytes):
        """尝试多种方式解压 .pzd"""
        
        # 方法1: Zlib 解压
        try:
            import zlib
            # 尝试直接解压
            decompressed = zlib.decompress(data)
            self.save_gif_frames(decompressed, "zlib")
            return
        except:
            pass
        
        # 方法2: 跳过前几个字节再解压
        for skip in [2, 4, 8, 12]:
            try:
                import zlib
                decompressed = zlib.decompress(data[skip:])
                self.save_gif_frames(decompressed, f"zlib_skip{skip}")
                return
            except:
                continue
        
        # 方法3: 检查是否是未压缩的 BIG 包
        if data[:4] == b'BIGF' or data[:4] == b'PACK':
            print("  -> 检测到 BIG/PACK 格式,需要进一步解析")
            self.parse_big_pack(data)
            return
        
        print("  -> 无法解压,保存原始数据供分析")
        raw_file = os.path.join(self.output_dir, "pzd_raw_data.bin")
        with open(raw_file, 'wb') as f:
            f.write(data)
    
    def save_gif_frames(self, data: bytes, method: str):
        """从解压后的数据中提取 GIF 帧"""
        print(f"\n  解压成功! (方法: {method}, 大小: {len(data)} 字节)")
        
        # 查找所有 GIF 帧
        gif_frames = []
        i = 0
        
        while i < len(data) - 6:
            # 查找 GIF 头
            if data[i:i+3] == b'GIF':
                # 找到 GIF 开始
                # 查找下一个 GIF 头或结束
                j = i + 1
                while j < len(data) - 3:
                    if data[j:j+3] == b'GIF':
                        break
                    j += 1
                
                frame_data = data[i:j]
                if len(frame_data) > 10:  # 有效帧
                    gif_frames.append((i, frame_data))
                i = j
            else:
                i += 1
        
        if gif_frames:
            print(f"  找到 {len(gif_frames)} 个 GIF 帧")
            for idx, (offset, frame_data) in enumerate(gif_frames):
                # 保存为独立的 GIF 文件
                # 注意:单帧 GIF 可以直接显示
                output_file = os.path.join(self.output_dir, f"gif_frame_{idx:03d}.gif")
                with open(output_file, 'wb') as f:
                    f.write(frame_data)
                print(f"    -> 保存帧 {idx}: {output_file} ({len(frame_data)} 字节)")
        else:
            print("  未找到 GIF 帧,可能是其他格式")
            # 保存解压后的完整数据
            decompressed_file = os.path.join(self.output_dir, "pzd_decompressed.bin")
            with open(decompressed_file, 'wb') as f:
                f.write(data)
            print(f"  保存解压数据: {decompressed_file}")
    
    def parse_big_pack(self, data: bytes):
        """解析 BIG/PACK 格式"""
        print("\n  解析 BIG/PACK 格式...")
        
        # 检查头部
        header = data[:4]
        if header == b'BIGF':
            print("  BIGF 格式 (BioWare)")
            # BIGF 格式: 4字节 'BIGF', 4字节版本, 4字节文件数量...
            if len(data) >= 12:
                version = struct.unpack('<I', data[4:8])[0]
                num_files = struct.unpack('<I', data[8:12])[0]
                print(f"    版本: {version}, 文件数量: {num_files}")
                
                # 尝试提取文件名列表
                self.extract_big_files(data)
                
        elif header == b'PACK':
            print("  PACK 格式 (常见打包格式)")
            self.extract_pack_files(data)
    
    def extract_big_files(self, data: bytes):
        """从 BIG 文件中提取文件"""
        # 简单的字符串搜索,查找可能的 GIF 文件名
        import re
        
        # 搜索 gif_00_xx 模式
        pattern = rb'gif_00_\d+'
        matches = re.findall(pattern, data)
        
        if matches:
            print(f"  找到 {len(matches)} 个 GIF 文件名")
            
            # 尝试提取每个文件
            for match in set(matches):
                print(f"    - {match.decode('ascii')}")
        
        # 保存完整数据供分析
        big_file = os.path.join(self.output_dir, "big_extracted.bin")
        with open(big_file, 'wb') as f:
            f.write(data)
        print(f"  保存 BIG 数据: {big_file}")
    
    def extract_pack_files(self, data: bytes):
        """从 PACK 格式中提取文件"""
        # 类似的处理
        pack_file = os.path.join(self.output_dir, "pack_extracted.bin")
        with open(pack_file, 'wb') as f:
            f.write(data)
        print(f"  保存 PACK 数据: {pack_file}")
    
    def scan_for_gif(self):
        """直接扫描文件中的 GIF 头"""
        print("\n扫描 GIF 头...")
        gif_positions = []
        i = 0
        
        while i < len(self.data) - 6:
            if self.data[i:i+3] == b'GIF':
                gif_positions.append(i)
                print(f"  发现 GIF 头: 偏移 0x{i:06X}")
                i += 1
            else:
                i += 1
        
        if gif_positions:
            print(f"共发现 {len(gif_positions)} 个 GIF 头")
            
            # 提取第一个 GIF 看看
            for idx, pos in enumerate(gif_positions[:5]):  # 只提取前5个
                # 尝试找到 GIF 结束 (通常是下一个 GIF 头或文件尾)
                end = len(self.data)
                for next_pos in gif_positions:
                    if next_pos > pos:
                        end = next_pos
                        break
                
                gif_data = self.data[pos:end]
                if len(gif_data) > 10:
                    output_file = os.path.join(self.output_dir, f"direct_gif_{idx:03d}.gif")
                    with open(output_file, 'wb') as f:
                        f.write(gif_data)
                    print(f"  保存: {output_file}")
    
    def run(self):
        """主执行流程"""
        print("=" * 60)
        print("GIF/WEBP 资源包提取工具")
        print("=" * 60)
        
        # 加载文件
        self.load_file()
        self.create_output_dir()
        
        # 1. 提取所有 WEBP 资源
        webp_list = self.find_all_webp()
        if webp_list:
            self.extract_webp(webp_list)
        else:
            print("未找到 WEBP 资源")
        
        # 2. 处理 .pzd 资源
        pzd_info = self.find_pzd_resource()
        if pzd_info:
            offset, size = pzd_info
            self.extract_pzd_data(offset, size)
        else:
            print("\n未找到 .pzd 资源,尝试直接扫描 GIF...")
            self.scan_for_gif()
        
        # 3. 额外扫描 GIF
        self.scan_for_gif()
        
        print("\n" + "=" * 60)
        print("提取完成!")
        print(f"输出目录: {self.output_dir}")
        print("=" * 60)


def main():
    if len(sys.argv) < 2:
        print("用法: python extract_resources.py <文件路径>")
        print("示例: python extract_resources.py game_resource.bin")
        sys.exit(1)
    
    filepath = sys.argv[1]
    
    if not os.path.exists(filepath):
        print(f"错误: 文件不存在 - {filepath}")
        sys.exit(1)
    
    extractor = ResourceExtractor(filepath)
    extractor.run()


if __name__ == "__main__":
    main()

复制上方代码,将代码放到一个文本文件中,修改文件名后缀为.py,自己定义一个文件名

将需要提取的文件和这个文件放到同一个文件夹目录下,在这个文件夹窗口右键鼠标点击CMD命令,输入如下命令。

python yourpythonfilename.py youruncodefile.bin

之后就能在新建的文件夹中看到提取出来的文件了。

所以这里我们要找的动态文件在Spine文件夹下。

二、通过HEX文本查看/编辑器找到目标文件。

锁定目标后,我们进入文件夹

这里我们看到四个文件夹,分别是

  • 大厅角色
  • 其他
  • 角色UI
  • 坐骑UI

这里我们就能看到,大厅角色就是浮生幻梦这种展示的立绘,而目标资源就在UI_Character文件夹下。

这里我们可以看到每个角色都有一个编号,且其都有一个文件夹

这就是从旧到新的角色了。

我们选择一个提取,就选择209吧

这个角色是飞鸢

具体是谁看不见,还得看二进制文件里写的是谁

进入209文件夹,我们可以看到这些数据:

一个比较大的PZB文件,一个npModule.bin文件。

这里我们通过HEX文本编辑器简单的读一下。

PZB文件:

这里我们能够看到极为明显的文件头:PNG文件的文件头。由于PNG文件相较于其他文件多一个文件尾,我们划到文件末尾查看文件尾信息:

同样是极为明显的PNG文件尾特征。

PNG文件头: 89 50 4E 47

PNG文件尾:AE 42 60 82

我们使用HEX文本编辑器直接操作,删除前方的多余文件头和后方多余文件尾,保存并修改文件后缀名为PNG。

之后我们会得到一个这样的文件:

如果是做过开发的小伙伴应该一眼就能看出来,这玩意儿是精灵图,包括了角色的所有可动部分的贴图资产。

接下来我们关注另一个资产:npmodule.bin文件。

略显抽象哈,这个一时半会看不出来什么东西,还得略微研究一下。

资源提取这篇就先到这里,剩下的那个我得琢磨一下怎么给它提取出来。

上一篇
下一篇