解包学习贴4——天天酷跑资源提取之Spine文件还原

这里是第四篇记录贴啦,解包进度应该快到尾声了,加油站主,你一定阔以的。

前三篇帖子在这里哦

上一篇中我们找到了这些资源的制作软件,接下来的操作将主要集中到SPINE上,所以接下来我们所需要做的就是如何把这些文件还原回去。

一、Spine项目的文件结构。

在第三篇帖子中,我们读取出来了精灵图、精灵图切割指引文件、骨骼文件。

其中,骨骼文件可以直接导入JSON,虽然有一些问题,但是也算是可以读出来了。

精灵图文件没有问题,作为PNG存储。

那么问题来了,作为关键的精灵图切割指引文件,这个文件该怎么被读取?

在我们的实际操作、AI查询以及网上冲浪发现,对于较早版本的Spine项目,主要是针对Spine2.x版本,它的项目文件夹应当是这样的

  • 精灵图PNG文件
  • images文件夹,用于存放切割好的精灵图
  • .atlas阿特拉斯文件,用于切割精灵图。
  • JSON文件,用于指引骨骼数据,UV数据、骨骼绑定、皮肤绑定、动画数据等其他数据,大头数据在这里。

二、 重构.atlas阿特拉斯文件

根据我们上方的改写,我们看到的比较小的npmoudule文件应当就是ATLAS文件。

但是,标准的ATLAS文件应当是一个无BOM的UTF8编码的文本文件,也就是这东西用记事本就能够直接查看,但是我们找到的指引文件显然是没法直接读取的,所以接下来我们需要通过脚本,重写一个.atlas阿特拉斯文件出来。

根据我们在第三篇帖子中找到的规律,我们可以提取核心数据位置和大小,重构阿特拉斯文件,而且第三篇帖子中,我有一个没提到的,就是索引文件末尾还有一个段比较少的部分,每个组件名都出现了两次,第二次的应该是游戏运行时的索引,总之这部分的记录我们叫他短记录,我们分析的部分叫长记录,长记录中的数据是我们需要的

接下来,通过这个代码来生成阿特拉斯文件,但是这个脚本的生成有点问题,你得让你的精灵图和这个文件内写好的文件名一致,需要读取的源文件也要文件名保持一致,而且需要检查一些内部写死的这个精灵图的大小,是2048*2048还是1024*2048,要不后续会报错,这个程序我没怎么优化,而且注意一下生成的文件中描述精灵图大小的部分应该会有一个空格,如果后续Spine导入报错记得删掉。

import struct
import re

def parse_npmodule_bin(file_path):
    """
    解析 npmodule.bin 文件,提取每个资产的第一部分(完整记录)中的 x, y, width, height
    """
    with open(file_path, "rb") as f:
        data = f.read()
    
    results = {}
    i = 0
    data_len = len(data)
    
    while i < data_len:
        # 查找以可打印ASCII字符开头的字符串(资产名称)
        if 32 <= data[i] <= 126:  # 可打印字符范围
            start = i
            # 读取资产名称(直到遇到null字节或非ASCII字符)
            name_bytes = []
            while i < data_len and data[i] != 0 and 32 <= data[i] <= 126:
                name_bytes.append(data[i])
                i += 1
            # 如果成功读取到名称
            if name_bytes:
                name = bytes(name_bytes).decode('ascii')
                # 跳过可能的null字节
                if i < data_len and data[i] == 0:
                    i += 1
                
                # 开始读取后续的数值(第一部分:完整记录)
                values = []
                # 读取直到遇到下一个资产名称或文件结束
                while i < data_len:
                    # 检查下一个字符是否是资产名称的开头(字母或数字)
                    next_char = data[i] if i < data_len else 0
                    # 如果遇到下一个资产名称的起始特征(可打印字符且不是数值的一部分),则停止
                    if 32 <= next_char <= 126 and next_char not in [0x04, 0x00]:
                        # 进一步检查是否真的是名称(连续的可打印字符)
                        j = i
                        temp_name = []
                        while j < data_len and data[j] != 0 and 32 <= data[j] <= 126:
                            temp_name.append(data[j])
                            j += 1
                        if temp_name and len(temp_name) >= 3:  # 名称至少3个字符
                            # 这是下一个资产,停止当前资产的解析
                            break
                    
                    # 读取标记 0x04 后面的4字节数值
                    if data[i] == 0x04:
                        i += 1
                        if i + 4 <= data_len:
                            val = struct.unpack('<I', data[i:i+4])[0]
                            values.append(val)
                            i += 4
                        else:
                            break
                    else:
                        # 跳过非0x04字节(可能是填充或对齐)
                        i += 1
                
                # 只保留完整记录(数值数量 >= 8 的认为是完整记录)
                if len(values) >= 8:
                    # 前4个数值:x, y, width, height
                    x, y, w, h = values[0], values[1], values[2], values[3]
                    results[name] = {
                        "x": x,
                        "y": y,
                        "width": w,
                        "height": h,
                        "all_values": values[:12] if len(values) >= 12 else values
                    }
                    print(f"提取: {name:20} | 坐标: ({x:4}, {y:4}) | 尺寸: {w:3}x{h:3} | 共 {len(values)} 个数值")
                else:
                    # 这是短记录,忽略
                    print(f"忽略短记录: {name:20} (仅 {len(values)} 个数值)")
            else:
                i += 1
        else:
            i += 1
    
    return results

