这是解包的第二篇帖子,上一篇帖子请查看这里。
在上一篇的文章中,我们完成了对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文件。

略显抽象哈,这个一时半会看不出来什么东西,还得略微研究一下。
资源提取这篇就先到这里,剩下的那个我得琢磨一下怎么给它提取出来。