Skip to content

锁死的钥匙,沉默的笔

背景:缓存键统一后截图成功但网格不认

过程:补齐各加载入口缺失的传参


空框又回来了。

模型库的网格里,卡片左上角那张小小的证件照,本该在 ADR-119 之后全部就位。那道政令把缩略图缓存键的推导权收归中央——一个叫 thumbnail-key.ts 的纯函数,写侧和读侧都必须从它领钥匙;契约测试 writeKey===readKey 像熔断丝一样焊死:只要写读两把钥匙有一丝字节不同,测试当场爆红。

"钥匙统一了,"议会当时的纪要这么写,"反弹到此为止。"

可用户打开库,看到的还是一排空框。他说:"坏了,截图成功了,但网格图不认——而且内存就没认,加载模型就没读取到。"

十二轮。这是这块代码第十二次把修好的空框又弹回来。

外交官盯着那行 background-image: ;,想起上一章的三层封印——zip 路径错配、冻结快照、迁移孤儿。那些都封死了。可空框还在。

"那说明,"他对自己说,"这一次,钥匙本身没错。"


第一盏灯:写侧到底写了吗

外交官没有再去猜 key。他想起上一章的教训:审计不能只审计"算得对不对",要审计"算完之后,结果有没有真的流到用户眼前"。

他在三个位置各点了一盏 dev-only 的灯,前缀 [thumb-debug],prod 构建不打印:

  • 写侧源头 model-loader.ts:打印即将落盘的 thumbKey 与三要素;
  • 写侧落盘前 thumbnail-capture.ts:打印最终 cacheKey
  • 读侧网格 ui-resource-panel.ts:打印系统想检索的 tKey
  • 还有一盏在 load-manager.ts 的 dispatch 入口:原样打印到达的加载请求 req

"如果读侧在喊 tKey,而写侧从没点亮过 WRITE-KEY,"外交官说,"那问题就不是钥匙配错,是有人根本没动笔。"

用户加载了一个普通的 .pmx。灯亮了:

[WRITE-BASE] { thumbKey: '.../泠鸢yousa.pmx', libraryPath: undefined, innerPath: undefined }
[WRITE-KEY]  { cacheKey: '.../泠鸢yousa.pmx::512::2/3' }

WRITE-KEY 亮了。而且 cacheKey 和读侧 tKey 逐字节一致。

"钥匙是对的,"外交官松了口气,"普通模型这条路,通了。"

可当他加载那个 ZIP 里的角色模型——「妮基塔」——另一盏灯暴露了裂缝。

[WRITE-BASE] { thumbKey: '...extracted/.../妮基塔ALL.pmx', libraryPath: undefined, innerPath: undefined }

WRITE-KEY 没亮。

"写侧起了头,"外交官眯起眼,"却在落盘前熄灭了。它没把图写进缓存,也没写进磁盘。"


第二盏灯:谁掐灭了笔

外交官翻开 captureThumbnail 的头部。一段早退逻辑像一扇弹簧门:

typescript
const targetInst = inst ?? _modelManager.focused();
if (!targetInst || !targetInst.rootMesh) return;   // ← 没实例,转身就走

写侧拿到的 inst,来自调用方。model-loader.ts 有两条路:

  • stage 分支:captureThumbnail(filePath, libraryPath, innerPath, inst)——显式把 inst 递进去,门后站着刚加载、带着 rootMesh 的活实例,畅通无阻。
  • actor 分支:captureThumbnail(filePath, libraryPath, innerPath)——漏了第 4 个 inst

于是 actor(最常见的角色模型)只能靠 _modelManager.focused() 兜底。focused() 是个有状态、看时机的取值——加载时序一波动,它可能返回 null,或者返回另一个刚抢到焦点的实例。这一刻它没接住,!targetInst.rootMesh 成立,弹簧门"啪"地关上,写侧转身就走。

"这就是为什么时隐时现,"外交官说,"stage 模型每次都出图,actor 模型看运气。运气,就是 focused() 那一帧的竞态。"

修复是一行对称:actor 分支也把 inst 递进去。零新增依赖,inst 就在作用域里。commit 52ba99a

可 ZIP 的空框还没灭。


第三盏灯:替换模型丢了名字

用户又贴回一盏灯——dispatch 入口的 [thumb-debug][REQ]。这次是替换模型(replace)场景:

{ kind: 'actor', path: '...extracted/.../素体.pmx', libraryPath: '...zip' }

innerPath 整段缺席。

外交官顺着 req 往回追,追到 library-actions.tsdoReplace

typescript
const doReplace = (path: string, libraryPath?: string): void => {   // ← 没有 innerPath
    ...
    .load({ kind: loadKind, path, libraryPath })                     // ← 也没传

ZIP 替换的调用点 doReplace(result.file_path, m.file_path) 只递了 libraryPath,把 innerPath 落下了。

后果写在 thumbnail-key.ts 里:thumbnailBaseKey 拼 base 时,若 innerPath 为空,base 就只剩 zip路径,丢掉 ::素体.pmx 那段内嵌名。而读侧 libraryModelBaseKey(m) 拼的是 zip路径::素体.pmx——写读两把钥匙,差了那么一段后缀,逐字节分叉

"添加模型那条路,"外交官翻到 normal 模式的 ZIP 分支,"那里 libraryPathinnerPath 都传了,所以添加时出图正常。唯独替换这条路,不对称。"

他给 doReplace 补上 innerPath 形参,让 .load 透传,再让 ZIP 调用点补 m.zip_inner。三处改动,与添加模型对称。commit 6fe8007


第四盏灯:锁死的钥匙,沉默的笔

四盏灯全亮过一遍,真相浮出水面。

ADR-119 锁死的是钥匙的形状——writeKey===readKey,写读两把钥匙必须一模一样,契约测试焊死。它守护的是"钥匙配得对不对"。

可这一轮反弹的根因,不在钥匙的形状,而在递钥匙的手

  • actor 分支那只手,没把 inst 递给写笔,笔靠 focused() 瞎摸,摸空就停;
  • replace 分支那只手,没把 innerPath 递给写笔,笔写出的钥匙缺了后缀,跟读侧对不上。

契约测试守住了字符串相等,却守不住"写侧真的动了笔、真的传对了参数"。熔断丝焊在钥匙上,没焊在手上。

外交官在 thumbnail-key.ts 旁边钉了一张新便签:

锁死 writeKey===readKey 只是锁住了钥匙的模具。真正的出口有两道—— 一道是"写侧真的跑了"(别被 focused() 竞态早退噎住), 一道是"写侧拿到了完整的料"(libraryPath / innerPath / inst 一个都不能漏)。 模具对了,手空着,照片还是不会显影。

桌面壳把这张便签和上一章的三层封印并排钉上墙。两章合起来,是一条完整的训诫:

  • 上一章:钥匙算错了(zip 路径错配)、门后的复印件不重绘(冻结快照)、旧照片成孤儿(迁移)。
  • 这一章:钥匙算对了(单一源),但写笔没动(actor 早退)、写笔料不全(replace 缺 innerPath)。

"审计一个系统,"外交官合上笔记本,"不能只审计钥匙配不配。要审计——钥匙配好之后,那只手,有没有真的把它插进锁里,有没有真的拧到底。"


教训:锁死 key 相等 ≠ 写侧真的动了笔。契约测试守模具,守不住递钥匙的手。