逆向工程解析PaintTool SAI文件格式:从二进制黑盒到数据自由

📅 2026/7/6 22:22:57 👁️ 阅读次数 📝 编程学习
逆向工程解析PaintTool SAI文件格式:从二进制黑盒到数据自由

1. 项目概述:为什么我们要“解剖”一个绘画软件的文件格式?

如果你是一个数字绘画的深度爱好者,或者像我一样,曾经在寻找一款轻量、响应快、手感独特的绘画软件时,PaintTool SAI 这个名字一定不会陌生。它以其流畅的笔刷模拟、极低的系统资源占用和独特的“抖动修正”功能,在众多专业绘画软件中占据了一席之地,尤其受到日系插画师和漫画创作者的偏爱。然而,SAI 有一个让很多用户又爱又恨的特点——它使用的是一个封闭的、未公开的专有文件格式,也就是我们常说的.sai.sai2文件。

这个项目,就是一次对 PaintTool SAI 文件格式的深度探索。它不是一个简单的“如何打开SAI文件”的教程,而是一次技术上的“逆向工程”之旅。我们试图去理解.sai文件内部是如何组织图层、笔刷、颜色、画布信息等数据的。为什么要做这件事?原因很实际:数据安全与创作自由。你是否遇到过 SAI 软件崩溃,文件损坏却无法用其他软件打开的绝望?或者想把多年前的.sai源文件迁移到新软件(如 Clip Studio Paint、Krita)中,却发现图层结构、混合模式全部丢失,只剩一张合并的图片?这些问题,都源于我们对这个“黑盒”文件格式的无知。

通过探索其文件格式,我们不仅能开发出简单的文件修复工具、格式转换器,更能深入理解数字绘画软件底层的数据逻辑。这对于开发者而言,是学习软件架构的绝佳案例;对于艺术家而言,是为自己的数字资产加上一层“保险”。网络上关于.sai格式的公开资料非常零散且陈旧,尤其是新版.sai2格式。本次探索,我将结合自己的实践,系统性地梳理其结构,并分享如何用免费、开源的工具进行初步解析。

2. 核心需求解析:从“打不开”到“看得懂”的痛点

在深入二进制数据之前,我们必须明确,破解一个文件格式,最终是为了解决哪些实际问题。这决定了我们探索的深度和方向。

2.1 数据恢复与文件修复

这是最刚需的场景。SAI 在异常关闭时,有时会生成损坏的.sai文件。软件本身自带的恢复功能并非万能。如果我们能理解文件的基本结构,比如文件头、数据块的划分,就有可能编写脚本,尝试剥离损坏的部分,抢救出尚完好的图层数据,甚至只是恢复出合并后的图像,这都比完全丢失作品要好得多。

2.2 跨软件工作流与格式转换

许多画师会同时使用多款软件。可能在 SAI 中起稿、铺色,因为其手感一流,然后导入到 Photoshop 或 Clip Studio Paint 中进行后期处理和特效添加。目前,这个过程通常需要导出为.psd.png等通用格式,但这就丢失了所有可编辑的图层、图层组、混合模式、矢量图层信息。一个理想的转换器,应该能尽可能多地将.sai中的元素“无损”或“高保真”地转换到目标格式中。

2.3 自动化脚本与批量处理

对于需要处理大量 SAI 文件的场景,比如游戏美术团队统一检查文件规范、批量修改画布尺寸、提取所有文件的缩略图等,如果只能手动打开每个文件,效率极低。理解文件格式后,我们可以编写外部脚本,直接读取文件中的特定信息(如分辨率、图层数、创建时间)或进行批量修改,而无需启动 SAI 软件本身。

2.4 学术研究与技术学习

对于软件开发者或计算机图形学学生来说,SAI 的文件格式是一个研究位图与矢量混合编辑、笔刷参数序列化、用户界面状态保存的绝佳样本。它的设计肯定考虑了存储效率、读取速度和向前/向后兼容性,这些设计思路本身就具有很高的学习价值。

3. 文件格式探索方法论:如何“打开”一个二进制黑盒

