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

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

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

解包学习贴1—-天天酷跑资源提取之程序脱壳解密

在上一篇的文章中,我们完成了对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('

# 检查是否为 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('

# 合理性检查:长度应该合理(几十到几万)

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('

num_files = struct.unpack('

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文件。

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

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

相关推荐

弱电机房光纤线槽的基础知识
365根据什么来封号

弱电机房光纤线槽的基础知识

🗓️ 01-16 👁️ 8294
iPhone和iPad上如何批量删除邮件应用中的邮件
365bet真人体育

iPhone和iPad上如何批量删除邮件应用中的邮件

🗓️ 10-02 👁️ 2895
元器件科普之LED数码管的原理和应用
365根据什么来封号

元器件科普之LED数码管的原理和应用

🗓️ 08-09 👁️ 6272
新疆维吾尔自治区行政区划代码一览表
365根据什么来封号

新疆维吾尔自治区行政区划代码一览表

🗓️ 09-29 👁️ 7077
想拥有DNF迷你寒冰虎?这份攻略助你轻松获得!
365根据什么来封号

想拥有DNF迷你寒冰虎?这份攻略助你轻松获得!

🗓️ 08-24 👁️ 4128
dota2怎么看延迟
365bet真人体育

dota2怎么看延迟

🗓️ 09-21 👁️ 2123
洗衣機槽洗淨多久一次?別等長出海苔才後悔!
最佳娱乐365bet娱乐场下载

洗衣機槽洗淨多久一次?別等長出海苔才後悔!

🗓️ 01-10 👁️ 4376
【UX设计】如何为你的游戏选择正确的字体?
最佳娱乐365bet娱乐场下载

【UX设计】如何为你的游戏选择正确的字体?

🗓️ 01-19 👁️ 4200
211马力+四驱,可爱又个性,国产方盒子SUV,怎么选?
最佳娱乐365bet娱乐场下载

211马力+四驱,可爱又个性,国产方盒子SUV,怎么选?

🗓️ 02-18 👁️ 9757
服怎么读
365bet真人体育

服怎么读

🗓️ 10-10 👁️ 1946
电动车充电器品牌榜前十推荐,避免充坏电池
365根据什么来封号

电动车充电器品牌榜前十推荐,避免充坏电池

🗓️ 01-15 👁️ 2554
數(数)
最佳娱乐365bet娱乐场下载

數(数)

🗓️ 01-07 👁️ 680