首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >我用 WorkBuddy 把一套 30 年前的 Fortran 老软件汉化了,还顺手做了一套自动回归

我用 WorkBuddy 把一套 30 年前的 Fortran 老软件汉化了,还顺手做了一套自动回归

原创
作者头像
用户12800207
发布于 2026-10-05 21:02:10
发布于 2026-10-05 21:02:10
290
举报

QA 工程师:日常做电信终端软件测试。

一、被汉化的目标是什么

ITSHFBC 是 ITU 的高频(HF)传播预测软件套件,HF 通信、频率规划、链路预算都会用到。它是 90 年代的遗产:

  • 计算内核:Salford FTN95 Fortran 编译
  • 界面:ClearWin+(ClearWin 自己的窗口框架,不是 Win32 原生控件)
  • 交付形态:一堆没有源码的 EXE

我手上要汉化的是这几个:

代码语言:python
复制
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       信号强度

目标很明确:中文菜单 + 中文对话框 + 不崩。前面两个都还好,第三个是深渊。

二、汉化之后为什么间歇性崩溃

翻译完第一次跑,看起来是好的。用着用着就弹窗:

代码语言:python
复制
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 一切正常。

所以必须换策略。

四、实际方案:自动化全菜单遍历 + 分组排除

核心思路:把定位单位从"单个串"换成"一组串"。

代码语言:python
复制
全菜单遍历 → 发现肇事菜单项 → 按对话框归组 → 排除整组 → 重建 → 复测该菜单项
→ 确认肇事 → 把偏移固化进 trans_exclusions.py → 全菜单遍历连跑两轮全 OK → 通过

工具脚本一共九个,都放在 hanization\ 下:

代码语言:python
复制
| 脚本 | 作用 |
|---|---|
| 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@ 调用,每个格式串参数是这么传进内核的:

代码语言:python
复制
; 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 的所有引用点
  2. 从引用点向前回扫 6~34 字节,找 68 xx xx xx xx(push imm32),取真实逻辑长度(值域 4~255)
  3. 写入 GBK 中文 + 空格填充到原逻辑长度,同时把 push 立即数改成译文的字节数

build_argmap.py 做第一步,deep_patch.py 做第三步。以 Areawin 为例,基础重建之后一共 23 条深度补丁。

五个必须遵守的约束