def save_to_atlas(results, atlas_path, image_name="UI_Character209.BIG.png", image_width=2048, image_height=2048):
    """
    将提取的坐标信息保存为 Spine 的 .atlas 文件(无空行版本)
    """
    if not results:
        print("没有提取到有效数据,无法生成 atlas 文件")
        return False
    
    with open(atlas_path, "w", encoding="utf-8") as f:
        # 写入 atlas 文件头(最后不加空行)
        f.write(f"{image_name}\n")
        f.write(f"size: {image_width}, {image_height}\n")
        f.write("format: RGBA8888\n")
        f.write("filter: Linear,Linear\n")
        f.write("repeat: none\n")
        
        # 为每个资产写入子图信息(无空行)
        for name, info in results.items():
            f.write(f"{name}\n")
            f.write(f"  rotate: false\n")
            f.write(f"  xy: {info['x']}, {info['y']}\n")
            f.write(f"  size: {info['width']}, {info['height']}\n")
            f.write(f"  orig: {info['width']}, {info['height']}\n")
            f.write(f"  offset: 0, 0\n")
            f.write(f"  index: -1\n")
    
    print(f"\n成功生成 atlas 文件: {atlas_path}")
    print(f"共包含 {len(results)} 个子图")
    return True

def export_to_csv(results, csv_path):
    """
    导出为 CSV 文件,方便在 Excel 中查看
    """
    import csv
    with open(csv_path, "w", newline="", encoding="utf-8") as f:
        writer = csv.writer(f)
        writer.writerow(["资产名称", "X坐标", "Y坐标", "宽度", "高度"])
        for name, info in results.items():
            writer.writerow([name, info["x"], info["y"], info["width"], info["height"]])
    print(f"已导出 CSV 文件: {csv_path}")

# 主程序
if __name__ == "__main__":
    # 请修改为你的实际文件路径
    npmodule_path = "npmodule.bin"  # 替换为你的文件路径
    atlas_output_path = "角色.atlas"
    csv_output_path = "裁剪坐标.csv"
    
    # 大图信息(请根据实际情况修改)
    BIG_IMAGE_NAME = "xiaomeng.png"  # 你的大PNG文件名
    BIG_IMAGE_WIDTH = 1024   # 大图宽度,请根据实际修改
    BIG_IMAGE_HEIGHT = 2048  # 大图高度,请根据实际修改
    
    print("开始解析 npmodule.bin...")
    results = parse_npmodule_bin(npmodule_path)
    
    if results:
        print(f"\n成功提取 {len(results)} 个资产的完整信息")
        
        # 生成 atlas 文件
        save_to_atlas(results, atlas_output_path, BIG_IMAGE_NAME, BIG_IMAGE_WIDTH, BIG_IMAGE_HEIGHT)
        
        # 可选:导出 CSV 方便查看
        export_to_csv(results, csv_output_path)
        
        print("\n完成!")
    else:
        print("未提取到任何数据,请检查文件路径或文件格式")