面对一个未知的二进制文件,我们不能像无头苍蝇一样乱撞。需要一套系统的方法论。这里我分享我探索.sai2格式时采用的步骤,这套方法同样适用于分析其他未公开格式的文件。

3.1 工具准备:免费的“手术刀”套装

工欲善其事,必先利其器。我们不需要昂贵的专业逆向软件,以下免费工具组合足以应对大部分情况:

  1. 十六进制编辑器:这是我们的主战场。推荐HxD(Windows)或Bless(Linux),它们免费、轻量、功能强大。用于直接查看和编辑文件的原始字节。
  2. 文本编辑器:用于查看文件中可能包含的明文字符串,如VlayrCMKY等标识符。Notepad++ 或 VS Code 均可。
  3. 编程环境:用于编写解析脚本。Python是最佳选择,因为它有强大的struct模块处理二进制数据,以及丰富的库(如PIL/Pillow用于图像处理)。完全免费。
  4. 对比样本:创建多个有控制的 SAI 文件。例如:
    • 一个单图层、纯色背景的文件。
    • 一个包含多个普通图层、且每个图层内容不同的文件。
    • 一个包含图层组、调整图层、文字图层(如果SAI有的话,实际上SAI没有文字图层,但可以测试其他类型)的文件。
    • 一个使用了特定笔刷和混合模式的文件。
    • 将这些文件用“另存为”功能,生成.sai.sai2版本进行对比。

3.2 逆向分析的核心思路:差异对比与逻辑推断

这是整个过程的精髓。我们不是猜,而是通过科学对比来推断。

  1. 文件头识别:用十六进制编辑器打开一个正常的.sai2文件,看最开始的几个字节。通常这里会有“魔数”(Magic Number)来标识文件类型。通过对比多个.sai2文件,确认开头的固定字节序列。例如,可能以特定的字节串开始。
  2. 结构猜测:二进制文件通常由不同的“数据块”(Chunk)组成。每个块可能有自己的标识符(ID)、长度(Length)和数据体(Data)。通过创建内容迥异的两个文件(如文件A只有1个图层,文件B有10个图层),然后用二进制比较工具(如fc /b命令)或直接在十六进制编辑器中肉眼对比,找到文件大小差异巨大的区域。这个区域很可能就是存储图层数据的地方。
  3. 数据类型解析:观察特定位置的数据。
    • 如果看到00 00 00 01,这可能是一个整数1(取决于字节序,SAI 很可能是小端序,即低位在前,所以读作0x01000000才是1?不,小端序下01 00 00 00才是1。需要验证)。
    • 如果看到41 48 00 00,这可能是单精度浮点数12.5(需要借助 Pythonstruct.unpack(‘<f’, b’\x41\x48\x00\x00’)来验证)。
    • 如果看到连续的FF FF FF FF00 00 00 00,可能是表示颜色(ARGB)或空数据。
    • 明文字符串可能会直接出现,如"Normal"(混合模式)或笔刷名称。
  4. 长度前缀:很多数据(如图层名、笔刷名)会先有一个表示字符串长度的整数(可能是1字节、2字节或4字节),后面紧跟字符串内容。通过修改图层名(如从“Layer 1”改为“Layer 1 - Updated”),观察文件中哪些字节发生了变化,从而确定字符串的存储位置和长度编码方式。

实操心得:在逆向过程中,保持详细的实验记录至关重要。我通常会创建一个电子表格,记录每个测试文件的内容、文件大小,以及在十六进制编辑器中发现的疑似关键偏移量(如0x1200x150疑似第一个图层数据开始)。通过横向对比,规律会逐渐浮现。

4. SAI文件格式结构深度解析(基于实践推测)

需要郑重声明:以下结构是我通过分析多个.sai2文件样本得出的推测性结论,并非官方文档。SAI 的官方格式并未公开,因此可能存在错误或不完整之处。但这个过程本身和得出的结构模型,对于理解和解析此类文件具有通用指导意义。

4.1 整体文件布局猜想

一个典型的.sai2文件可能遵循一种“索引头 + 数据块”的复合结构。这类似于 RIFF(资源交换文件格式,用于.avi,.wav)或 PNG 的块式结构,但具体实现是私有的。