这几条都是踩出来的,每条都对应一次失败:

  1. 用空格填充,绝对不要用 `\x00` 填充。 \x00 会被读进格式串,直接破坏解析。
  2. 译文后面如果物理上紧跟固定地址参数(比如程序名),必须整串等长。 否则 '&' 之后会错位读出非法字符。
  3. GBK 次字节不能是 % & [ ] ^ @ * \ \ ,且 &` 后面不能紧跟中文。这几个字符是 ClearWin+ 格式串的元字符,落在多字节区会触发歧义解析。
  4. 链内没有 push 长度的段不能用此法。 有些段 LEA 有引用但回扫找不到长度 —— 这些只能老老实实回退成原英文。deep_patch.py 会自动标记并跳过,别硬来。
  5. 基础翻译表里不要写大参数串的内部子串。 翻译器做模糊匹配时很容易把 "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 压缩包):

代码语言:python
复制
# 快速调用: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。

![广州→北京 SNR 中位值](post_assets/cover_广州北京_SNR.png)

![青岛→上海 MUFday](post_assets/fig_青岛上海_MUFday.png)

MUF 那条蓝线(FOT)穿过整个彩色信噪比区的地方,就是最佳工作频率窗口——这条曲线是整个 VOACAP 的核心输出,也是我花时间最多的地方:让这玩意儿能被脚本驱动,且出图不带任何 DPI 变形和底部裁切。

图型必须两轮匹配

代码语言:python
复制
# ❌ 单轮子串匹配:'REL' 会先命中 '...reliab...'(RPWRG 行的描述文字)
# ✅ 先按 ^\s([A-Za-z]+)\s= 精确比对,再退化到子串匹配

这个 bug 的表现特别坑:"我要 REL 结果出了 RPWRG",日志里还显示操作成功。

另外,Pointwin 的参数列表框实测只有 20 项:

代码语言:python
复制
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,不报错:

代码语言:python
复制
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 字符就会截掉尾字符,报错长这样:

代码语言:python
复制
In READLIST, could not open=...\ITSHFBDATABASE\METHOD.DAT
                  ^^^^^^^^^^^^^^^^^^^^^^

注意 itshfbc 和 database 之间没有分隔符,还少了末尾的 c —— 这就是"截断 + 不加分隔符"的双重特征。症状是启动阶段主窗口根本不出现,引擎卡在 Pause 等回车。

含空格也是死:D:\cp test_itshfbc 失败,D:\CPTEST~1 正常。症状是进程在,但"指定HF传播模型"对话框永不出现。

统一解法:无条件转 8.3 短路径,一次解决超长和空格两个问题:

代码语言:python
复制
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}),子进程里却写了这么一行:

代码语言:python
复制
# ❌ 顶层无参数重包 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
复制
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] 相差 < 0.1 s,直接确诊。

另外三个让我安静/debug 很久的坑

  • `threading.Thread(target=f, args=dict)` 会把 dict 拆成多参数 → TypeError 且线程静默死亡,而 /api/run 照样返回 ok。args 必须是元组 (x,)。后台线程的异常不会传给请求方,返回 ok ≠ 任务启动。
  • Windows 端口占用不能靠"绑定失败"判断。HTTPServer.allow_reuse_address=1 在 Windows 上等价 SO_REUSEADDR,允许重复绑定同一端口且不报错 —— 新服务绑上了但连接全被旧服务接走。netstat -ano 会看到两个 PID 同时 LISTENING 8765。表现是"我改了代码但界面没变化"。正解:绑之前 socket.connect_ex(('127.0.0.1', port)) == 0 先探测。
  • URL 里的中文文件名必须 `unquote()` 后再 `os.path.basename`,否则 404。另外无头 Chrome 截图本机 --headless=new 直接被 SIGTERM,得用 --headless=old --no-sandbox --user-data-dir=<独立目录>。

还有一个通用建议:调试 SSE 页面时别用 `--virtual-time-budget`,连接永不空闲会挂死 Chrome。用 --timeout=N 或 --dump-dom。

八、这些经验是怎么活到今天的

前面每一条坑,修完之后如果不固化,三个月后重踩一遍只是时间问题。所以我把整套流程和踩坑记录沉淀成了 Skill:

代码语言:python
复制
its-win32-ui-hanize-test   汉化质量验证全流程 + 长度补丁法
pointwin-plot              点对点自动绘图 + 14 条实测踩坑
voaarea-plot               区域覆盖绘图(自包含,可整目录拷到别的 PC)
pointwin-gui               HTML 界面版 + 13 条开发调试踩坑

之后的做法变成:踩坑 → 记进 Skill → 下次任务开始时 Skill 被自动加载 → 不再重复。

voaarea-plot 那一版是自包含的(脚本 + 建站模板都在里面),拷贝整个文件夹到目标机就能跑,目标机只要装好 ITSHFBC 套件 + Python 3.8 以上即可。已在异机路径、中文带空格路径下验证通过。

九、几点方法论

这七条不是理论,是踩出来的。前面八节里的每个坑,基本都能对应到其中一条。

  1. "间歇性"问题不能靠人工点点点收敛。 必须先把测试自动化到可复现,再谈定位。
  2. 交互型程序的定位单位是"组",不是"串"。 单串二分在交互效应和状态文件干扰下会给你不可复现的假结论。
  3. 找到肇事偏移只是止损。 我一开始以为定位到那条 push imm32 就结束了,后来才想明白:真正值钱的是搞清楚"这个 bug 的机制是什么"。机制清楚了(winio@ 的隐藏长度语义),才谈得上根治 —— 直接改那个长度立即数,一劳永逸,不用每次启动都打一遍补丁。
  4. 不要赌。 计算引擎那部分我动摇过,想顺手一起汉化,理由是"源码都在反编译结果里"。但算了两笔账就放弃了:收益是几个没人看的菜单文案,风险是动错一个字节整个程序算错数还查不出来。不划算的活儿直接回退,这比"技术上能不能做"重要得多。
  5. 老程序的反直觉行为必须记下来。 同一套 ClearWin+,Pointwin 吃 WM_COMMAND,Areawin 吃真实键盘导航。这种东西不写下来,下周自己都会忘。
  6. 让程序自己干它擅长的事。 抓屏有 DPI 变形、裁切、缩放三重坑;to Clipboard 一个都没有。能用内置功能就别自己造轮子。
  7. 静默失败比崩溃更可怕。 restype 截断、stdout 缓冲、端口重复绑定、任务字典被拆参数 —— 这四类 bug 的共同特征是"不报错,但结果不对"。有段时间我被 stdout 缓冲坑了很久,脚本明明 print 了,日志文件里就是空的。现在我的规矩是:日志必须带时间戳,验收看"值是否合理"而不是"有没有异常抛出来"。

十、一句话总结

汉化本身是体力活,难的是把"人肉点点点"变成"可复现的自动遍历"这件事。而这件事最省力的路径,就是让 AI Agent 来写这些笨脚本、并且把每一次失败的诊断过程都吃进去 —— 它不会嫌你的二进制反汇编文档枯燥,也不会在第三次遇到同样的 ctypes 句柄截断时重新困惑一遍。

AI Agent 在这类"有明确验证标准 + 可以放心大胆试错"的任务上,产出效率是真的高。但在需要"我不确定这个判断对不对"的地方,还是要自己拍板。

欢迎交流。ITSHFBC 系列其他 EXE(Si_win / Hfantwin / Worldwin)的汉化还在推进中,如果你手上也有类似"老 Fortran + 私有 GUI 框架"的活儿,评论区聊聊你们的处理思路。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、被汉化的目标是什么
  • 二、汉化之后为什么间歇性崩溃
  • 三、为什么不能继续靠人工点
  • 四、实际方案:自动化全菜单遍历 + 分组排除
  • 五、根治:长度参数补丁法
  • 五个必须遵守的约束
  • 六、汉化测试里的几个坑
  • 七、附带产出:一个真跑得起来的 GUI 工具
  • 图型必须两轮匹配
  • 截图方式的结论:能用程序自己的功能就别抓屏
  • 一个 64 位句柄被截断的坑
  • 目录名里的一个隐蔽 47 字符限制
  • 一个"改动不生效"改了三天的 bug:stdout 缓冲
  • 另外三个让我安静/debug 很久的坑
  • 八、这些经验是怎么活到今天的
  • 九、几点方法论
  • 十、一句话总结
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档