之后,我们应当可以在当前文件夹下得到我们的.atlas文件,可以通过记事本修改它。

这里需要声明一下,由于我们能找到的流传的破解版本3.8.75和酷跑使用的版本2.1.27文件项目结构不一样,而阿特拉斯文件负责处理分割图像,所以这里的重构的.atlas阿特拉斯文件应当是一个3.8版本标准下的文件,而不是2.X版本,别搞错了,要不3.X版本的Spine可读不出来.atlas文件中的正确数据,会报错的哦。

在这里呢,我总是报错,所以我去网上冲浪找了一个免费的Spine3.8项目来研究了一下正经的.atlas文件格式,正经格式大概是这样的:


E100203.png
size: 330,2046
format: RGBA8888
filter: Linear,Linear
repeat: none
A_glow_orange_small
  rotate: true
  xy: 240, 702
  size: 52, 51
  orig: 64, 64
  offset: 6, 6
  index: -1
A_green_lizi_00001
  rotate: true
  xy: 2, 631
  size: 176, 181
  orig: 223, 218
  offset: 17, 22
  index: -1
A_green_lizi_00002
  rotate: true
  xy: 2, 453
  size: 176, 177
  orig: 223, 218
  offset: 17, 24
  index: -1
...

每个子项目没有空行,整个文件区域上下保留一个空行,文件头写明需要处理的精灵图的文件名。

上面代码生成的就是这个效果,只是有一些小问题可能需要略作修改,我示例的图片是调试的时候的错误文件,那个格式必定报错的,我生成的那个对的文件,文件夹太多找不着了。。。

略作修改后,我们的文件就正常了。如果可以导入Spine并正确的切割文件,就成功了。

选择纹理解包器

在你的项目文件夹下提前建立好images文件夹哈,而且这里不要用默认的,自己选路径,如果没保存项目的话它也不知道你想存哪里,只能用旧的路径。

三、修正JSON文件。

上篇文章中我们也提到过,这个骨骼文件在3.X版本的SPINE中读取是有问题的。

对比很明显:

我们在VSCode中查看JSON文件可知,在其SKINS块下,每个组件的类型被定义成了:skinnedmesh

这个类型在2.X版本中包含了很多信息,但是到了3.X版本中,这个关键字被弃用了。更换为了MESH/REGION关键词。

MD,Android SDK我用过时API写程序也不出问题,高版本兼容低版本,怎么这玩意儿跨代就跟俩软件一样了。

好了扯正事,我在中文网上没查到相关的资料,所以去英文查并且用英文拜托哈基米和克劳德帮忙找了找数据,我们大概能得到如下的信息。

2.X版本和3.X版本的JSON格式部分设置不一样,2.X中的设置在3.X中有了新的解释方案,同时旧有的方案被弃用

发生变化的属性大概是这样的:

在2.X版本中,有两个属性inheritRotation和inheritScale,即“旋转继承”和“顶点继承”,它们使用一个布尔值标识是否启用,也就是TRUE和FALSE。

在3.X版本中,这两个项目合并成了一个枚举类型”transform”,不再使用布尔值标识,而是使用特定的关键词,它们的对应效果是这样的:

2.X原始项目(省略inherit)3.X转换后项目
rotation=true, scale=true“normal”
rotation=false, scale=false“noRotationOrReflection”
rotation=true, scale=false“noScale”
rotation=false, scale=true“onlyTranslation”

如果我们只能看到rotation而看不到scale,那么其默认的值为TRUE

当我们使用3.X版本读取时,因为软件不认识这两个属性了,而“transfrom”缺失,其会被默认为“normal”

