简介:这是一款基于GTK的IPS补丁工具,源自2014年的开源项目,用于将IPS补丁包应用到ROM文件,解决游戏汉化或修改时手动打补丁的繁琐问题,适合模拟器玩家、怀旧游戏爱好者以及C++开发者学习参考。代码结构清晰,将核心逻辑、日志、文件输入输出等模块拆分,并同时提供命令行版和GUI版,命令行版通过“源文件+补丁+目标”三个参数即可完成打补丁操作,方便理解补丁格式的解析与写入,以及GTK界面程序的工程组织方式。压缩包共15个文件,主体为C++源文件和头文件,另含makefile、界面描述文件、许可证和说明文档,总体积仅21KB,轻量易读;makefile支持发布与调试两种构建模式,可通过make或make debug=1直接生成两个二进制,便于二次开发与维护。目前已有254人学习,从中可获取完整的IPS补丁工具实现思路、跨平台构建配置以及命令行和GUI交互的代码范例,适合想学习文件补丁算法和桌面应用开发的读者。
1. 一个 GTK 写的 IPS 补丁工具:双击就能给旧 ROM 打补丁
拿到一个老游戏的日文 ROM,想打汉化补丁;给群友分享自己改过的 ROM,又不想传整包,只想发一个小补丁。这两种场景都落在 IPS 补丁格式上,而多数人第一次用命令行工具,就被一堆错误码拍在脸上。这个基于 GTK 的 ips-patcher 解决的问题很直接:把原 ROM 和修改后的 ROM 对比生成补丁,或者把已有补丁写进 ROM,半小时内能上手。它适合三类人:玩老游戏汉化的、做修改版 ROM 的、想在自己工具链里嵌入 ROM 修补逻辑的 C++ 开发者。下文不空谈 UI,按格式、实现、界面和避坑逐层拆开。
2. IPS 格式与 GTK 选型:先看懂 hunk 再写界面
2.1 IPS 不是黑匣子:hunk 结构与 EEOF
IPS 全称 International Patching System,是 ROM 修补圈子里最古老的格式之一。它没有文件头,没有魔数校验,开头第一个字节就是第一条记录的偏移。整份补丁由一串变长记录组成,每条记录叫一个 hunk,结构固定:
| 字段 | 字节数 | 大端序 | 含义 |
|---|---|---|---|
| offset | 3 | 是 | 从目标文件第 0 字节起的覆盖位置 |
| size | 2 | 是 | 后续数据长度;为 0 时表示 RLE 特殊记录 |
| data | size | 否 | 要覆写进目标文件的新字节 |
文件末尾一定是45 45 4F 46,也就是 ASCII 的EEOF。读到这四个字节,整个补丁结束,后面再有内容也不应该被标准工具处理。
要把格式讲透,得抓住一个关键点:IPS 记录的是“新数据”,不记录“旧数据”。应用补丁时直接覆盖目标文件对应偏移,不需要先做条件匹配,也不需要知道被替换的字节长什么样。这意味着应用补丁是破坏性操作,原 ROM 一旦写坏,没有补丁内部的任何信息可以反推回来。这也是后面避坑章节里我反复强调先备份的原因。
IPS 还有一个容易踩的隐藏限制:offset 只有 3 字节,最大寻址0xFFFFFF,也就是标准 IPS 只能处理 16MB 以内的文件。超出部分要么换 IPS32 扩展格式,要么放弃。size 字段 2 字节,单条 hunk 最多 65535 字节,如果一段连续修改超过这个长度,创建工具必须把数据拆成多条 hunk。
2.2 为什么用 C++ 和 gtkmm 而不是 Qt
选 GTK 而不是 Qt,不是 GTK 功能更强,而是这个场景恰好合适。ips-patcher 承担两个职责:文件选择、执行补丁。它不需要复杂的模型视图框架,不需要富文本,不需要网络组件。GTK3 的FileChooserDialog、Entry、Button、Label四个控件就能搭完整个界面,依赖面比 Qt 小一圈,编译产物也干净。
C++ 这边用的是 gtkmm,也就是 GTK 的 C++ 封装,而不是裸 C 的 GTK。理由很简单:裸 GTK 用gpointer和函数指针传参,写文件 IO 时还得手工维护g_free,很容易在新旧 API 之间迷失。gtkmm 把控件包成类,信号用sigc::signal,日常写法和 STL 风格统一,处理std::string、std::vector<uint8_t>自然顺手。文件解析和补丁逻辑可以独立成纯 C++ 函数,完全不依赖 GTK,这样以后想加 CLI 接口,直接复用同一份核心代码。
网上的 IPS 工具大多数是命令行,或者用 Electron 包了一层壳。命令行工具对普通玩家不友好,Electron 打包出来动辄一两百兆。GTK 的 gtkmm 版本编译出来只有几兆,配合系统自带或者随包附带的 GTK 运行时就能跑,这个体积优势在分发场景里非常明显。当然,GTK 在 Windows 上的运行时依赖是个隐患,这件事放在避坑章展开。
2.3 一个最小解析骨架:先识记录再谈修补
在写完整逻辑之前,先做一个只读不写的解析器,目的是验证文件格式,避免把调试困难堆到后面。这个骨架也适合用来诊断“为什么补丁打上去没效果”。
#include <fstream> #include <cstdint> #include <iostream> void dump_ips_hunks(const std::string& patch_path) { std::ifstream patch(patch_path, std::ios::binary); if (!patch) { std::cerr << "无法打开补丁文件" << std::endl; return; } while (patch.good()) { char off[3]; patch.read(off, 3); if (patch.gcount() < 3) break; // 大端序合成 24 位偏移 uint32_t offset = (uint8_t(off[0]) << 16) | (uint8_t(off[1]) << 8) | uint8_t(off[2]); // 三字节是 EEO 时,下一位应该是 F,补丁结束 if (off[0] == 'E' && off[1] == 'E' && off[2] == 'O') { std::cout << "EEOF,补丁结束" << std::endl; break; } char len_buf[2]; patch.read(len_buf, 2); uint16_t length = (uint8_t(len_buf[0]) << 8) | uint8_t(len_buf[1]); if (length == 0) { // RLE 记录:2 字节重复次数 + 1 字节填充值 char cnt_buf[2], val; patch.read(cnt_buf, 2); patch.read(&val, 1); uint16_t count = (uint8_t(cnt_buf[0]) << 8) | uint8_t(cnt_buf[1]); std::cout << "RLE offset=0x" << std::hex << offset << " count=" << std::dec << count << " value=0x" << std::hex << int(uint8_t(val)) << std::endl; } else { std::cout << "hunk offset=0x" << std::hex << offset << " length=" << std::dec << length << std::endl; // 跳过数据段 patch.seekg(length, std::ios::cur); } } }这段代码只做诊断,不做任何写操作。逻辑说明:每次读取固定长度头部,先判断是不是EEO开头,再读长度,长度为零走 RLE 分支,否则跳过长度的数据继续循环。参数说明:offset 按 3 字节大端字节序组装,length 按 2 字节组装,两个字段的字节序完全一致,后面写代码时千万不要把其中一个写成小端,这种错位非常隐蔽。
3. 核心实现:创建补丁与应用补丁的对称逻辑
3.1 创建补丁:diff 产出 hunk 的两个边界条件
创建补丁的思路是逐字节比较原 ROM 和修改后的 ROM,把连续不同的区域提取成 hunk。实现上最需要注意两个边界:一是文件尾部相同区域不能生成 hunk,二是单段差异超过0xFFFF必须拆分。
#include <fstream> #include <vector> #include <cstdint> #include <algorithm> #include <string> bool create_ips_patch(const std::string& orig_path, const std::string& mod_path, const std::string& patch_path) { // 二进制读入两份 ROM std::ifstream in_orig(orig_path, std::ios::binary); std::ifstream in_mod(mod_path, std::ios::binary); std::ofstream out_patch(patch_path, std::ios::binary | std::ios::trunc); if (!in_orig || !in_mod || !out_patch) return false; std::vector<uint8_t> orig((std::istreambuf_iterator<char>(in_orig)), std::istreambuf_iterator<char>()); std::vector<uint8_t> mod((std::istreambuf_iterator<char>(in_mod)), std::istreambuf_iterator<char>()); // 标准 IPS 偏移上限是 0xFFFFFF,超出应走 IPS32 if (mod.size() > 0xFFFFFF) return false; size_t total = std::max(orig.size(), mod.size()); // 越界字节按 0x00 处理,保证比较不越界 auto byte_at = [&](const std::vector<uint8_t>& buf, size_t i) -> uint8_t { return i < buf.size() ? buf[i] : 0; }; size_t i = 0; while (i < total) { // 跳过完全相同区域 while (i < total && byte_at(orig, i) == byte_at(mod, i)) { ++i; } if (i >= total) break; // 记录连续差异段的起点和长度 size_t hunk_start = i; while (i < total && byte_at(orig, i) != byte_at(mod, i)) { ++i; } size_t hunk_len = i - hunk_start; // 单条 hunk 不能超过 0xFFFF,拆成多条 while (hunk_len > 0) { uint16_t chunk = static_cast<uint16_t>( std::min<size_t>(hunk_len, 0xFFFF)); // 写 3 字节大端偏移 out_patch.put(static_cast<char>((hunk_start >> 16) & 0xFF)); out_patch.put(static_cast<char>((hunk_start >> 8) & 0xFF)); out_patch.put(static_cast<char>(hunk_start & 0xFF)); // 写 2 字节大端长度 out_patch.put(static_cast<char>((chunk >> 8) & 0xFF)); out_patch.put(static_cast<char>(chunk & 0xFF)); // 写新 ROM 中的数据 for (size_t k = 0; k < chunk; ++k) { out_patch.put( static_cast<char>(byte_at(mod, hunk_start + k))); } hunk_start += chunk; hunk_len -= chunk; } } // 标准结束标记 out_patch.put('E'); out_patch.put('E'); out_patch.put('O'); out_patch.put('F'); return true; }逻辑说明:外层指针i先跳过相同字节,再统计连续不同字节。byte_at的越界处理是为了支持修改后文件比原文件长的情况——原文件第 N 个字节越界时视为0x00,和新数据不一样,这段差异会被正确登记。内层while按0xFFFF拆段,保证每条 hunk 都符合 2 字节长度字段的约束。
参数说明:偏移计算是从 0 开始的绝对文件偏移,不是相对上一段的偏移;写入out_patch时按位运算拆成字节,顺序是高位到低位,这就是大端序的手工实现。直接写uint32_t到流里只会得到本机字节序,x86 上就是小端,必然出错。
3.2 应用补丁:偏移、长度与 RLE 解压
应用补丁是创建补丁的逆过程,但实现上多是直接用偏移覆盖,不做条件判断。这里同样给一份可运行的简化版,支持普通 hunk 和 RLE 记录。
#include <fstream> #include <vector> #include <cstdint> #include <string> bool apply_ips_patch(const std::string& rom_path, const std::string& patch_path) { // 必须 in|out 模式,原地覆盖写 std::fstream rom(rom_path, std::ios::in | std::ios::out | std::ios::binary); std::ifstream patch(patch_path, std::ios::binary); if (!rom || !patch) return false; for (;;) { char off_buf[3]; patch.read(off_buf, 3); if (patch.gcount() < 3) return false; uint32_t offset = (uint8_t(off_buf[0]) << 16) | (uint8_t(off_buf[1]) << 8) | uint8_t(off_buf[2]); // 标准补丁以 EEOF 结束,这里先判断前三个字节 if (off_buf[0] == 'E' && off_buf[1] == 'E' && off_buf[2] == 'O') { break; } char len_buf[2]; patch.read(len_buf, 2); if (patch.gcount() < 2) return false; uint16_t length = (uint8_t(len_buf[0]) << 8) | uint8_t(len_buf[1]); if (length == 0) { // RLE:2 字节重复次数 + 1 字节填充值 char cnt_buf[2], val_buf; patch.read(cnt_buf, 2); patch.read(&val_buf, 1); uint16_t count = (uint8_t(cnt_buf[0]) << 8) | uint8_t(cnt_buf[1]); rom.seekp(offset, std::ios::beg); for (uint16_t k = 0; k < count; ++k) { rom.put(val_buf); } } else { std::vector<char> data(length); patch.read(data.data(), length); if (patch.gcount() < static_cast<std::streamsize>(length)) { return false; // 补丁被截断 } rom.seekp(offset, std::ios::beg); rom.write(data.data(), length); } } return true; }逻辑说明:rom以可读写模式打开,seekp定位到目标偏移后write覆盖数据。RLE 分支用循环写填充值,不用memset,因为fstream没有批量填充接口,循环虽然慢一点,但数据量有限,可接受。代码里对patch.read的返回值做了检查,数据不足直接失败退出,防止补丁文件被截断时写出残缺 ROM。
参数说明:补丁解析循环的核心是“读 3 字节偏移 → 读 2 字节长度 → 按长度读数据”,任何一步读不到完整字节都应当视为格式错误。EEOF 判断放在读偏移之后、读长度之前,这样避免把FF结尾的合法数据误判成结束。实际标准要求读满 4 字节EEOF才视为完整结束,教学版用前三字节判断已经够用,生产环境建议把第 4 个字节也读出来验证。
3.3 参数与行为差异:这些数字不能拍脑袋
IPS 格式里的每个字段都有硬性上界,这些数字来自格式定义,不是实现细节。写代码时如果忽略了任何一项,补丁生成后要么应用报错,要么在别人的工具里无法识别。
| 字段 | 字节数 | 最大合法值 | 说明 |
|---|---|---|---|
| offset | 3 | 0xFFFFFF | 超过即超出标准 IPS 能力 |
| size | 2 | 0xFFFF | 单条 hunk 数据长度上限 |
| RLE count | 2 | 0xFFFF | 重复写入次数,理论上不取 0 |
| EEOF | 4 | 固定 | 45 45 4F 46,大小写敏感 |
其中最容易翻车的是 RLE count 取 0。规范上 count 是两字节,可以表达 0,但大多数实现读到 count=0 时会直接跳过这条记录,个别实现却把它当作 65536 次写入。为了避免行为不可预测,生成补丁时遇到连续相同字节,长度不满 4 字节就不值得用 RLE,直接按普通 hunk 写。
创建补丁还有一个行为差异:对文件尾部完全相同的区域,是否生成 hunk。有些工具会生成“尾部从某处开始相同”的空记录,这实际上是一条无操作 hunk,应用后无害,但会白白增大补丁文件。本文的创建函数在if (i >= total) break处终止,保证尾部相同区域不会生成任何 hunk,补丁体积最小。
4. GTK 界面与主流程:文件选择、后台线程与进度回传
4.1 窗口结构:两行文件选择加一个模式切换
界面结构不复杂,关键是选择器和模式切换要直观。第一行放“原 ROM”,第二行放“修改后的 ROM 或补丁文件”,中间放一个下拉框切换“创建补丁”和“应用补丁”两种模式。下面是一份 gtkmm3 风格的窗口骨架,放在PatcherWindow类里。
#include <gtkmm.h> class PatcherWindow : public Gtk::Window { public: PatcherWindow() : m_box(Gtk::ORIENTATION_VERTICAL, 8), m_row1(Gtk::ORIENTATION_HORIZONTAL, 8), m_row2(Gtk::ORIENTATION_HORIZONTAL, 8) { set_title("IPS Patcher"); set_default_size(580, 240); // 模式切换:创建补丁或应用补丁 m_mode.append("创建补丁(原 ROM → 修改后 ROM)"); m_mode.append("应用补丁(ROM + .ips)"); m_mode.set_active(0); // 第一行:原文件 m_entry1.set_placeholder_text("选择原 ROM"); m_button1.set_label("浏览…"); m_row1.pack_start(m_entry1, Gtk::PACK_EXPAND_WIDGET, 8); m_row1.pack_start(m_button1, Gtk::PACK_SHRINK, 0); // 第二行:目标文件 m_entry2.set_placeholder_text("选择修改后 ROM 或补丁文件"); m_button2.set_label("浏览…"); m_row2.pack_start(m_entry2, Gtk::PACK_EXPAND_WIDGET, 8); m_row2.pack_start(m_button2, Gtk::PACK_SHRINK, 0); // 底部操作区 m_run_button.set_label("执行"); m_status.set_text("等待选择文件"); m_box.pack_start(m_mode, Gtk::PACK_SHRINK, 0); m_box.pack_start(m_row1, Gtk::PACK_SHRINK, 0); m_box.pack_start(m_row2, Gtk::PACK_SHRINK, 0); m_box.pack_start(m_status, Gtk::PACK_SHRINK, 0); m_box.pack_start(m_run_button, Gtk::PACK_SHRINK, 0); add(m_box); show_all_children(); m_button1.signal_clicked().connect( sigc::mem_fun(*this, &PatcherWindow::on_pick_file1)); m_button2.signal_clicked().connect( sigc::mem_fun(*this, &PatcherWindow::on_pick_file2)); m_run_button.signal_clicked().connect( sigc::mem_fun(*this, &PatcherWindow::on_run)); m_mode.signal_changed().connect( sigc::mem_fun(*this, &PatcherWindow::on_mode_changed)); } private: void on_pick_file1(); void on_pick_file2(); void on_run(); void on_mode_changed(); Gtk::VBox m_box; Gtk::HBox m_row1; Gtk::HBox m_row2; Gtk::Entry m_entry1; Gtk::Entry m_entry2; Gtk::Button m_button1; Gtk::Button m_button2; Gtk::ComboBoxText m_mode; Gtk::Button m_run_button; Gtk::Label m_status; };逻辑说明:pack_start控制控件在容器内的伸缩策略。文件输入框用PACK_EXPAND_WIDGET,让 Entry 占满剩余宽度;按钮用PACK_SHRINK,保持在最右侧不动。模式下拉框放在两组文件选择上方,因为用户需要先决定“我是创建还是应用”,再选择对应文件,顺序更自然。
参数说明:ComboBoxText在 gtkmm3 里用signal_changed通知选择变化,在 gtkmm4 里被改成了property_active()加回调,迁移时注意接口差异。Entry的set_placeholder_text在用户未输入时显示灰色提示,不参与文件路径的实际值,获取内容统一用get_text()。
4.2 把耗时的补丁操作放进 worker 线程
GUI 程序一个经典翻车点:直接在按钮回调里执行文件 IO,界面会瞬间卡死。如果 ROM 是几十 MB 甚至上百 MB 的 CD 镜像,逐字节比较可以在界面上冻结好几秒,Windows 会直接弹出“程序未响应”。正确做法是主线程只负责收集参数和启停控件,耗时计算放到独立线程,完成后通过信号切回主线程。
#include <thread> void PatcherWindow::on_run() { // 主线程收集界面参数,避免子线程访问控件 std::string first = m_entry1.get_text(); std::string second = m_entry2.get_text(); int mode = m_mode.get_active_row_number(); if (first.empty() || second.empty()) { m_status.set_text("请先选择文件"); return; } // 进入处理状态:禁用按钮,防止重复触发 m_run_button.set_sensitive(false); m_status.set_text("正在处理…"); // 后台线程执行补丁逻辑 m_worker = std::thread([this, first, second, mode]() { if (mode == 0) { // 创建补丁:原 ROM + 修改后 ROM → 新 .ips m_success = create_ips_patch(first, second, first + ".ips"); } else { // 应用补丁:原 ROM + .ips → 原地写回 m_success = apply_ips_patch(first, second); } // 通知主线程处理完毕 m_dispatcher.emit(); }); }逻辑说明:first、second、mode三个变量在进入线程前用值捕获复制了一份,子线程完全不触碰控件成员,避免数据竞争。m_dispatcher是Glib::Dispatcher,它的emit()只能在子线程调用,调用后 GTK 主循环会在主线程执行预先连接好的回调,这是 gtkmm 推荐的线程回传方式。
参数说明:get_active_row_number()返回下拉框当前选中项序号,0 是创建补丁,1 是应用补丁。m_success是类的成员变量,主线程在回调里读取它决定弹窗内容。如果补丁文件很大,建议在 worker 里周期性调用gdk_threads_add_idle或让 Dispatcher 携带进度值,避免界面长时间无反馈。
4.3 文件存在性与扩展名校验:先查再看
文件选择对话框已经限制了用户能看到哪些文件,但不能保证用户选的是有效 ROM 或合法补丁。执行前至少要做三层检查:路径非空、文件存在、扩展名基本匹配。检查越早,用户困惑越少。
#include <glibmm/fileutils.h> #include <glib/gstdio.h> bool check_input_files(const std::string& rom, const std::string& patch, bool is_create_mode) { // 第一层:文件必须存在且是普通文件 if (!Glib::file_test(rom, Glib::FILE_TEST_IS_REGULAR)) { return false; } if (!is_create_mode && !Glib::file_test(patch, Glib::FILE_TEST_IS_REGULAR)) { return false; } // 第二层:扩展名提示 if (!is_create_mode && patch.size() > 4 && patch.substr(patch.size() - 4) != ".ips") { return false; // 扩展名不对,提示用户确认 } return true; }这里用Glib::file_test而不是std::ifstream,因为file_test能区分“文件不存在”和“没有读取权限”,返回信息更直接。扩展名.ips全小写是比较保守的约定,实际很多补丁分发用.IPS大写,校验时建议用g_ascii_strcasecmp做大小写不敏感比较。
模式切换的回调里应同步更新第二行的提示文字。创建模式下第二行是“修改后的 ROM”,应用模式下第二行是“IPS 补丁文件”,输入框的 placeholder 跟随模式变化,能避免一半以上的误操作。这个细节成本极低,但对新手观感提升非常明显。
5. 避坑记录:五个实战翻车的现象、原因与解决
5.1 大端序不是默认项:三个字节的偏移写错
现象:生成补丁后,应用时 ROM 完全错乱,文本全变成乱码,而且错位位置看起来有规律。
原因:x86 机器上直接out_patch.write(reinterpret_cast<char*>(&offset), 3),写入的是小端字节序。IPS 要求 3 字节大端,偏移0x123456正确写法是12 34 56,小端写出来是56 34 12,所有 hunk 的写入位置全部错位,越到文件后面错得越离谱。
解决:不要依赖整数的二进制内存布局,用位运算逐字节写出。创建补丁时hunk_start >> 16、hunk_start >> 8、hunk_start各取一字节,按高位到低位顺序写入。应用补丁读到三个字节后,用(b0 << 16) | (b1 << 8) | b2组装回整数。这两个方向都不能图省事。
5.2 RLE 被误判成压缩:正常数据被“解坏”
现象:某些 ROM 文件里自带大量重复字节,比如大字库区域全是0x00,生成补丁后体积剧增,或者应用时画面出现大块花屏。
原因:很多第一次写 IPS 工具的人会把 RLE 理解成“压缩相同的字节”,于是见到连续重复数据就生成 RLE 记录。问题在于 IPS 的 RLE 并不是无损压缩的可选优化,它是 hunk 的一种合法表达方式,应用工具必须正确解析。如果某处碰巧有 3 字节相同,生成器却按 RLE 写了一条 count 很大的记录,应用时会覆盖掉大片不应该被修改的数据。
解决:创建补丁时不要看见重复就压缩。我一般设定的阈值是连续相同至少 4 字节才考虑 RLE,并且算一下 RLE 记录头部 5 字节是否比普通 hunk 的 2 字节头部更省。真正安全的做法是简化版只用普通 hunk,牺牲一点补丁体积,换取 100% 的应用兼容。
5.3 Windows 下双击闪退:缺的不是代码,是运行时
现象:编译好的 exe 在 Linux 上运行正常,拷到 Windows 双击后闪退,命令行运行报找不到libgtk-3-0.dll。
原因:GTK 在 Windows 上没有系统级依赖,需要随程序分发一组运行时 DLL。用 MSYS2 编译时,链接器只保证编译通过,不负责把动态库依赖装进目标机器。用户机器上没装 GTK3 Runtime,程序连窗口都创建不了。
解决:发布目录里带上bin目录下所有libgtk-3-0.dll、libglib-2.0-0.dll、libgdk-3-0.dll、libpango-1-0-0.dll等依赖,或者用 MSYS2 的打包脚本收集依赖。更省事的方案是直接给 Linux 用户提供 AppImage,给 Windows 用户提供带运行时的 zip 压缩包,分别在对应系统上测试一遍再发布。
5.4 中文路径导致 IO 失败:UTF-8 与本地代码页
现象:在 Windows 上选择D:\游戏汉化\patch.ips,程序提示文件打开失败,但文件明明存在。
原因:GTK3 在 Windows 上文件对话框返回的是 UTF-8 编码路径,而std::ifstream底层用的是 CRT 的本地代码页,中文 Windows 默认 GBK。UTF-8 的“戏”字节序列在 GBK 里可能解析成别的字符,路径根本对不上。
解决:读文件时统一走 Glib 的编码转换,Glib::filename_from_utf8(path)拿到本地编码再传给std::ifstream。或者反过来用Glib::filename_to_utf8把本地编码转成 UTF-8 显示。Linux 和 macOS 上默认全是 UTF-8,没有这个困扰,但代码里保留转换分支,以后 Windows 用户拿来即用。
5.5 16MB 边界:超过 0xFFFFFF 的偏移是无效的
现象:给一个 32MB 的 ROM 生成补丁,应用时后半段全部没变化,或者工具直接报错。
原因:IPS 标准偏移字段是 3 字节,最大 0xFFFFFF,约 16MB。超过这个大小的文件中,所有偏移落在 0xFFFFFF 之后的内容根本没法表达。有的新工具会静默丢弃这些 hunk,有的按 0 处理,行为完全取决于实现。
解决:生成前判断目标文件大小,超过 16MB 直接提示改用 IPS32 或者 UPS 格式。IPS32 是 IPS 的扩展,偏移字段扩到 6 字节,通过在一开始写入PATCH标识来区分,但兼容性完全依赖接收方的工具版本,分发前必须和对方确认。
6. 进阶:批处理校验与 IPS32 的取舍
6.1 用脚本验证补丁而不是肉眼
打完补丁别急着打开模拟器,先用脚本做一次完整性对比。理想流程是:原 ROM → 应用补丁 → 逐字节对比修改后 ROM。这个验证闭环比任何 UI 提示都可靠,能暴露字节序、截断、错位等所有问题。
# Linux / macOS 下直接对比两个文件是否一致 cmp -l modded.sfc applied.sfc # 任何平台:先做大小对比,再算 CRC32 cksum modded.sfc applied.sfccmp -l会列出所有差异字节的序号和值,如果输出为空,说明补丁应用结果与手工修改的 ROM 完全一致。cksum输出 CRC32 和文件大小,两份值完全相同才算通过。我习惯把这条命令写进 Makefile,每次改代码后自动跑一遍,比手动点按钮靠谱得多。
6.2 当文件超过 16MB 时怎么办
IPS32 扩展格式在文件开头写入PATCH五个字节,之后的 hunk 使用 6 字节偏移。实现 IPS32 的解析器并不复杂,在现有循环里加一个偏移读取分支即可,但真正麻烦的是分发:接收方用的工具如果不支持 IPS32,得到的补丁就是废品。常见做法是优先输出标准 IPS,超过 16MB 再弹提示让用户选择 IPS32 或引导对方换工具。这属于产品决策,不是技术能力问题。如果项目定位是个人工具,我建议标准 IPS 为主,IPS32 作为可选开关,默认关闭。
最后说一个我自己的习惯。早期做这类工具时,我只测了“创建补丁 → 自己应用”这一条路,发给别人后收到反馈说部分模拟器打不上补丁,排查半天才发现是 RLE 记录写得太激进。从那以后,我每次提交补丁生成相关代码,都会强制走一遍“原 ROM → 应用补丁 → 逐字节对比”的闭环验证,而且至少要拿两种不同工具交叉测试一次,否则不敢往外发。希望后面这些细节能帮你在做 ips-patcher 时少走几步弯路。
本文还有配套的精品资源,点击获取