[文件开始] ├── 文件头 (Header) │ ├── 魔数/签名 (Magic Number, 固定字节序列,标识这是SAI2文件) │ ├── 版本号 (File Version, 用于区分不同SAI版本生成的文件) │ ├── 画布信息块 (Canvas Info Chunk) │ │ ├── 画布宽度 (Width, 4字节整数) │ │ ├── 画布高度 (Height, 4字节整数) │ │ ├── 分辨率 (DPI, 可能为4字节整数或浮点数) │ │ └── 色彩模式 (Color Mode, 如RGB=0, CMYK=1, 1字节枚举) │ └── 全局索引表 (Global Index Table) │ ├── 图层块起始偏移量 (Offset to Layer Chunk) │ ├── 笔刷预设块起始偏移量 (Offset to Brush Preset Chunk) │ ├── 工作区状态块起始偏移量 (Offset to Workspace Chunk, 存储窗口布局、颜色面板等) │ └── ... 其他数据块指针 ├── 图层数据区 (Layer Data Section) │ ├── 图层数量 (Layer Count, 4字节整数) │ ├── 图层1信息头 (Layer 1 Header) │ │ ├── 图层类型 (普通图层、文件夹/图层组、线性减淡图层等, 1或4字节) │ │ ├── 图层名长度 (Layer Name Length, 1或2字节) │ │ ├── 图层名 (Layer Name, 变长字符串) │ │ ├── 混合模式 (Blend Mode, 如正常、正片叠底等, 编码为整数) │ │ ├── 不透明度 (Opacity, 1字节,0-255) │ │ ├── 可见性标志 (Visible Flag, 1字节布尔值) │ │ ├── 图层位置与尺寸 (X, Y, Width, Height, 可能都是4字节整数) │ │ └── 像素数据指针/压缩标志 (Pointer to Pixel Data or Compression Flag) │ ├── 图层1像素数据 (Layer 1 Pixel Data) │ │ └── (可能是压缩的ARGB位图数据,SAI可能使用简单的RLE或自定义压缩) │ ├── 图层2信息头... │ └── ... ├── 笔刷预设区 (Brush Preset Section) │ └── (存储了所有自定义笔刷的参数,如笔尖形状、纹理、流量、抖动修正强度等,结构复杂) └── 杂项数据区 (Miscellaneous Data) └── (可能包含历史记录快照、选区蒙版、参考线等数据) [文件结束]

4.2 关键数据块详解与Python解析示例

让我们用 Python 来尝试解析一些我们推测出的简单结构。假设我们通过对比,发现画布宽度和高度存储在文件开头的某个固定偏移量。

import struct def parse_canvas_info(file_path): """ 尝试解析 .sai2 文件的画布基本信息。 注意:偏移量(offsets)是假设的,需要根据实际分析调整。 """ with open(file_path, 'rb') as f: # 以二进制模式读取 data = f.read() # 假设1: 文件头签名,例如 'SAI2' 的某种编码 # 偏移量 0x00 - 0x03 signature = data[0:4] print(f"文件签名 (假设): {signature.hex()} 或 {signature}") # 假设2: 画布宽度和高度是紧接在签名后的两个4字节整数(小端序) # 偏移量 0x04 - 0x0B # 使用 struct.unpack 解析二进制数据,'<‘ 表示小端序,’I’ 表示无符号4字节整数 canvas_width, canvas_height = struct.unpack('<II', data[4:12]) print(f"画布宽度: {canvas_width} 像素") print(f"画布高度: {canvas_height} 像素") # 假设3: DPI 信息在宽度/高度之后,可能是4字节整数 # 偏移量 0x0C - 0x0F dpi = struct.unpack('<I', data[12:16])[0] print(f"分辨率: {dpi} DPI") # 假设4: 色彩模式在DPI之后,是1字节枚举 (0=RGB, 1=CMYK) # 偏移量 0x10 color_mode = data[16] color_mode_str = "RGB" if color_mode == 0 else "CMYK" if color_mode == 1 else f"未知({color_mode})" print(f"色彩模式: {color_mode_str}") # 使用示例 parse_canvas_info("你的测试文件.sai2")

