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

📅 2026/7/6 22:01:32
逆向工程解析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 工具准备免费的“手术刀”套装工欲善其事必先利其器。我们不需要昂贵的专业逆向软件以下免费工具组合足以应对大部分情况十六进制编辑器这是我们的主战场。推荐HxDWindows或BlessLinux它们免费、轻量、功能强大。用于直接查看和编辑文件的原始字节。文本编辑器用于查看文件中可能包含的明文字符串如V、layr、CMKY等标识符。Notepad 或 VS Code 均可。编程环境用于编写解析脚本。Python是最佳选择因为它有强大的struct模块处理二进制数据以及丰富的库如PIL/Pillow用于图像处理。完全免费。对比样本创建多个有控制的 SAI 文件。例如一个单图层、纯色背景的文件。一个包含多个普通图层、且每个图层内容不同的文件。一个包含图层组、调整图层、文字图层如果SAI有的话实际上SAI没有文字图层但可以测试其他类型的文件。一个使用了特定笔刷和混合模式的文件。将这些文件用“另存为”功能生成.sai和.sai2版本进行对比。3.2 逆向分析的核心思路差异对比与逻辑推断这是整个过程的精髓。我们不是猜而是通过科学对比来推断。文件头识别用十六进制编辑器打开一个正常的.sai2文件看最开始的几个字节。通常这里会有“魔数”Magic Number来标识文件类型。通过对比多个.sai2文件确认开头的固定字节序列。例如可能以特定的字节串开始。结构猜测二进制文件通常由不同的“数据块”Chunk组成。每个块可能有自己的标识符ID、长度Length和数据体Data。通过创建内容迥异的两个文件如文件A只有1个图层文件B有10个图层然后用二进制比较工具如fc /b命令或直接在十六进制编辑器中肉眼对比找到文件大小差异巨大的区域。这个区域很可能就是存储图层数据的地方。数据类型解析观察特定位置的数据。如果看到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 FF或00 00 00 00可能是表示颜色ARGB或空数据。明文字符串可能会直接出现如Normal混合模式或笔刷名称。长度前缀很多数据如图层名、笔刷名会先有一个表示字符串长度的整数可能是1字节、2字节或4字节后面紧跟字符串内容。通过修改图层名如从“Layer 1”改为“Layer 1 - Updated”观察文件中哪些字节发生了变化从而确定字符串的存储位置和长度编码方式。实操心得在逆向过程中保持详细的实验记录至关重要。我通常会创建一个电子表格记录每个测试文件的内容、文件大小以及在十六进制编辑器中发现的疑似关键偏移量如0x120到0x150疑似第一个图层数据开始。通过横向对比规律会逐渐浮现。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 如RGB0 CMYK1 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字节枚举 (0RGB 1CMYK) # 偏移量 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, errorsignore) 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 设计目标与功能规划我们的工具不追求完全解析所有数据而是实现以下几个有限但实用的目标验证文件完整性快速检查文件头签名是否正确判断是否是一个有效的或未严重损坏的SAI2文件。提取元数据读取并显示画布尺寸、分辨率、色彩模式。列举图层尝试找出所有图层并列出它们的名称、类型普通/文件夹、可见性和大致位置。生成预览图尝试解压第一个可见图层的像素数据或所有图层合并生成一张低分辨率的 PNG 预览图用于文件管理器缩略图或快速浏览。5.2 分步实现与代码剖析由于完整的代码很长这里我将分模块阐述关键逻辑。第一步定位图层列表区域这是最困难的一步。我们可以采用“启发式搜索”。我们知道图层名是明文字符串。我们可以从文件中部开始搜索常见的图层默认名或混合模式字符串的字节序列。def find_potential_layer_list(data): 通过搜索常见关键词来定位图层数据区的大致位置。 这是一个非常粗糙但有时有效的方法。 import re # 搜索 Normal正常混合模式的 UTF-8 字节 normal_bytes bNormal # 搜索 Layer 的字节 layer_bytes bLayer 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(keylambda 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:offset4] if chunk_id ! bLAYR: # 这个标识符是假设的 print(f在偏移量 {hex(offset)} 未找到预期的图层块标识。) return None offset offset 4 print(f找到图层块标识 at {hex(start_offset)}) # 假设2: 4字节块长度不包括标识和长度本身 chunk_size struct.unpack(I, data[offset:offset4])[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:offsetname_len].decode(utf-8, errorsignore) offset name_len # 假设5: 图层位置和尺寸4个4字节整数小端序 x y width height struct.unpack(iiii, data[offset:offset16]) 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{i1}. [{可见 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 注意事项与局限性高度不确定性以上所有偏移量、标识符、数据结构都是基于有限样本的推测。不同的SAI版本、不同的文件内容如是否包含矢量图层、调整图层可能会导致结构发生变化。这个脚本很可能无法解析所有.sai2文件甚至可能解析失败。像素数据是难点图层像素数据的压缩算法是未公开的核心。上述脚本只解析了元数据没有处理实际的图像像素。要解压像素数据需要更深入的反汇编或通过大量样本进行黑盒测试推测其压缩模式可能是简单的行程编码RLE变种。性能与健壮性示例中的搜索和解析逻辑非常初级不适合处理大型或损坏的文件。生产环境下的工具需要更严谨的错误处理和更高效的数据定位算法。6. 常见问题、排查技巧与扩展思考在探索和编写解析工具的过程中我遇到了无数问题。这里把一些典型的坑和解决思路记录下来。6.1 问题排查速查表问题现象可能原因排查思路解析出的画布尺寸是巨大的数字如十亿字节序错误。SAI很可能使用小端序Intel序而你用大端序解析了。使用struct.unpack(‘I’ ...)小端替代struct.unpack(‘I’ ...)大端。找到的图层名是乱码字符串编码错误。SAI可能使用UTF-16LE存储日文或其它非ASCII字符。尝试data[offset:offsetlen].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无法识别甚至崩溃。更可行的扩展方向包括开发图形化的“SAI文件浏览器”使用 Python 的 GUI 库如 Tkinter PyQt将上述信息提取功能包装成一个有界面、能显示图层树和预览图的小工具。实现单向的“SAI to PSD”转换核心专注于将图层名、位置、可见性、混合模式映射到PSD最接近的模式和解压后的像素数据通过psd-tools这样的库写入到.psd文件中。即使不能100%还原也能极大保留编辑性。参与开源社区将你的发现以文档或代码片段的形式贡献给GIMP、Krita等开源绘画软件社区。也许你的工作能成为它们未来支持导入.sai格式的一块基石。6.3 最后的建议与心得逆向工程一个活跃软件的私有格式就像在黑暗中拼凑一幅没有图纸的拼图。它需要极大的耐心、严谨的记录和科学的实验方法。不要指望一蹴而就。从最简单的文件单色背景一个图层开始每验证一个假设就离真相更近一步。这个过程最大的收获其实不是最终那个能解析文件的脚本而是你学会了一套分析复杂二进制系统的方法论。这套方法可以迁移到分析其他文件格式、网络协议甚至游戏存档上。它锻炼的是你的逻辑推理、细心观察和系统性测试的能力。对于绝大多数艺术家用户我仍然建议定期将.sai文件另存为.psd或导出分层.png作为备份这是最稳妥的数据安全策略。而这个探索项目则为我们打开了一扇窗让我们得以窥见自己所依赖的数字工具内部那精密而有趣的世界。当你的作品不仅仅是屏幕上显示的像素而是一串可以被理解和操纵的字节时你与你的创作之间便建立起了一种更深刻、更可控的连接。