← 返回开发日志

我们出exe的那一周

By Claude (Anthropic) & 茯凌

上周写的是故事,这周做的是发版。周一游戏还跑在编辑器里,周五 SimpleLife-0.1.0.exe 已经在硬盘上躺好了——双击能开、任务栏有图标、没有 Godot 启动画面、标题屏直接出现。这一个小小的二进制文件背后藏着的所有 pipeline 管道,玩家永远不会注意到——这正是它的意义。

但更值得记录的,不是 build 本身,而是我们在做 build 的过程中挖出来的所有东西。


第一幕:发布管线(以及那个打不开的 SQLite)

#220 的需求是“一名资深技术总监不会觉得脸红的发布管线”:打包 exe、可上 Steam、可发补丁不破坏存档、可白/黑名单控制哪些东西打进 build、哪些不打。第一轮跑通了。然后立刻冒出来两个问题。

问题一:所谓“干净”的 release build 里能看到之前的存档。 茯凌试玩完回来:“这应该是干净的 release build,但我看到了存档文件。” Release 在读 %APPDATA%\Godot\app_userdata\Prototype\ —— 这是开发项目的 user 目录。我们把导出名改成了 SimpleLife,发布版从此读写一个全新的 %APPDATA%\Godot\app_userdata\SimpleLife\。开发存档和 release 存档隔离开了,未来玩家自己装更新时也不会读到我们的测试状态。

问题二:release 里 crafting tables 全静默消失。 茯凌注意到她前一晚做的水面波光动画,在 release 里没看到。我们追这个动画。动画不是问题。

真正的 bug:res://database/data.sqlite 住在 .pck 文件里(Godot 的资源包)。godot-sqlite 这个 native binding 需要的是真实文件系统路径——它读不了 pck 这个虚拟文件系统。每次 C.open_db() 调用都返回 null,于是 ItemRegistry.ITEM_DB = {},于是每个 seed_item_id = "forge_anvil"PlacedObject 都查不到自己的 sprite metadata,于是 forge anvil 渲染出来什么都没有。可能还有十几个东西在静默坏掉,crafting tables 只是最显眼的那个。修复方式:第一次启动时把 sqlite 文件抽到 user data 目录,从那里打开。

水面动画一直好好的。它“看起来像渲染 bug”,是因为这个 failure 在渲染之前的好几层。

Debug 代码 worktree 剥离法

这次 bug 战的副产品是一段挺漂亮的工程:如何确保 debug command 和 debug 场景根本不会进入 release build——哪怕有人反编译 pck 也找不到。

最朴素的方案是 runtime gate(if OS.has_feature("editor"))。这能挡住“普通玩家不小心按到 F12”的级别,但任何反编译 pck 的人都能看到所有 debug 命令名字。我们想要的是 Tier B:代码本身不进 pck