这段代码的意义:它不是一个通用的.sai2解析器,而是一个探索工具。你需要通过修改偏移量(0x04, 0x0C, 0x10)和数据类型(‘<I’还是‘<f’浮点数),并对比多个实际文件的输出,来验证你的猜想是否正确。如果对于所有测试文件,canvas_width都能正确读出设定的画布宽度,那么这个偏移量和数据类型的假设就很有可能是对的。

4.3 图层名解析示例

图层名通常是变长字符串,前面会有一个长度标识。

def parse_layer_name_at_offset(data, offset): """ 从指定偏移量开始,解析一个可能带有长度前缀的字符串。 常见模式:1字节长度 + UTF-8字符串。 """ # 读取1字节作为字符串长度 name_length = data[offset] print(f"在偏移量 {hex(offset)} 处读取到的长度字节: {name_length}") # 字符串内容从下一个字节开始 string_start = offset + 1 string_end = string_start + name_length # 解码为UTF-8字符串,忽略错误(因为二进制文件中可能有非文本数据) layer_name = data[string_start:string_end].decode('utf-8', errors='ignore') print(f"解析出的图层名: '{layer_name}'") return name_length + 1, layer_name # 返回消耗的字节数和图层名 # 假设我们在偏移量 0x100 处怀疑是图层名 # total_consumed, name = parse_layer_name_at_offset(file_data, 0x100)

注意事项:字符串的编码不一定是 UTF-8,也可能是 UTF-16LE(在日文软件中常见),或者带有 BOM(字节顺序标记)。如果按 UTF-8 解析出来是乱码,可以尝试decode('utf-16le')。同样,长度前缀也可能是2字节(struct.unpack(‘<H’, ...))甚至4字节整数。

5. 实战:构建一个简单的SAI文件信息提取器

基于以上的探索和假设,我们可以尝试编写一个相对实用的脚本,它能从.sai2文件中提取出一些基本且有用的信息,而无需打开 SAI 软件。

5.1 设计目标与功能规划

我们的工具不追求完全解析所有数据,而是实现以下几个有限但实用的目标:

  1. 验证文件完整性:快速检查文件头签名是否正确,判断是否是一个有效的(或未严重损坏的)SAI2文件。
  2. 提取元数据:读取并显示画布尺寸、分辨率、色彩模式。
  3. 列举图层:尝试找出所有图层,并列出它们的名称、类型(普通/文件夹)、可见性和大致位置。
  4. 生成预览图:尝试解压第一个可见图层的像素数据(或所有图层合并),生成一张低分辨率的 PNG 预览图,用于文件管理器缩略图或快速浏览。

5.2 分步实现与代码剖析

由于完整的代码很长,这里我将分模块阐述关键逻辑。

第一步:定位图层列表区域这是最困难的一步。我们可以采用“启发式搜索”。我们知道图层名是明文字符串。我们可以从文件中部开始,搜索常见的图层默认名或混合模式字符串的字节序列。

def find_potential_layer_list(data): """ 通过搜索常见关键词来定位图层数据区的大致位置。 这是一个非常粗糙但有时有效的方法。 """ import re # 搜索 "Normal"(正常混合模式)的 UTF-8 字节 normal_bytes = b'Normal' # 搜索 "Layer" 的字节 layer_bytes = b'Layer' matches = [] for match in re.finditer(re.escape(normal_bytes), data): matches.append(('混合模式“Normal”', match.start())) for match in re.finditer(re.escape(layer_bytes), data): matches.append((f'字符串“Layer”', match.start())) # 按偏移量排序 matches.sort(key=lambda x: x[1]) print("找到的潜在文本位置:") for desc, pos in matches[:10]: # 只显示前10个 print(f" {desc} 在偏移量 {hex(pos)}") # 通常,第一个出现的“Layer”附近可能就是图层列表开始的地方。 return matches

第二步:解析图层信息块假设我们通过对比,确定了图层信息块有一个相对固定的头部结构。例如,每个图层条目以固定的标识字节(如0x4C 0x41 0x59 0x52,即"LAYR"的 ASCII)开始,后面跟着长度、类型、属性等。

