Unity 汉化流水线(含自审计)
把「提取 → 风险判定 → 翻译 → 写入 → 自审计」做成可复用的四阶段流水线。 核心不是翻译本身,而是先判断哪些串不能翻——这是唯一会把游戏改崩的地方。
什么时候用它
用之前先确认三条,任一条不成立就不用往下走:
- 文本在哪:
Mono/IL2CPP的 Unity 游戏,文本硬编码在托管程序集(*_Data/Managed/Assembly-CSharp.dll)或resources.assets里,没有Localization表、.po、.csv之类的本地化资源。 若有现成本地化表,直接用表,别改程序集。 - 能拿到原始文件:改的是可执行文件,必须有一份未被修改过的原件做基线(备份目录、Steam 校验后重下的都行)。没有基线就不要做原地修改。
- 中文能显示:项目内嵌的是动态字体(TMP 或 Legacy
Font)。静态字体/图集字体的中文需要换贴图,本流水线只处理动态字体。
IL2CPP(无 Assembly-CSharp.dll,只有 GameAssembly.dll)不在支持范围,需要 Il2CppDumper + 重编译,另说。
快速开始
# 0) 建项目骨架(生成 l10n.json)
python <skill>/scripts/zhl10n.py init -C 你的项目目录
# 1) 清点全部 ldstr 站点(约 10s / 5000 条)
python <skill>/scripts/zhl10n.py extract
# 2) 看风险分级:哪些能翻、哪些只能定向翻、哪些绝对不能翻
python <skill>/scripts/zhl10n.py risk
# 3) 填 trans_map.json / site_map.json,然后写入(不改原件,输出到 out/build/)
python <skill>/scripts/zhl10n.py apply
# 4) 自审计(默认读 out/build 产物)
python <skill>/scripts/zhl10n.py audit --fail-on P1
一条龙:zhl10n.py all(extract → apply → audit)。
装依赖:pip install dnfile dncil UnityPy fonttools。只跑 audit 不装 UnityPy 也能用。
各环节职责
| 阶段 | 入口 | 输入 | 输出 | 责任边界 |
|---|---|---|---|---|
| init | zhl10n.py init | 目标游戏路径 | l10n.json、空表 | 只生成配置,不碰游戏文件 |
| extract | extract.py | 原始 DLL(+ 可选 assets) | out/extract/sites.jsonsites.json 每项含 str/type/method/token/offset | 只读。必须读未打补丁的原件,否则会提取到已汉化的串 |
| risk | risk.py | sites.json | 控制台分级统计 | 只判定,不修改任何表 |
| apply | apply.py | 原始 DLL + 翻译表 + 字体子集 | out/build/Assembly-CSharp.dllout/build/resources.assets | 写新文件,原件不动(--inplace 才原地改,会先留 .orig)。任何命中 block 的译文拒绝写入并报错 |
| audit | audit.py | 产物 + 翻译表 + 术语表 + 日志 | out/audit/audit.{json,md,html}fixlist.csv | 只读。可对任意版本的文件跑,用于新旧对比 |
三层翻译表,职责各不相同:
trans_map.json—— 全局表,按字符串替换所有出现位置。只放「翻成中文不会破坏逻辑」的串。site_map.json—— 定向表,键是类型::方法,只改该处的ldstr。给「同一个词在菜单里该翻、在输入系统里不能翻」这种情况用。enum_map.json—— 枚举名表,改的是#Strings堆里的字段名。给ToString()出来的枚举显示名用。
判定顺序:定向 → 全局。定向命中就不再走全局。
自审计
自审计是这套流水线的重点。它在写入之后独立读产物,不信任翻译表自己的说法。
输出
| 文件 | 内容 |
|---|---|
audit.json | 完整结构化结果:每条 finding 含 check/severity/count/items[]/hint |
audit.md | 人读摘要,按 P0→P2 排序 |
audit.html | 可折叠的单文件报告 |
fixlist.csv | 需修复项清单,一行一条,可直接当待办表用 |
检查项(31 项,6 族)
CV 完整性——有没有漏
| 编号 | 级别 | 检查 | 判定依据 |
|---|---|---|---|
| CV1 | P1 | 未翻译文本 | 站点可显示 + 两种表都无译文 + 未被风险规则放行 |
| CV1b | P2 | 形态像界面文本但被拦下 | 说明是引擎约束,不是漏译,需定向翻译并实测 |
| CV2 | P1 | 空译 / 占位译 | 译文为空、含 TODO/待译 |
| CV3 | P1 | 长句译文与原文完全一致 | 疑似根本没翻 |
| CV4 | P2 | 短词/符号译文与原文一致 | 图标字、按键名保留原文通常正确 |
| CV5 | P2 | 定向表死条目 | 声明了但原版 IL 里找不到,方法改名或已被全局表覆盖 |
CS 一致性——术语对不对得上
| 编号 | 级别 | 检查 |
|---|---|---|
| CS1 | P2 | 同一原文在全局表与定向表译法不同 |
| CS2 | P1 | 术语表强制译法未遵守 |
| CS3 | P1 | 译文命中术语表禁用词 |
| CS4 | P2 | 重复键(仅大小写/空白不同) |
| CS5 | P2 | 中文标点风格偏半角 |
FM 格式——拼接会不会坏
| 编号 | 级别 | 检查 | 后果 |
|---|---|---|---|
| FM1 | P1 | 占位符不守恒 | String.Format 抛异常或数字消失 |
| FM1b | P2 | 移除英文复数占位符 | 正确做法,仅提示确认没误删真参数 |
| FM2 | P2 | 换行数量不一致 | 排版错位 |
| FM3 | P1 | 单位/拼接片段丢失首尾空格 | 1000coins 粘连 |
| FM4 | P2 | 其它首尾空格变化 | 中文之间通常无所谓 |
| FM5/FM6 | P2 | 半角标点 / 连续空白 | 观感 |
| FM7 | P2 | 译文长度膨胀过大 | 多为把说明写进了标签 |
RK 风险——会不会把游戏改崩
| 编号 | 级别 | 检查 |
|---|---|---|
| RK1 | P0 | 高风险字符串被纳入翻译表(路径、引擎键、正则、输入动作名…) |
| RK2 | P2 | 全局表内含单标识符,建议改定向 |
| RK3 | P1 | 翻译表 key 含中文,二次应用会错乱 |
| RK4 | P2 | 译文残留英文片段 |
IG 结构——文件本身是否还合法
| 编号 | 级别 | 检查 |
|---|---|---|
| IG0/IG1/IG2/IG4 | P0 | 目标文件缺失、PE/元数据异常、#US 无法解析、ldstr token 非法、元数据行数与原版不一致 |
| IG3 | P2 | 补丁后仍为英文的站点(预期内的白名单,用于核对) |
| IG5/IG6 | P1 | 字体缺字 / 字体文件无法解析 |
RT 运行——游戏自己招了没
| 编号 | 级别 | 检查 |
|---|---|---|
| RT1 | P1 | Player.log 里出现异常堆栈(需要 source.logs 配置) |
触发方式
三种,按场景选:
# 1. 写完立刻自检(开发循环里最常用)
zhl10n.py audit
# 2. 当门禁用:CI 或发布前,不达标直接非零退出
zhl10n.py audit --fail-on P0 # 只要有阻断级就失败
zhl10n.py audit --fail-on P1 # 默认严格度,发布建议用这个
zhl10n.py audit --fail-on P2 # 连提示都要清
# 3. 回归对比:这次改完比上次多了哪些问题
zhl10n.py audit --baseline
apply 结束时也会自动跑一次 audit,把结论打印出来——写入阶段不会在不知情的情况下产出坏包。
怎么用审计结果
- 先看
P0。任何一条 P0 都不许发布,它等于「游戏可能起不来」。 - 再看
P1。CV1是漏译清单,FM1是必崩清单,RT1是已经崩了的证据。 P2当核对表:CV1b告诉你哪些是引擎硬约束而不是你漏了,CV5/CS4是可以直接删的垃圾条目。fixlist.csv直接对着一行行改,改完重跑,直到P0=0 且 P1=0。
配置(l10n.json)
{
"project": "游戏名", "version": "1.0",
"source": {
"dll": "…/Managed/Assembly-CSharp.dll", // 只用于定位,extract 会读 backup
"backup": "…/_Backup/Assembly-CSharp.dll", // 必须是未修改的原件
"assets": ["…/resources.assets"],
"logs": ["…/Player.log"] // 可选,供 RT1
},
"output": { "dir": "out" },
"text": { "map": "trans_map.json", "site_map": "site_map.json", "enum_map": "enum_map.json" },
"glossary": "glossary.csv",
"font": { "subset": "font/zh-subset.ttf", "source": "font/NotoSansSC-Bold.otf", "targets": {} },
"audit": { "severity_fail": "P1", "max_expansion_ratio": 2.5, "max_expansion_abs": 40 },
"risk": { "extra_patterns": [], "extra_allow": [] }
}
两个逃生口,遇到报错先想它们:
risk.extra_patterns—— 项目里已知不能翻的串(正则)。例:^Levels$risk.extra_allow—— 已确认不必翻的具体串。加进去就不算漏译
术语表 glossary.csv:en,zh,forbidden 三列,forbidden 用 | 分隔同义词。CS2/CS3 依赖它,没有则跳过这两项。
判定规则:哪些串绝对不能翻
一句话版:在代码里当「键」用的串,翻了就找不到东西。
| 类别 | 例子 | 翻了会怎样 |
|---|---|---|
| 资源路径 | "Levels"、"UI/Fonts/X" | 关卡表空 → 存档恢复 NRE → 卡加载界面 |
| 输入动作/字段名 | Move/Up/Button | 键位绑定失效 |
| 存档字段 | legacySkillPointCredit | 存档读不出来 |
| 枚举名 | Deposit(ToString() 之外还有 Enum.Parse) | 解析失败 |
| 正则模式 | ([0-9.]+)% more coins | 提示文本改写失效,退回英文 |
| 对象/资源名 | new GameObject("Display glass") | 通常不崩,但会污染资源名 |
| 格式串 | {0:N0} / {1:N0} | String.Format 异常 |
同形不同命:Pause 在设置菜单是按钮文字(该翻),在 GameInput 里是动作名(不能翻)。
靠文本形态永远分不出来,只能靠「出现在哪个类型、哪个方法」。这是 risk.py 的 _code_shape() 在做的事:
- 建对象的方法(
.ctor/Build/Initialize/Create/Ensure…)里的字面量 → 名字,不翻 - 画界面的方法(
Draw/OnGUI/Format/get_…)里 → 文案,翻 - 比对型方法(
Is/Has/Matches/Find…)里 → 拿去匹配的键,不翻
加了新规则后必须用负向测试验证:往翻译表里故意塞一条 "Levels": "等级",确认审计报 P0。
字体的坑
内嵌动态字体只能等长原位替换。整体重写 resources.assets 会改变 Texture2D 的 m_StreamData 偏移,贴图全错位。
流程:font 阶段按「实际用到的汉字集合」子集化,转成 TTF(Unity 对 CFF/OTF 支持不稳),再填进 m_FontData。字形数不够就减字号或压字重,不能超原尺寸。
Bangers-Regular 这类只有几十 KB 的字体放不下中文——把字体路径重定向到另一个中文字体(在 l10n.json 的 font.targets 里配),比硬塞可靠。
交付前清单
[ ] audit --fail-on P1 通过
[ ] 实际启动游戏,逐屏走一遍:主菜单 / 设置 / 存档列表 / 游戏内 HUD / 弹窗 / 报错
[ ] Player.log 无新异常(RT1 为 0)
[ ] 备份原件位置写进发布说明(还原就靠它)
[ ] 记录产物 MD5,写进版本说明
改坏了的定位方法:先只换回原 DLL、保留其他,若正常则问题一定在程序集补丁;再只换 DLL、保留其他,若复现则确认。二分比读代码快。
参考文档
references/flow.md—— 每个阶段的详细实现与数据结构references/audit-spec.md—— 31 项检查的完整定义、级别、修复指引references/risk-rules.md—— 风险规则表与它背后的真实事故assets/l10n.example.json/assets/glossary.example.csv—— 配置模板