另外,在2.X版本中,有两个定义,即flipX和flipY,这两个值对于根骨骼缩放、镜像有影响,如果有部分骨骼继承了根骨骼,并且JSON没有使用新的关键词,那么这部分数据在3.X中不会被正确解析。

嘛,更加详细的解释是:

在 Spine 2.x 版本中,单个骨骼可以设置 flipX: trueflipY: true,为布尔值,这会将该骨骼及其所有子骨骼沿该轴镜像翻转。在 3.x 版本中,骨骼定义中完全移除了此功能。现在,翻转操作在骨架级别(翻转整个角色)或通过缩放值来处理——骨骼上的 scaleX 设置为 -1 即可达到与 2.x 版本中 flipX: true 相同的效果。如果一个 2.x 版本的文件具有这些属性,并在 3.x 版本中加载它,这些属性会被静默忽略,导致骨骼在应该镜像时却显示为未镜像,从而产生倾斜或方向错误。

另外,slots(插槽)也新增和改变了很多属性:

2.X原始3.X转换含义
“additive”: false“blend”: “normal”正常混合(Alpha 混合)
“additive”: true“blend”: “additive”线性减淡(发光、特效常用)
“blend”: “multiply”3.x 新增:正片叠底
“blend”: “screen”3.x 新增:滤色

blend代替了additive,同时,attachment属性也发生了一些改变,2.X直接存储的名称字符串,而3.X则是不再直接存储名称,而是存储一个指向皮肤(Skin)数据的索引或占位符

对比一下两者

2.X

"slots": [
  { "name": "head", "bone": "head", "attachment": "head", "color": "ffffffff", "additive": false }
]

3.X

"slots": [
  { "name": "head", "bone": "head", "attachment": "head", "color": "ffffffff", "blend": "normal" }
]

如上大概就是可能涉及到的一些有关2.X和3.X之间的一些重要项目的变动。

接下来,说会主线,既然我们已经知晓了变动法则,那么我们对其的修正并不困难,直接用Python脚本进行查找替换即可,同时,被弃用的skinedmesh替换成mesh就阔以了。

如下是修正代码

import json

def migrate_spine_2x_to_3x(input_path, output_path):
    """将 Spine 2.x 骨骼数据迁移到 3.x 版本"""
    with open(input_path, 'r', encoding='utf-8') as f:
        data = json.load(f)

    # ── 1. 更新版本头信息 ──────────────────────────────────────────
    if 'skeleton' in data:
        data['skeleton']['spine'] = '3.8.99'
        print("[OK] 版本头信息已更新至 3.8.99")

    # ── 2. 修复骨骼(bones)数据 ──────────────────────────────────────────────────────
    bone_count = 0
    if 'bones' in data:
        for bone in data['bones']:
            # 提取 2.x 版本的继承标志(默认为 True)
            inherit_rotation = bone.pop('inheritRotation', True)
            inherit_scale    = bone.pop('inheritScale',    True)
            # 移除 2.x 版本中的翻转属性(3.x 中已废弃)
            bone.pop('flipX', None)
            bone.pop('flipY', None)

            # 将 2.x 的布尔值对转换为 3.x 的变换模式(transform mode)
            # 仅在非默认值时写入(默认值为 "normal",可省略该键)
            if inherit_rotation and inherit_scale:
                pass  # "normal" 为默认值,省略该键
            elif inherit_rotation and not inherit_scale:
                bone['transform'] = 'noScale'                # 仅无缩放
            elif not inherit_rotation and not inherit_scale:
                bone['transform'] = 'noRotationOrReflection' # 无旋转或反射
            elif not inherit_rotation and inherit_scale:
                bone['transform'] = 'onlyTranslation'        # 仅平移

            bone_count += 1
        print(f"[OK] 已处理 {bone_count} 个骨骼")

    # ── 3. 修复插槽(slots)数据 ──────────────────────────────────────────────────────
    slot_count = 0
    if 'slots' in data:
        for slot in data['slots']:
            # 2.x 的 additive(布尔值)→ 3.x 的 blend(字符串)
            additive = slot.pop('additive', False)
            if additive:
                slot['blend'] = 'additive'  # 添加剂混合模式
            slot_count += 1
        print(f"[OK] 已处理 {slot_count} 个插槽")

    # ── 4. 修复皮肤(skins):skinnedmesh → mesh ─────────────────────────────────
    mesh_count  = 0      # skinnedmesh 转换计数
    fixed_count = 0      # 误识别修复计数
    if 'skins' in data:
        for skin_name, skin in data['skins'].items():
            for slot_name, slot in skin.items():
                for attach_name, attachment in slot.items():
                    atype = attachment.get('type', '')

                    # 类型转换:蒙皮网格 → 普通网格
                    if atype == 'skinnedmesh':
                        attachment['type'] = 'mesh'
                        mesh_count += 1

                    # 修复批量替换中误判为 'region' 但实际是网格的附件
                    #(真正的 region 不会有 'triangles' 字段)
                    elif atype == 'region' and 'triangles' in attachment:
                        attachment['type'] = 'mesh'
                        fixed_count += 1

        print(f"[OK] 已转换 {mesh_count} 个 skinnedmesh → mesh")
        if fixed_count:
            print(f"[OK] 已修复 {fixed_count} 个误识别的 region → mesh")

    # ── 5. 修复 IK 约束(2.x → 3.x 字段重命名)──────────────────────────────────
    if 'ik' in data:
        for ik in data['ik']:
            # 2.x 使用 'target' 字符串,3.x 同样使用 'target' —— 没问题
            # 'bendPositive' 重命名后依然保留,仅做检查即可
            pass
        print(f"[OK] IK 约束检查完成(共 {len(data['ik'])} 个)")

    # ── 6. 输出结果 ─────────────────────────────────────────────────────────
    with open(output_path, 'w', encoding='utf-8') as f:
        json.dump(data, f, indent='\t', ensure_ascii=False)

    print(f"\n[DONE] 已保存至: {output_path}")