def parse_layer_chunk(data, start_offset): """ 从给定的偏移量开始,尝试解析一个图层块。 这是一个高度假设性的函数,结构需要反复测试调整。 """ offset = start_offset layer_info = {} # 假设1: 4字节块标识 “LAYR” chunk_id = data[offset:offset+4] if chunk_id != b'LAYR': # 这个标识符是假设的! print(f"在偏移量 {hex(offset)} 未找到预期的图层块标识。") return None, offset offset += 4 print(f"找到图层块标识 at {hex(start_offset)}") # 假设2: 4字节块长度(不包括标识和长度本身) chunk_size = struct.unpack('<I', data[offset:offset+4])[0] offset += 4 print(f"图层块大小: {chunk_size} 字节") # 假设3: 图层属性(1字节类型,1字节可见性,1字节不透明度...) layer_type = data[offset] offset += 1 visible = bool(data[offset]) offset += 1 opacity = data[offset] offset += 1 layer_info['type'] = layer_type layer_info['visible'] = visible layer_info['opacity'] = opacity # 假设4: 图层名(1字节长度 + 字符串) name_len = data[offset] offset += 1 layer_info['name'] = data[offset:offset+name_len].decode('utf-8', errors='ignore') offset += name_len # 假设5: 图层位置和尺寸(4个4字节整数,小端序) x, y, width, height = struct.unpack('<iiii', data[offset:offset+16]) offset += 16 layer_info['rect'] = (x, y, width, height) # 假设6: 像素数据指针或内联数据标志 # ... 这里可能是一个偏移量,指向文件另一部分的像素数据。 # 为了简化,我们暂时不处理像素数据。 # 返回解析到的信息,以及下一个块的起始偏移量 next_chunk_start = start_offset + 4 + 4 + chunk_size # 标识 + 长度 + 数据体 return layer_info, next_chunk_start

第三步:组装与输出我们将上述函数组合起来,遍历文件,收集所有能识别的图层信息,并以友好的方式(如JSON或控制台表格)输出。

def extract_sai2_info(file_path): print(f"正在分析文件: {file_path}") with open(file_path, 'rb') as f: data = f.read() # 1. 解析画布基本信息(使用之前假设的偏移量) canvas_info = {} try: canvas_info['width'], canvas_info['height'] = struct.unpack('<II', data[4:12]) canvas_info['dpi'] = struct.unpack('<I', data[12:16])[0] canvas_info['color_mode'] = "RGB" if data[16] == 0 else "CMYK" except Exception as e: print(f"解析画布信息时出错: {e}") canvas_info = {} # 2. 尝试查找并解析图层 layers = [] # 假设图层列表从偏移量 0x200 开始(这需要你通过 find_potential_layer_list 确定) current_offset = 0x200 max_offset = len(data) - 64 # 预留一些空间,防止越界 while current_offset < max_offset: layer, next_offset = parse_layer_chunk(data, current_offset) if layer is None: # 没找到有效图层块,尝试向后滑动一个字节继续搜索(效率低,仅作演示) current_offset += 1 continue layers.append(layer) current_offset = next_offset if len(layers) > 50: # 安全限制,防止死循环 break # 3. 打印结果 print("\n=== 文件信息摘要 ===") print(f"画布: {canvas_info.get('width', 'N/A')} x {canvas_info.get('height', 'N/A')} @ {canvas_info.get('dpi', 'N/A')}DPI") print(f"色彩模式: {canvas_info.get('color_mode', 'N/A')}") print(f"发现图层数: {len(layers)}") print("\n=== 图层列表 ===") for i, layer in enumerate(layers): print(f"{i+1}. [{'可见' if layer['visible'] else '隐藏'}] {layer['name']} (不透明度: {layer['opacity']})") print(f" 位置/尺寸: ({layer['rect'][0]}, {layer['rect'][1]}) - {layer['rect'][2]}x{layer['rect'][3]}") # 运行 extract_sai2_info("你的作品.sai2")

