我用 WorkBuddy 把一套 30 年前的 Fortran 老软件汉化了,还顺手做了一套自动回归
> QA 工程师:日常做电信终端软件测试。 ## 一、被汉化的目标是什么 ITSHFBC 是 ITU 的高频(HF)传播预测软件套件,HF 通信、频率规划、链路预算都会用到。它是 90 年代的遗产: - 计算内核:Salford FTN95 Fortran 编译 - 界面:ClearWin+(ClearWin 自己的窗口框架,不是 Win32 原生控件) - 交付形态:一堆没有源码的 EXE 我手上要汉化的是这几个: ``` C:\itshfbc\bin_win\Pointwin.exe 点对点链路预测 C:\itshfbc\bin_win\Areawin.exe 区域覆盖(VOAAREA) C:\itshfbc\bin_win\Hfantwin.exe 天线分析 C:\itshfbc\bin_win\Si_win.exe 信号强度 ``` 目标很明确:中文菜单 + 中文对话框 + 不崩。前面两个都还好,第三个是深渊。 ## 二、汉化之后为什么间歇性崩溃 翻译完第一次跑,看起来是好的。用着用着就弹窗: ``` Exception Missing ']' Unused arguments on winio call 0xC0000409 (STATUS_STACK_BUFFER_OVERRUN) ``` 关键特征是"间歇性" —— 同一个菜单项,走不同的数据路径崩、换个数据又不崩、连开两次可能结果不同。这种现象如果靠人工点点点验证,结论只能是"好像有点问题但说不上来",根本没法收敛。 我拿现成的 PE 分析工具扒了一下 ClearWin+ 的字符串处理逻辑,结论是这样: ClearWin+ 把每个格式串的"隐藏长度"作为参数传给 `winio@` 的调用约定,跨串续行拼接完全依赖这个长度做边界判断。 汉化把英文串换成中文,字节数变了(UTF-8 更夸张,GBK 也会变),但传统汉化工具只改内容不改长度 —— 结果就是越界读,后面的内存被当成格式串继续解析,直到撞上某个非法字符才崩。 所以"越界读"是随机的,崩点也是随机的。这就是间歇性的来源。 ## 三、为什么不能继续靠人工点 我最初想的方案很朴素:把 4 个 EXE 的所有菜单、对话框、按钮手工点一遍,记录崩点。写了个 menu_test.py 自动化之后发现两个更麻烦的问题: 问题 1:全菜单遍历会互相干扰。 有的对话框会写状态文件到 C:\itshfbc\run\(performw.ini、icepacg.out 这类)。上一轮测试留下的状态会改变下一轮的程序行为路径,于是"同一个菜单项,上一轮崩、这一轮不崩"。这种情况下做单串二分定位,结论是不可复现的垃圾。 问题 2:肇事因素互相耦合。 崩的往往不是"某一个译文串有问题",而是"A 串和 B 串在特定交互序列下叠加才触发"。单独测 A 一切正常。 所以必须换策略。 ## 四、实际方案:自动化全菜单遍历 + 分组排除 核心思路:把定位单位从"单个串"换成"一组串"。 ``` 全菜单遍历 → 发现肇事菜单项 → 按对话框归组 → 排除整组 → 重建 → 复测该菜单项 → 确认肇事 → 把偏移固化进 trans_exclusions.py → 全菜单遍历连跑两轮全 OK → 通过 ``` 工具脚本一共九个,都放在 hanization\ 下: ``` | 脚本 | 作用 | |---|---| | menu_test.py | 菜单树枚举 + 逐项遍历(每项独立进程) | | group_test.py | 分组排除定位 | | menu_bisect.py | 菜单项触发测试 / 子集重建 | | button_test.py | 按钮遍历 + rebuild()(从 .bak 重建) | | diag_menu_crash.py | 抓 Exception 窗口里的具体运行时错误 | | sweep_danger.py | 危险特征筛查回退 | | build_argmap.py | 全量 LEA + push 引用图 | | deep_patch.py | 深度长度补丁 | | find_setup_culprit.py / find_printer_culprit.py | 对话框级 dump + 与 .bak 逐偏移 diff | ``` 遍历每项都走独立进程:启动 → 点 Accept → 发 WM_COMMAND(0x0111) + 菜单 ID → 监测 Exception 窗口 / 进程退出。按钮用 BM_CLICK(0x00F5)。 两个识别到的高危结构,供参考: - 绘图区段整段回退(0x04E200-0x051E00),这段交互最复杂,收益不值得硬啃 - 无界面计算引擎(Icepacw / Voacapw / Rec533w)根本不要汉化。Icepacw 汉化后曾直接输出 0 字节、图形菜单崩溃。Voacapw 实测没坏,也建议回退 —— 不要赌。 ## 五、根治:长度参数补丁法 定位到肇事偏移只是止损。真正的解法是让翻译后的串"长度自洽"。 FTN95 编译出的 winio@ 调用,每个格式串参数是这么传进内核的: ``` ; LEA 指向 .data 里的格式串 8D 05 xx xx xx xx lea eax, [offset .data] 68 20 00 00 00 push 20h ; ← 隐藏的逻辑长度 ``` 那个 `push` 后的立即数,就是我一直在找的"隐藏长度"。 它就明明白白躺在 .text 段里。 所以传统做法(缩短内容 + \x00 填充到原长)是错的——填充不解决长度语义问题。正确做法: 1. 扫描 .text 中 8D /r(LEA disp32)指向 .data 的所有引用点 1. 从引用点向前回扫 6~34 字节,找 68 xx xx xx xx(push imm32),取真实逻辑长度(值域 4~255) 1. 写入 GBK 中文 + 空格填充到原逻辑长度,同时把 push 立即数改成译文的字节数 build_argmap.py 做第一步,deep_patch.py 做第三步。以 Areawin 为例,基础重建之后一共 23 条深度补丁。 ## 五个必须遵守的约束 这几条都是踩出来的,每条都对应一次失败: 1. 用空格填充,绝对不要用 `\x00` 填充。 \x00 会被读进格式串,直接破坏解析。 1. 译文后面如果物理上紧跟固定地址参数(比如程序名),必须整串等长。 否则 '&' 之后会错位读出非法字符。 1. GBK 次字节不能是 % & [ ] ^ @ * \ \ ,且 &` 后面不能紧跟中文。这几个字符是 ClearWin+ 格式串的元字符,落在多字节区会触发歧义解析。 1. 链内没有 push 长度的段不能用此法。 有些段 LEA 有引用但回扫找不到长度 —— 这些只能老老实实回退成原英文。deep_patch.py 会自动标记并跳过,别硬来。 1. 基础翻译表里不要写大参数串的内部子串。 翻译器做模糊匹配时很容易把 "About" 这种 50 字节链里的某个词当独立条目替换,一改就破坏整串结构。 ## 六、汉化测试里的几个坑 Defender 会给你制造假阳性。 EXE 重写后首次启动可能被杀毒软件扫描拖慢,NO-STARTDLG 这个判据会误判成"启动即崩"。首跑结果异常时先复测再下结论,这一条救了我至少三次误判。 状态文件会污染测试。 C:\itshfbc\run\ 下的 performw.ini、icepacg.out 会改变程序行为路径。对比测试前要么清理,要么明确保持两侧一致。 多实例并行会互相 taskkill。 同时跑两个测试实例,A 实例结束时 taskkill 把 B 实例的窗口干掉,B 被判为"崩溃"。一次只跑一个实例。 tasklist 输出是 GBK。 Python 里必须 decode('gbk'),否则 UnicodeDecodeError,而且报错位置离真正的原因十万八千里。 引擎不能裸跑。 调用方式是 w.exe .dat .out,且 cwd 必须是 run 目录。裸跑会挂起等回车。 Areawin 的菜单对消息注入免疫。 这一点很反直觉 —— 同一套 ClearWin+,Pointwin 接受 `WM_COMMAND`(运行→图形 = 4008,有效),Areawin 的 Run 菜单完全不接受:WM_COMMAND、坐标点击、前台激活全部无效。注入事件不满足它回调的内部条件,只能改用真实键盘菜单导航。我的教训是:不要把一个程序的自动化方案套用到另一个程序上。 区域覆盖验证不用等出图。 计算 + 显示要 10~20 分钟。判断标准是"无 Exception + voacapx.out 持续增长",够了。 ## 七、附带产出:一个真跑得起来的 GUI 工具 汉化验证过程中顺手把点对点绘图也自动化了,现在是一个能发给同事用的 HTML 界面工具(零第三方依赖,18.3 MB 压缩包): ``` # 快速调用:JSON 注入 → 驱动 Pointwin → 出图 python -P pointwin_plot.py guangzhou_beijing.json plots/gz_bj_SNR.png --w 900 # 多图型一次跑完(引擎只算一次) python -P pointwin_plot.py params.json --jobs jobs.json ``` 实测耗时:单图 12~13 秒(替代了原来 19 秒的抓屏方案);多图型首张 13 秒,后续每张约 5 秒(走绘图窗 Parameters 菜单重绘,不重跑 VOACAP)—— 6 张图从 78 秒降到约 40 秒。 下面是自动化工具出的两张成品图。第一张是广州→北京 24 小时 SNR 中位值(CCIR 系数、SSN=76),第二张是青岛→上海的最适用频率 MUFday。   MUF 那条蓝线(FOT)穿过整个彩色信噪比区的地方,就是最佳工作频率窗口——这条曲线是整个 VOACAP 的核心输出,也是我花时间最多的地方:让这玩意儿能被脚本驱动,且出图不带任何 DPI 变形和底部裁切。 ## 图型必须两轮匹配 ``` # ❌ 单轮子串匹配:'REL' 会先命中 '...reliab...'(RPWRG 行的描述文字) # ✅ 先按 ^\s([A-Za-z]+)\s= 精确比对,再退化到子串匹配 ``` 这个 bug 的表现特别坑:"我要 REL 结果出了 RPWRG",日志里还显示操作成功。 另外,Pointwin 的参数列表框实测只有 20 项: ``` TANGLE DELAY VHITE MUFday LOSS DBU SDBW NDBW SNR RPWRG REL MPROB SPRB SIGLW SIGUP SNRLW SNRUP TGAIN RGAIN SNRxx ``` 没有 `RANGLE`(辐射角只有 TANGLE)。我前端清单里多写了一个 RANGLE,结果全选时在第 11 张整批失败。"Time availability" 是 REL 的别名,会重复出图,也不要放。 ## 截图方式的结论:能用程序自己的功能就别抓屏 系统 125% 缩放(1920×1080,DPI 120)时,用 PrintWindow 抓窗口得到的永远是放大 1.25 倍后被裁掉的局部 —— 右侧图例截断、底部 NTIA/ITS 落款全丢。更阴的是"底部墨迹自检"会误判通过(底部恰好是空白区)。 判定手法:量网格线间距。正常约 63 px,变形时约 77 px(= 63 × 1.25)。 正解是让程序自己导出:绘图窗口菜单 to Clipboard(命令 id 动态查找含 "clipboard" 的项),从剪贴板取回 DIB 存 PNG。这个导出与窗口大小、DPI 缩放完全无关 —— 实测窗口 850 / 1000 / 1400 宽,导出恒为 830×630,完整含底部轴和落款,无变形无裁切。 不要 `SendMessage`,只发 `PostMessage`。 SendMessage 同步等待,程序正忙时消息泵被堵住,命令根本不执行(实测 6 秒内无 DIB)。另外不要抢前台 —— 实测 SetForegroundWindow 成功(绘图窗变前台)时反而写不进剪贴板。每次触发前先 EmptyClipboard(),否则会取到上一张的残留。 ## 一个 64 位句柄被截断的坑 这个是整个过程最耗时间的 bug,症状是"剪贴板明明有 DIB 却读不出来",而且静默返回 None,不报错: ``` user32 = ctypes.windll.user32 k32 = ctypes.windll.kernel32 # ❌ ctypes 默认 restype=c_int,会把 64 位句柄截断成 32 位 # 随后 GlobalLock 拿到空指针/垃圾指针 # ✅ 必须显式声明: user32.GetClipboardData.restype = ctypes.c_void_p user32.GetClipboardData.argtypes = [ctypes.c_uint] k32.GlobalLock.restype = ctypes.c_void_p k32.GlobalLock.argtypes = [ctypes.c_void_p] k32.GlobalSize.restype = ctypes.c_size_t ``` 诊断手法(很有效,值得记住):枚举 EnumClipboardFormats 并逐个打印 GlobalSize 和头 32 字节,CF_DIB(8) / CF_DIBV5(17) 的真实尺寸立刻可见。本机实测 DIB 头 28000000 e3030000 f4020000 01002000 03000000 → 40 字节头 / 995×756 / 32bpp / BI_BITFIELDS。 顺带两个小坑:不要在已 `OpenClipboard` 的状态下再调 `OpenClipboard`(嵌套打开失败,封装成"自己开剪贴板"的函数后会得到空格式列表 → 又是静默 None);EnumClipboardFormats 本身不需要先 OpenClipboard。 ## 目录名里的一个隐蔽 47 字符限制 Fortran 引擎把根目录读进约 47 字符的固定缓冲区,而且拼子路径不加分隔符。根目录 48 字符就会截掉尾字符,报错长这样: ``` In READLIST, could not open=...\ITSHFBDATABASE\METHOD.DAT ^^^^^^^^^^^^^^^^^^^^^^ ``` 注意 itshfbc 和 database 之间没有分隔符,还少了末尾的 c —— 这就是"截断 + 不加分隔符"的双重特征。症状是启动阶段主窗口根本不出现,引擎卡在 Pause 等回车。 含空格也是死:D:\cp test_itshfbc 失败,D:\CPTEST~1 正常。症状是进程在,但"指定HF传播模型"对话框永不出现。 统一解法:无条件转 8.3 短路径,一次解决超长和空格两个问题: ``` import ctypes buf = ctypes.create_unicode_buffer(260) ctypes.windll.kernel32.GetShortPathNameW( r'D:\code\openITS\dist\PointwinGuiPortable\itshfbc', buf, 260) # → D:\CODE~1\DIST\POINTW~1\itshfbc ``` 中文路径本身没问题。 ## 一个"改动不生效"改了三天的 bug:stdout 缓冲 父进程 Popen(..., -u, env={PYTHONUNBUFFERED=1}),子进程里却写了这么一行: ``` # ❌ 顶层无参数重包 stdout sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') # ✅ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace', line_buffering=True) # ← 关键 ``` 新建 TextIOWrapper 会把 `-u` 设好的无缓冲状态丢掉,退回 8KB 块缓冲。 症状极具迷惑性:服务正常、SSE 正常、前端正常,但任务运行期间页面完全不动(进度条不走、日志空白),等子进程退出才一次性把 20 条日志全刷出来。实证:修复前 20 条 plot_done 事件的时间戳全挤在 10 ms 内,而 job_done.elapsed = 109 s。 验收判据(一定要做,别只看"日志出来了"): ``` python -P -u pointwin_plot.py params.json \ | python -c "import sys,time; t=time.time(); [print('%6.2f %s'%(time.time()-t, l.rstrip())) for l in sys.stdin]" ``` 正常应该 0.00 → 2.13 → 8.16 → 12.60 → … 逐步推进。全挤在同一个时刻就是缓冲没打开。 另一条更省事的:拉 /api/log,看 plot_done 的时间跨度应该 ≈ 任务总耗时;若 [1/20] 与 [20/20] 相差 。 还有一个通用建议:调试 SSE 页面时别用 `--virtual-time-budget`,连接永不空闲会挂死 Chrome。用 --timeout=N 或 --dump-dom。 ## 八、这些经验是怎么活到今天的 前面每一条坑,修完之后如果不固化,三个月后重踩一遍只是时间问题。所以我把整套流程和踩坑记录沉淀成了 Skill: ``` its-win32-ui-hanize-test 汉化质量验证全流程 + 长度补丁法 pointwin-plot 点对点自动绘图 + 14 条实测踩坑 voaarea-plot 区域覆盖绘图(自包含,可整目录拷到别的 PC) pointwin-gui HTML 界面版 + 13 条开发调试踩坑 ``` 之后的做法变成:踩坑 → 记进 Skill → 下次任务开始时 Skill 被自动加载 → 不再重复。 voaarea-plot 那一版是自包含的(脚本 + 建站模板都在里面),拷贝整个文件夹到目标机就能跑,目标机只要装好 ITSHFBC 套件 + Python 3.8 以上即可。已在异机路径、中文带空格路径下验证通过。 ## 九、几点方法论 这七条不是理论,是踩出来的。前面八节里的每个坑,基本都能对应到其中一条。 1. "间歇性"问题不能靠人工点点点收敛。 必须先把测试自动化到可复现,再谈定位。 1. 交互型程序的定位单位是"组",不是"串"。 单串二分在交互效应和状态文件干扰下会给你不可复现的假结论。 1. 找到肇事偏移只是止损。 我一开始以为定位到那条 push imm32 就结束了,后来才想明白:真正值钱的是搞清楚"这个 bug 的机制是什么"。机制清楚了(winio@ 的隐藏长度语义),才谈得上根治 —— 直接改那个长度立即数,一劳永逸,不用每次启动都打一遍补丁。 1. 不要赌。 计算引擎那部分我动摇过,想顺手一起汉化,理由是"源码都在反编译结果里"。但算了两笔账就放弃了:收益是几个没人看的菜单文案,风险是动错一个字节整个程序算错数还查不出来。不划算的活儿直接回退,这比"技术上能不能做"重要得多。 1. 老程序的反直觉行为必须记下来。 同一套 ClearWin+,Pointwin 吃 WM_COMMAND,Areawin 吃真实键盘导航。这种东西不写下来,下周自己都会忘。 1. 让程序自己干它擅长的事。 抓屏有 DPI 变形、裁切、缩放三重坑;to Clipboard 一个都没有。能用内置功能就别自己造轮子。 1. 静默失败比崩溃更可怕。 restype 截断、stdout 缓冲、端口重复绑定、任务字典被拆参数 —— 这四类 bug 的共同特征是"不报错,但结果不对"。有段时间我被 stdout 缓冲坑了很久,脚本明明 print 了,日志文件里就是空的。现在我的规矩是:日志必须带时间戳,验收看"值是否合理"而不是"有没有异常抛出来"。 ## 十、一句话总结 汉化本身是体力活,难的是把"人肉点点点"变成"可复现的自动遍历"这件事。而这件事最省力的路径,就是让 AI Agent 来写这些笨脚本、并且把每一次失败的诊断过程都吃进去 —— 它不会嫌你的二进制反汇编文档枯燥,也不会在第三次遇到同样的 ctypes 句柄截断时重新困惑一遍。 AI Agent 在这类"有明确验证标准 + 可以放心大胆试错"的任务上,产出效率是真的高。但在需要"我不确定这个判断对不对"的地方,还是要自己拍板。 欢迎交流。ITSHFBC 系列其他 EXE(Si_win / Hfantwin / Worldwin)的汉化还在推进中,如果你手上也有类似"老 Fortran + 私有 GUI 框架"的活儿,评论区聊聊你们的处理思路。