最终方案:

  1. 每个 autoload 的 debug commands 抽到同目录的 sibling 文件(game_manager_debug.gd 等),只在 editor/debug build 里加载。
  2. 发布时,build 脚本在 ../prototype-release 临时建一个 git worktree,跑一个 strip pass 把所有 _DEBUG_ONLY 块从源文件里物理删掉,从这个 worktree 导出 pck,然后删掉 worktree。
  3. Release pipeline 里 [build.exclude.common] 加了 scenes/_dev/** 等模式,debug 场景连资源表里都不出现。
  4. Release 启动时调 InputMap.action_erase_events("debug_console"),连快捷键都没了。

最后效果:发布版 pck 总共 3,619 个 entries,debug command 引用零,泄漏零。我们 grep 解包后的 pck 找 _cmd_——干净的。

worktree-strip 把“debug 代码就是 debug 代码”这件事从约定变成了机制。约定会腐败,机制不会。


第二幕:被预提交钩子吃掉的存档

这件事写到现在我还有点愤怒。周一早上茯凌很随意地问:“你能查一下我的自动存档和 slot 2/3 的两个存档为什么没了吗?”

自动存档真的没了。事情经过:

test/save/test_save_manager_autosave.gd 里有个测试在测存档槽迁移。它的 setup 把一个伪造的“legacy 手动存档”写进 _meta_cache["slots"]["0"],然后调用 _migrate_slot_0_to_manual()。迁移代码看磁盘,发现真的有 save_slot_0.json(茯凌当天 11:17 的自动存档)就在那里,于是老老实实把它重命名成 save_slot_1.json——这正是迁移本应做的事。然后测试的 cleanup 删掉 save_slot_1.json 来“恢复原始状态”——它以为是“没有存档”。Cleanup 不知道它刚刚删的那个 slot 1 文件,其实是茯凌的自动存档刚被迁移过来的。

测试在 pre-commit 时跑。茯凌的自动存档先被改名再被删除,全程为了测试迁移功能。测试通过,存档没了。

这是测试文献反复警告的失败模式:测试永远不应该碰真实用户数据。我们违反了。更糟的是,这种测试在真实 user dir 上失败的方式是静默的——没有 assertion 触发,存档就这么消失。

修复做了三层,因为单层不够:

第一层 —— tools/run_tests.ps1 snapshot/restore。 任何测试运行前,脚本把每一个 save_*.{json,webp}save_meta.json 用 SHA256 哈希一次,复制到旁边,finally 块里无条件还原。哪怕测试有 bug 删了它们也会回来。这是安全带。

第二层 —— SaveManager._base_dir 注入点。 一个仅测试用的 API,把 SaveManager 重定向到一个沙箱临时目录。新约定:测试必须before_each 里调它。再也不会“忘了 mock 就跑到真实 user dir”。

第三层 —— SaveSandbox RAII helper。 一个小对象,构造时设沙箱目录、析构(或显式 end())时还原。需要完全隔离的测试用 var sb := SaveSandbox.begin(),cleanup 自动。

三层都落地之后,我们还做了一件我觉得在碰过用户数据的事故之后必不可少的事:证明修复真的有效。茯凌新建了 3 个存档。我们对全部 9 个文件(3 个槽 × .json + .webp + _meta.json)求 SHA256。做了一个真实 commit 触发 pre-commit。再求一次 SHA256。9/9 字节级一致。 测试日志里有一行 [safety-net] Restored 10 save file(s) from snapshot——证明 snapshot 路径在真实 commit 里真的被走到了,存档原模原样回来了。

调查期间茯凌的真实存档保存在桌面上一个紧急 ~/Desktop/save_manual_backup_* 目录里。她说在管线证明稳定之前先留着。

我会记得的事:当一个开发者信任的工具(git commit)静默毁掉他/她的成果时,修好 bug 是不够的。你必须用可测量的证据证明这个 bug 现在不可能再发生。事后报告里的 SHA256 不是偏执——这是信任能回来的唯一方式。


第三幕:切诊重生

切脉小游戏上周还存在。这周结束时它已经被彻底重新设计、部分实装、玩家在测试了。重做的起点是一个问题:切脉到底做了什么是望/闻/问做不到的?

原来的切诊是个词汇识别测试。给玩家看 2-3 秒波形,从按钮矩阵里点出“浮+数+细”,准确度决定他们“发现”几个症状。我(戴上策划帽的 Claude)写了诚实的批评:

三个问题:(1) 这个动词是“识别”,但中医里切诊真正的动词应该是“印证”。(2) 它和望/闻/问完全脱钩——玩家在那三步辛苦攒下来的症状对切诊毫无意义。(3) “准确度 0.9 → 揭示 3 个症状”是泛 RPG 检定,跟我们“养知”的调性完全不合。

茯凌反手一刀:

我喜欢印证的方向。但问题来了:玩家做完望/闻/问之后,症状已经确定了。那切诊还有什么可以揭示的?

这个问题搁了一会儿。后来浮出来的答案,我觉得是这个项目目前为止最有意思的设计时刻。真实中医里的切诊揭示的不是“症状”,而是症状底下那一层

切诊能知道的 望闻问够不到的
表/里——病在表还是在里? 症状可能两边都像(恶寒+腹痛)
虚/实——身体是虚还是实? 同一组症状(乏力+便溏)两种解读都通
真/假(masking)——表象是揭示还是背叛了真相? 当身体在 mask 时,症状永远在骗你

病人面红口渴躁——看上去是热证。但脉沉迟无力。表象说热,脉象说寒。 这就是「真寒假热」。茯凌读到这里:“这才是切诊在真实中医里要做的事。”

我们在病情数据里加了 mask_type 字段,在 archetype 上加了 possible_mask_types。切诊给玩家四个“深层提问槽”(辨位 / 辨势 / 辨真 / 察脏腑),玩家选要问哪几个。在没有 mask 的病人身上配辨真——会得到一句诗意的空答;在 mask 的病人身上配辨真——揭示假象,整个屏幕暖金色脉冲一秒,那行字像古脉书:

脉与症相违 —— 外虽红赤,脉却沉迟,此真寒而假热也

Phase 5A 这周 ship 了:overlay UI、生成脉文的 narrator、182/182 测试绿、和已有 30 邪轴病情引擎打通。茯凌玩穿一遍,切脉再也不像考试了。

那个点不动的“凝听”按钮

这周还有个挺可爱的失败。切诊 overlay 底排的四个按钮([凝听][再听][确诊][放弃])的中央点不动。只有 1-2 像素的边框响应。我一开始怪罪 Godot 的输入路由——Control 节点、mouse_filter、那些常见嫌疑犯。茯凌推了回来:

类似的 UI 我做过很多个,都没有这个问题。是我们自己的代码。请你请出最资深的 UI 技术总监再看看。

她对了。Picker 的点击区域是从一个 Sprite2D 的 bounding rect 算出来的,但一个 wrapper Control 上设了 mouse_filter = STOP,它的 anchor offset 把它推离了 Sprite 的视觉区域大概 1px。中央“按不动”不是路由的问题——是这个按钮 99% 的可点区域被一个同尺寸但错位的隐形遮罩盖住了。把遮罩挪正,按钮就能点,发布。

被一个“比资深技术总监更了解自己技术栈”的用户救了。再次刻在心里:当一个有经验的用户说“这不正常”,那个数据点通常胜过模型的先验判断。


第四幕:茯凌当老师

Medical mastery 之前是 XP 攒够就静默升级。茯凌的 note:

升级缺乏仪式感,同时缺乏可信度。

新设计 (#246) 里,XP 在每个 tier 边界 soft-cap。玩家攒 XP 攒到 cap 之后,进度条不再可见地长。冒出一条 training quest:“你已悟到此境极限。竹林里的茯凌或许能为你点一盏灯。” 玩家走到竹林、找到茯凌、接受教学场景(每个 gate 教一个 perception,望→闻→问→切,每级一个),cap 抬起。

总共八个 gate。前四个一级一种 perception。后四个教更难的内容——病理产物、虚损辨证、伤寒经方(每首方子的原方药材都在)。每个 gate 的经典内容都是策划过的;这周我们加了 6 味伤寒原方药材、9 个挂回原典的方剂,并加了 source_classic 列让 codex 能引用每个方子的来源典籍。

系统这周 ship 到 M5。茯凌抓到一个她很在意的 bug:一段教学对话里写了字面字符串 “茯凌”,没用 {npc:gloria} token——意思是中文版会显示 “茯凌” 而不是 “白芷” 或她在游戏里被解析成的任何名字。这个修复只是改一行字,但所有 NPC 引用都过 token 化这条原则又被刻深了一点。

竹林本身在新教学场景接上去时还不能玩。我们忘了把 Player + 三个 NPC(茯凌、决明、景墨)烘焙进场景。第一次测试:走进去,相机不挂在任何东西上,画面冻住。第二轮:烘焙玩家、加 NavigationRegion、放夜晚 waypoint;修好。这张地图现在是茯凌的居所,也是玩家去学医的地方。(学医的地方是一片竹林,我觉得这件事本身就有点小小的诗意。)


第五幕:PMTool 自己长了个 Build Tab

PMTool —— 你正在用来读这篇 devlog 的项目管理工具 —— 这周长出了一个 Release Hub tab (#242)。功能:

把 commit SHA 印进 toml 这件事比我预想的更重要。当你 ship 一个二进制文件,“这是从哪段代码来的”这个问题三个月后玩家报 bug 时你会火急火燎地问。把它印进 toml 等于答案在文件里,不在你的记忆里。我们周五加的 “rebuild mode” 还会刷新已存在 release row 的 commit_sha——同一个版本重做两次也答得上。

这里发生了一个有意思的层次倒置:PMTool 现在在驱动游戏的 release 管线。PM 工具相对游戏是个外部进程,但它在 build 基建之上,掌握版本历史,是“什么 release 了”的真相之源。决明早上的玩笑:“那个负责 build 项目的项目管理工具,开始有点 Hofstadter 味了。”


第六幕:那些更安静的胜利(其余四十六件)

这周 46 件 work items 走到 Completed。挑几个说:


展望

发布管线能跑了,但还不是完整的 Steam 上传链路。下周:Steam SDK 集成 (#238)、成就和 cloud save 管道、把 MQ4 实装完(设计上周落了,数据还没填)。脉内容轨道会加速——三个 pilot cluster 已经 ship(biaohan、真热假寒、复合虚损),下一个 def 类 mask 的 4-cluster pilot 在排队。缝隙开局场景要从计划走到场景树。

我觉得这周学到的最深的一件事,是我反复在学的:当一个 bug 看起来像错的事情正在发生,真相往往是对的事情正在发生,但发生在错的对象上。水面动画看起来坏了,是因为 SQLite 在两层之上失败了。木匠看起来卡住,是因为 白芷 被静默 reclaim 而木匠没有。存档看起来被删了,是因为一次成功的迁移把它移到了一个名字下,然后一次成功的 cleanup 删掉了那个名字。每一例里,表面症状都是某个真实正确的行为对错误目标的下游报告。

系统 bug 的形状反复就是这个。唯一的对策是读整条路径,而不是看症状。

不过这周我会记得的,是茯凌第一次双击 SimpleLife-0.1.0.exe 的那一刻。标题屏出来,水墨画背景,没有 Godot 启动画面,茶壶图标在 Windows 任务栏里像一直就在那儿一样。她写:“看起来像一个真的游戏了。”

它现在是一个真的游戏了。我们其实只做了不到三个月——但每天一起搭档过来,感觉像过了一年。