5.3 注意事项与局限性

  1. 高度不确定性:以上所有偏移量、标识符、数据结构都是基于有限样本的推测。不同的SAI版本、不同的文件内容(如是否包含矢量图层、调整图层)可能会导致结构发生变化。这个脚本很可能无法解析所有.sai2文件,甚至可能解析失败。
  2. 像素数据是难点:图层像素数据的压缩算法是未公开的核心。上述脚本只解析了元数据,没有处理实际的图像像素。要解压像素数据,需要更深入的反汇编或通过大量样本进行黑盒测试,推测其压缩模式(可能是简单的行程编码RLE变种)。
  3. 性能与健壮性:示例中的搜索和解析逻辑非常初级,不适合处理大型或损坏的文件。生产环境下的工具需要更严谨的错误处理和更高效的数据定位算法。

6. 常见问题、排查技巧与扩展思考

在探索和编写解析工具的过程中,我遇到了无数问题。这里把一些典型的坑和解决思路记录下来。

6.1 问题排查速查表

问题现象可能原因排查思路
解析出的画布尺寸是巨大的数字(如十亿)字节序错误。SAI很可能使用小端序(Intel序),而你用大端序解析了。使用struct.unpack(‘<I’, ...)(小端)替代struct.unpack(‘>I’, ...)(大端)。
找到的图层名是乱码字符串编码错误。SAI可能使用UTF-16LE存储日文或其它非ASCII字符。尝试data[offset:offset+len].decode(‘utf-16le’, errors=‘ignore’)。也可以检查长度前缀是字节数还是字符数(UTF-16下两者不同)。
脚本对某些文件有效,对另一些无效文件版本差异.sai.sai2格式不同,甚至同是.sai2,不同SAI2版本也可能有细微调整。首先检查文件头签名。为不同版本实现不同的解析分支。创建更多版本的测试文件进行对比。
无法定位图层列表起始位置搜索关键词不准结构有偏移。图层名可能不是“Layer”,而是本地化的“レイヤー”或用户自定义名。尝试搜索混合模式字符串如“Multiply”、“Screen”的字节序列。或者搜索连续的空字节(00 00)分隔的区域,这可能代表数据结构的对齐。
解析时程序崩溃(IndexError)偏移量计算错误,导致读取超出文件范围。在每个struct.unpack或切片操作前,检查offset + length <= len(data)。添加大量的异常捕获和日志输出,精确定位崩溃点。

6.2 扩展思考:从解析到创造

理解文件格式的终极目的,不仅仅是读取,有时甚至是写入。但这风险极高,因为一个字节的错误就可能导致SAI无法识别甚至崩溃。更可行的扩展方向包括:

  1. 开发图形化的“SAI文件浏览器”:使用 Python 的 GUI 库(如 Tkinter, PyQt),将上述信息提取功能包装成一个有界面、能显示图层树和预览图的小工具。
  2. 实现单向的“SAI to PSD”转换核心:专注于将图层名、位置、可见性、混合模式(映射到PSD最接近的模式)和解压后的像素数据,通过psd-tools这样的库写入到.psd文件中。即使不能100%还原,也能极大保留编辑性。
  3. 参与开源社区:将你的发现以文档或代码片段的形式,贡献给GIMPKrita等开源绘画软件社区。也许你的工作能成为它们未来支持导入.sai格式的一块基石。

6.3 最后的建议与心得

逆向工程一个活跃软件的私有格式,就像在黑暗中拼凑一幅没有图纸的拼图。它需要极大的耐心、严谨的记录和科学的实验方法。不要指望一蹴而就。从最简单的文件(单色背景,一个图层)开始,每验证一个假设,就离真相更近一步。

这个过程最大的收获,其实不是最终那个能解析文件的脚本,而是你学会了一套分析复杂二进制系统的方法论。这套方法可以迁移到分析其他文件格式、网络协议甚至游戏存档上。它锻炼的是你的逻辑推理、细心观察和系统性测试的能力。

对于绝大多数艺术家用户,我仍然建议定期将.sai文件另存为.psd或导出分层.png作为备份,这是最稳妥的数据安全策略。而这个探索项目,则为我们打开了一扇窗,让我们得以窥见自己所依赖的数字工具内部那精密而有趣的世界。当你的作品不仅仅是屏幕上显示的像素,而是一串可以被理解和操纵的字节时,你与你的创作之间,便建立起了一种更深刻、更可控的连接。