- 桌面应用
【免费下载链接】BongoCat
🐱 BongoCat — A cross-platform interactive desktop pet that brings fun to your desktop!
本文以 BongoCat 仓库的架构决策记录 docs/adr/0050-converted-key-image-name-spelling.md 为骨架,结合bongocat-model-store、bongocat-live2d-render、bongocat-live2d等 crate 的源码与测试,完整还原一次真实发生的"拼写漂移"事故及其修复方案。读者将掌握:BongoCat 键位图命名体系(转换词汇表 / 运行时候选 / 导入归一化)三者如何协同、程序化 diff 与变异验证如何把"用户发现的 bug"变成"测试发现的 bug",以及这套"可遍历契约 + 双重断言"的防漂移方法论。
一、背景:一次只差大小写的命名漂移
BongoCat 支持在应用内导入上游Bongo-Cat-Mver模型(ADR-0037)。转换过程会把旧版 Windows 虚拟键码翻译成产品自己的键位图文件名——这套名字必须是bongocat-live2d-render从 HID usage 解析出的那套词汇表(KeyA、Enter、BackSlash……),而不是第二套命名。
ADR-0049 提出了一个可检验的要求:"键位事件、键名、资源名称、图片回退、BongoCat 模型导入与 Mver 转换之间的映射完全一致"。当开发团队真的把这句话做成程序化检查时,发现两张表各自演化出了一处拼写漂移,其中键盘侧恰好漏了一个名字——Backslash(小写 s)对不上产品词表的BackSlash(大写 S)。
转换侧(mver 词汇表):0xDC => Some("Backslash") 产品侧(运行时词表): 0x31 => Some("BackSlash")这个差异肉眼几乎看不出来,但它造成的后果是实打实的:转换安装的Backslash.png从功能上线起就永远选不中,该键完全不产生任何动作——不画图、爪子也不下压(按 ADR-0042 的缺图规则)。
二、四个决定性问题形态的事实
事实 1:漂移是程序化 diff 找出来的,不是肉眼看到的
用正则分别抽出mver::legacy_virtual_key_name(转换侧)与live2d::key_name_candidates(运行时侧,含KEY_LETTERS、KEY_NUMBERS、KEYPAD_DIGIT_NAMES、bongocat-render::FUNCTION_KEY_NAMES)里的所有字符串字面量求差集:114 个转换输出名里恰好一个漏网——Backslash。
事实 2:漏网的那个是死键
mver.rs的0xDC => Some("Backslash")是照抄参考工具BongoCat-Converter/src/utils/keyMap.ts的220: "Backslash",而产品词表是0x31 => Some("BackSlash")。按 ADR-0042 的缺图规则,该键完全不产生动作。这与 ADR-0038 记录的AltGr是同一类错误——不过AltGr好歹被运行时候选兜住了,Backslash则没有任何兜底。
事实 3:只差大小写的改名在大小写不敏感的文件系统上是空操作
macOS 与 Windows 都不区分路径大小写,所以Backslash.png→BackSlash.png的"改名"里destination.exists()恒为真(目标其实就是同一个文件),归一化直接跳过,包保留旧拼写。真正让这类包能画的是运行时候选别名;归一化只在大小写敏感的文件系统(非首发平台)上才真正改名。这一点在源码里有直接体现:
crates/bongocat-model-store/src/key_names.rs的is_same_file用fs::canonicalize判断"左右是否同一个文件"——大小写不敏感的文件系统对DPadUp.png与DpadUp.png会回答true;- 同文件的
rename_case_only因此必须经过一个临时兄弟名(.bongocat-key-image-{legacy}.png)中转两次rename,才能在所有平台上真正改变目录条目的拼写(key_names.rs)。
事实 4:只断言"能解析"的契约测试挡不住这类漂移
一旦别名存在,旧拼写也能解析,于是"转换输出能解析"这条断言在漂移回退时仍然通过。变异验证证实了这一点:先把BackSlash改回Backslash,只查"能解析"的版本没有变红——说明断言没有牙齿。
三、决策 1:转换输出改产品拼写
legacy_virtual_key_name的0xDC输出BackSlash。这与 ADR-0038 的Alt/AltGr→ 产品名、ADR-0039 的Return→Enter是同一套做法:转换写产品词汇表,旧拼写由运行时候选兜底。
源码中这一改动的落点与注释见 vocabulary.rs:
// The legacy chart spells this key `Backslash`; the product's runtime // and its shipped presets spell it `BackSlash`. The product spelling // wins, the same way `Backspace` beats the chart's `BackSpace` above, // so the image this installs is the one the resolver looks up. 0xDC => Some("BackSlash"),注意0x08(退格键)是同类处理的老例子:参考表写BackSpace,产品运行时与预置模型都写Backspace,同样采用产品写法(vocabulary.rs)。
四、决策 2:运行时候选把旧拼写加成末位别名
canonical 在前、旧拼写垫底:
0x31 (BackSlash) → BackSlash, Backslash运行时源码中的实际候选列表见 lib.rs:0x31的 exact 名是BackSlash(同文件 L328),随后在"pre-rename 拼写"分支把Backslash追加为末位候选。注释说明了原因:旧拼写与 canonical 只差大小写,且 macOS/Windows 路径不区分大小写,所以已安装包里的旧拼写文件会一直保留——KeyImageInventory::provides比较的是文件的真实主干,只有这条候选能让它画出来。
这样,已经安装过、不会被重新归一化的包(无迁移路径)仍然能画;而新导入的包归一化后带 canonical 名,命中列表第一项。
五、决策 3:导入归一化登记该拼写
LEGACY_KEY_IMAGE_NAMES加入("Backslash", "BackSlash")。这个表在源码中是一个 15 项的常量数组,见 key_names.rs:
const LEGACY_KEY_IMAGE_NAMES: [(&str, &str); 15] = [ ("Alt", "AltLeft"), ("AltGr", "AltRight"), ("Return", "Enter"), ("Function", "Globe"), ("Backslash", "BackSlash"), // 本次新增 // ... gamepad 半区:LeftTrigger→LeftShoulder、DPadUp→DpadUp 等 ];按事实 3,这条登记在两个首发平台(macOS/Windows)上通常是空操作;它的价值在于让"旧拼写 → canonical"这件事只有一个来源,而不是散落在运行时别名里。归一化发生在 store 自己的 staging 副本上、commit_installed_staging之前——用户选中的源目录始终只读,改名不改变文件数与字节数。
配套测试a_case_only_legacy_stem_is_renamed_to_its_canonical_spelling同时覆盖了Backslash→BackSlash与DPadUp→DpadUp两条"只差大小写"的改名路径,断言它们变成真实的目录条目(key_names.rs)。
六、决策 4:把"转换能安装的名字"做成可遍历契约,并同时断言两件事
这是本次修复最核心的工程措施,分两部分:
契约左半:转换侧输出集合可遍历。bongocat_model::legacy_keyboard_key_image_names()遍历两种键盘模式的全部虚拟键码(0..=0xFF),返回去重排序后的输出名集合(vocabulary.rs)。手柄按钮名不在其中——它们是 gamepad 模型的美术名,不进入键位图(ADR-0037);地球键也不在,因为旧码空间里根本没有它的码,转换永不产出Globe.png。
契约右半:运行时断言双重条件。bongocat-live2d的测试every_key_image_name_the_conversion_can_install_resolves_to_a_key对每个输出名断言既能解析、又是 canonical 名(即某个可达 usage 的候选列表第一项),只查前者不够(事实 4)。测试实现见 tests/vocabulary.rs:
const FAMILY_NAMES: [&str; 2] = ["Shift", "Control"]; // 1) 每个转换输出名必须出现在某个 usage 的候选集合里(可解析) // 2) 每个转换输出名必须是某个候选列表的第一项(canonical), // 或属于显式例外 FAMILY_NAMES // 3) 反向断言:例外列表里的名字目前确实没有 canonical 拼写, // 否则列表变成死代码、测试先变红提醒删除Shift与Control是有意的非 canonical 名:旧表给两侧同一个码(ADR-0038 决策 4),转换输出家族名、运行时为两侧解析家族图。它们被显式例外列表记录,并断言"这两个例外仍然必要",避免列表悄悄变成死代码。
测试文档注释还点明了双重断言的动机:Backslash正是第二种失败——"只有别名能救回它",因为别名会让旧拼写一直可达,漂移就能无声地回来。canonical 断言堵死了这条路。
七、明确不做
- 不把游戏按钮名纳入该契约:它们不进入键位图解析器(ADR-0037)。
- 不新增第三套拼写(如
BackSlashKey):ADR-0038 已经明确不做同类事。 - 不把
Shift/Control展开成两侧:那会同时作废 ADR-0037 记录的真实样本转换证据(真实样本bongo_cat_mver_0.1.6_64的转换结果left-keys{Control, KeyR, Shift}是既有的验证证据)。
八、残余风险与待验证项(文档明示不得当作已确认)
- 已安装的旧包靠别名工作,磁盘上没有被重写:
Backslash是运行时候选表的一部分,属于永久词汇。 - 例外列表是硬编码的两个名字:如果将来
Shift/Control有了 canonical 拼写,例外断言会先变红提醒删除,但删除本身是人工动作。 - 该漂移没有真实样本覆盖:仓库里唯一真实 Mver 样本(
bongo_cat_mver_0.1.6_64)的键位表不含0xDC,这条路径只有合成测试。 - 大小写不敏感文件系统上的行为只在 macOS 上验证过:Windows 侧同样是大小写不敏感,但没有实机跑过这条归一化。
九、验证:先红后绿 + 变异验证
修复过程遵循严格的测试驱动流程(2026-09-19,本机 macOS / aarch64):
- 先红后绿:修复前
every_key_image_name_the_conversion_can_install_resolves_to_a_key变红并精确报出no key resolves these conversion outputs: ["Backslash"];修复后绿。 - 变异验证(关键):把
0xDC改回Backslash后该测试变红(exit 101),证明断言有牙齿;同时确认了"只断言能解析"的初版不会变红,因此把断言加强为"同时是 canonical 名"。 bongocat-model-store新增the_conversion_emits_the_product_spelling_for_every_key_image:对BackSlash/Backspace/Enter/AltLeft/AltRight成对断言"新拼写出现、旧拼写不出现",并断言输出名都是合法资源主干(纯 ASCII 字母数字,不含路径分隔符),见 tests/vocabulary.rs。bongocat-model-store新增a_case_only_legacy_stem_keeps_its_artwork_under_the_canonical_name:断言 canonical 名可读到该美术(不断言旧名消失——在大小写不敏感的文件系统上它本来就不会消失)。cargo test --locked -p bongocat-model-store -p bongocat-live2d全绿。
未运行:真实 Mver 样本回归(样本键位表不含0xDC)、Windows 实机归一化路径。
十、方法论沉淀:这次事故教会了我们什么
把 ADR-0050 放进 BongoCat 键位命名体系的整体上下文(ADR-0037、ADR-0038、ADR-0039、ADR-0041、ADR-0042、ADR-0049)可以提炼出四条可复用的经验:
- "能解析"不等于"能命中"。解析断言只验证了旧拼写仍可达,而漂移的伤害恰恰在"可达但永远不是首选"。契约断言必须同时验证 canonical 位置。
- 程序化 diff 优于肉眼审查。114 个名字里找一个拼写漂移,正则抽字面量求差集是唯一可靠的手段;这也意味着每张映射表都应是可遍历的(
legacy_keyboard_key_image_names正是为此而生)。 - 变异验证给断言装上牙齿。把实现改回错误状态、断言测试变红,是确认"测试真的在守护这条不变量"的最低成本手段。
- 大小写不敏感文件系统会吞掉"改名"。凡是只差大小写的旧名(
Backslash/DPadUp),归一化必须走临时兄弟名中转;凡是这类包,运行时候选是唯一的兜底——两条路径缺一不可。
这套"转换写产品词汇、运行时候选兜底、导入归一化单源登记、可遍历契约双重断言"的体系,就是 BongoCat 键位图命名不再漂移的完整答案:下次漂移会被测试发现,而不是被用户发现。
- 桌面应用
【免费下载链接】BongoCat
🐱 BongoCat — A cross-platform interactive desktop pet that brings fun to your desktop!
相关推荐
GLM-5.3 vs Kimi K3 vs DeepSeek-V4 Pro:2026开源大模型SOTA之争终极对比(13项基准)
GLM 5.3 vs Kimi K3 vs DeepSeek V4 Pro:2026开源大模型SOTA之争终极对比(13项基准) 本文用 13 项核心基准,对
桌面应用LeetCode 1579 解析:双 Union-Find + 贪心边序,求图完全可遍历时可移除的最大边数
LeetCode 1579 解析:双 Union Find + 贪心边序,求图完全可遍历时可移除的最大边数 本文围绕 leetcode 仓库中的 1579 题解
ruflo-federation 插件契约解析(ADR-0001):3-Gate 对齐、ADR-097 预算熔断、命名空间协调与 Smoke-as-Contract 工程实践
ruflo federation 插件契约解析(ADR 0001):3 Gate 对齐、ADR 097 预算熔断、命名空间协调与 Smoke as Contra
人工智能AI Agent多智能体Agent 编排Agent 记忆工具调用代码智能体MCP 服务AI 评测
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考