if __name__ == '__main__':
    # 执行迁移:从 input.json 读取,输出到 output.json
    migrate_spine_2x_to_3x('input.json', 'output.json')

记得改一下文件名,input.json,运行程序之后的output.json文件就是已经修复好的文件。

四、修正附件错误

当我们正确的修复JSON后,我们再进行导入,我们应该会看到如下骨骼,看到的话就说明骨骼主体修复正常:

接下来要做的就是补正附件了,如果大家直接选择在images文件夹下输出atlas文件裁剪出来的素材片段,那么大概率附件不会显示,而是一堆红叉叉,这是由于资源路径问题,修复这个问题依旧需要到JSON文件中,这个的修复相对简单,只需要更改一个属性值即可。

这个值应该在Skins块下,这里未修改的情况下应当是角色名称:UI_Character209

上方提到了,3.X版本索引有了一些变化,所以这里的目录应该是

images->feiyuan1->feiyuan1

的这种文件夹格式,但是这么改太麻烦了

我们直接改json,改成default,Spine就会自动在images文件夹下寻找资产,这样就比较简单了。

Spine会自动扫描更新,如果你搞定了,右边的红色标致就没有了。

之后点击左上角切换到动画界面

点击图片的小按钮,亮起表示可见,关闭骨骼的可见性,效果就是这样的。

下边的这两个按钮是动画播放,右边的是正常播放,左边的是倒放。

点击就可以预览角色动画啦~

这里是预览视频,就是服务器带宽不够,卡卡的。

五、导出喜欢的角色:

既然事情都已经搞定了,接下来要做的就是导出我们的小可爱啦。

导出过程比较方便,点击左上角Spine图标,选择导出

导出类型如果选择”动画“,那么最好选择视频或者动图,要不最后你会得到一大堆的图片,如果想要角色的某个姿势,可以在动画预览窗口中调整到你想要的姿势,在导出类型中选择”当前姿势“
”当前姿势“导出适合图片或者PSD文件导出,如果要导出PSD文件(图层已经分好了),不要选择动画,否则你就会得到一个包含所有帧的PSD文件,其实这里只要一帧即可。

上一篇