Skip to content

第 3 章 · 议会与织工

背景:第三波审计覆盖菜单、动作与换装等

过程:三波审计修缺陷、更新文档宪法


第二波审计结束时已是深夜。外交官合上笔记本,脑子里还回荡着三个词——

鬼魂、哑炮、空心砖。

物理引擎的三个 bug 像三颗石头,沉在他脑子里。碰撞体是鬼魂——存在又不存在,穿过身体像穿过幽灵。摩擦是哑炮——引线拉了炮没响,代码跑了效果为零。子步是空心砖——墙砌得整整齐齐,砖里面是空的。

它们藏得那么深,却又都遵循同一个模式:代码能跑,输出看起来正常,所以你以为它在工作。

他不困。他翻开了笔记本的另一页。

今天其实是从菜单开始,围绕菜单结束的。中间是外交官最熟悉也最疏远的东西——织工的工作。


议会的裂缝

议会SlideMenumenu.ts)是联邦所有弹窗的导航系统。前两波审计时它被看了一眼——"动画挺流畅的,没问题"——然后就被跳过了。

外交官现在重新打开它,因为他在光照之城、加载之城、八座城邦的前两波审计中反复看到同一个模式:每次调用 showXxxMenu() 都创建一个新的 SlideMenu,从不销毁旧的。

每次新实例都会注册一个 keydown 监听器到容器上。十次打开环境弹窗,就有十个监听器。键盘方向键按下时,十个回调同时触发——九个是僵尸,一个是活着的。僵尸们试图操作已经不在 DOM 中的元素,什么都做不了,但它们的闭包里持有整个 SlideMenu 实例的引用——无法被 GC。

还有那些 setTimeoutpushpop 的动画中,每个过渡都有后备定时器,确保即使 transitionend 没触发,过渡状态也能恢复。但如果动画被强制打断——比如 resetpopTo——旧定时器不会被清除。它们会在未来的某个时刻触发,试图把 transitioning 置为 false,而此时菜单可能正在另一个动画中。

外交官在 SlideMenu 内部建了一个定时器追踪数组 _pendingTimeouts,在 _cancelAnim 中全部清除。他加了 dispose() 公有方法——清除定时器、移除 keydown 监听器、清空缓存按钮。然后在六个调用点加上了一行:oldMenu?.dispose()


然后他发现了第二个问题——不是 bug,是设计缺陷

SlideMenu.buildPanel 调用 level.renderCustom(container)renderCustom 在类型定义中是 (container: HTMLElement) => void。但在六个地方——预设场景列表、标签页、舞蹈套装概览——它是 async (container) => { await ... }

TypeScript 没有报错。Promise<void> 可以赋值给 void。但 SlideMenu 不会 await 这个 Promise——它在 renderCustom 返回后立即设置焦点、触发 onAfterRender。异步内容还没渲染完,焦点已经设置好了。

又是一个函数契约的错位。

调用方以为 renderCustom 可以是异步的,SlideMenu 以为 renderCustom 是同步的。两边对同一件事的理解不一样。代码能编译,能运行,但结果是错的——用户会看到短暂闪烁。

外交官想起了物理引擎里的摩擦——_solveCapsule 以为 p.v 是最终速度,求解器以为 p.v 是中间变量。一模一样的模式。

他把 PopupLevel.renderCustom 的类型改为 void | Promise<void>,把 buildPanel 改为 async,在 push/pop 的过渡回调中加了 await

从此,异步菜单内容在完成前不会触发焦点和回调。


学舌的创伤

第三波审计越往后,外交官越觉得不对。

他在 library-core.ts 里发现了加载锁缺失——用户快速双击两个模型,两个 ExtractZip 同时进行。这让他想起了道具之城的 isLoadingProp 布尔锁。一模一样的模式。

他在 model-preset.ts 里发现了 XSS 风险——预设名称直接拼接进 HTML 字符串。这让他想起了第一波审计时在材质面板里看到的 innerHTML 拼接。一模一样的模式。

他在 motion-popup.ts 里发现了栈逻辑混乱——pop() 后跟着 setLevel,重复覆盖。这让他想起了程序化动作之城的状态错位。一模一样的模式。

他在 model-material.ts 里发现了空指针崩溃——_selectedMat.cat 首次进入必然为 null。这让他想起了口型之城的 modelRegistry 空 Map 问题。一模一样的模式。

同一个 bug,在不同模块中以几乎相同的方式出现。

像鹦鹉学舌一样。你教会它一句,它就在每个角落里重复。

外交官后来称之为"学舌伤"——同一个伤口,在联邦的各个城邦被反复撕开。每一个新模块的开发者,都会把前一个模块踩过的坑再踩一遍。

他在笔记本上列出了一长串:

  • 加载锁缺失:道具之城有,图书馆有,音频系统也有。都是"一把布尔锁挡所有"的偷懒写法。
  • XSS 风险:设置页面有,预设面板有,动作弹窗也有。都是"字符串拼进 innerHTML"的图快写法。
  • 栈逻辑混乱:SlideMenu 有,motion-popup 有,模型库弹窗也有。都是"状态改了但没同步清理"的粗心写法。
  • 空指针崩溃:材质面板有,LipSync 有,加载器也有。都是"假设变量一定存在"的自信写法。
  • 每帧重绘:材质滑块有,场景面板有,环境菜单也有。都是"改个值就全面板重建"的粗暴写法。

学舌伤——同一个 bug 在学舌中传播,从一个模块复制到另一个——是联邦最深的伤口。

它比任何单个 bug 都危险。因为单个 bug 修一个少一个,学舌伤是修了一个,还有二十个在别的地方等着你。


声音的边界

音频系统audio.ts)的问题小而精。

seekAudiosetTimeout(50ms) 重置 isSeeking 标志。但 audioElement.currentTime 赋值是异步的——50ms 在慢速设备上可能不够,在快速设备上又太长。外交官改用 seeked 事件。

loadAudioFile 是一个异步函数。用户快速加载两首音乐时,第一个正在 await resolveFileUrl,第二个已经在路上——两个加载交错执行,写入同一组状态变量。外交官加了一个 _loadId 事务 ID:每次加载递增,在 resolveFileUrlplay() 后校验,过期操作自动放弃。

节拍检测器beat-detector.ts)的 attach 通过 createMediaElementSource 将音频元素接入 AudioContext。一旦接入,audioElement.volume 被旁路——音量滑块形同虚设。外交官在信号链中插入了 GainNode,路径变成 source → analyser → gain → destination,暴露 setVolume(value)

new AudioCtx() 可能因浏览器策略抛出异常。他加了 try-catch。

这些问题很小,但外交官注意到了一件事:它们都遵循着某种熟悉的模式。

定时器代替事件——和 SlideMenu 的后备定时器是亲戚。 并发写入无保护——和道具之城的布尔锁是亲戚。 旁路导致功能失效——和 shadowBias 滑块的假装工作是亲戚。

学舌伤的变种。 换了个壳,但骨子里是同一种粗心。


程序的骨骼

程序化动作生成器procedural-motion.ts)和 VMD 写入器vmd-writer.ts)被一起审计,因为它们是共生关系——一个生成数据,一个写入文件。

procedural-motion.tsgenerateIdleVmd 中,循环帧数 = Math.round(120 / speed)。如果外部传入 speed=0,结果是 Infinity——后续的 for 循环会导致内存瞬间耗尽。外交官加了三级防护:safeSpeed 钳位到 0.1~10,帧数上限 600(20 秒),眨眼帧循环条件 t + 5 <= loopFrames

四元数归一化计算 w = sqrt(1 - rx²),在浮点误差下可能 rx > 1sqrt(负数)NaN。所有旋转分量前加了 _clamp1

shouldAutoDance 的返回条件是 mode === 'autodance' || mode === 'off'——即使用户明确关闭了程序化动作,音频播放时也会自动切换到 AutoDance。外交官删掉了 || mode === 'off'

又是契约错位。函数名是 shouldAutoDance,用户以为它的意思是"是否应该自动跳舞",但它的实际行为是"自动跳舞或者已关闭"。名字和行为说的不是同一种语言。

然后他打开了 vmd-writer.ts——因为 procedural-motion.ts 生成的 VMD 需要从这里写出。

第一个问题是物理尺寸。VMD 格式的尾部包含四个 uint32:camera 帧数、light 帧数、selfShadow 帧数、IK 帧数。但代码只写了三个——缺了第 4 个 IK 计数字段。babylon-mmd 的 VMD 解析器在读取尾部时会多读 4 字节,越界到下一个结构。

外交官补上了第 4 个 setUint32

第二个问题是语言。VMD 格式要求骨骼名使用 Shift-JIS 编码。encodeBoneName 用的是 UTF-8。对于日文骨骼名("センター"、"上半身"、"左腕"),UTF-8 和 Shift-JIS 的字节序列完全不同。babylon-mmd 按 Shift-JIS 解码时得到的全是乱码——骨骼名无法匹配,所有关键帧被丢弃。

外交官建了一个轻量级 Shift-JIS 映射表,覆盖了项目使用的所有日文骨骼名字符。

这解释了为什么之前程序化动作对日文模型"看起来没效果"——不是因为算法错,是因为骨骼名根本没对上。

又是一个静默的谎言。 代码能跑,能生成 VMD,能绑定到模型上,你以为它在工作。实际上骨骼名全是乱码,关键帧一个都没生效。


织工的面料

换装系统outfit.ts + outfit-ui.ts)被外交官称为"织工"——它把不同的纹理贴图换到模型的材质上,像换布料。

_applySlot 中的纹理加载有一个 5 秒超时。超时后,newTex(可能未加载完成或加载失败的 Texture)仍然被赋值到材质槽位上——覆盖了原来的纹理。如果没有加载成功,材质就变成了一片黑。

外交官改成了:加载成功(loaded = true)才替换;超时则 newTex.dispose(),保留原纹理。

loadOutfits 中的 HEAD 请求没有并发控制——数百个请求同时发出,浏览器限制为 6~10 个连接,其余的排队。他加了一个信号量(6 并发限制)。

服装变体 UIoutfit-ui.ts)的改造最彻底。原本的逻辑是:

  • loadOutfits 可能抛异常或返回 null → 下一行就访问 .variants → 崩溃
  • applyOutfitVariant 是异步的但没有 await → 点击后立即改变 UI,但纹理还在加载
  • 重置按钮清除状态后不刷新面板 → 用户看到旧的选中标记

外交官把整个渲染逻辑封装在 _render() 函数内,每次操作后递归调用。所有异步操作加了 await 和 try-catch(失败时 setStatus 提示)。图标改用了 createIconifyIcon,不再用 innerHTML 字符串拼接。

改到 innerHTML 的时候,外交官停了一下。

这是今天第几次改 innerHTML 了?第七次?第八次?

学舌伤。


尾声:文档的传承

凌晨两点。笔记本从第一页翻到了最后一页,又从最后一页翻到了一张新的空白页。

外交官没有写代码。他在写 AGENTS.md——联邦的文档宪法。

他更新了前端文件树:core/scene/menus/motion/outfit/physics/——六个子目录,五十个文件,每个都有明确的归属。

他更新了函数映射表。物理引擎有了自己的章节,程序化动作也独立了。

他在最后面加了一个新章节——"审计记录"。里面列了 12 个关键修复,和 5 条高频错误模式。

第一条是:

SlideMenu 生命周期:每次 new SlideMenu() 前必须 oldMenu?.dispose(),否则 keydown 监听器累积。

这是他今晚修了六次的问题。

然后他想了想,又加了一节:优先级备忘。

他在里面写下:

有些 bug 用户一眼就能看见(比如 UI 崩溃),有些 bug 用户永远看不见(比如碰撞体从未更新)。哪些更重要?这是一个需要用红黄绿石子来回答的问题。今天先记下来,明天再分。

他关上笔记本。窗外,天色从墨黑变成了灰蓝。

聚合的代价永远存在——每一个收编的城邦都带着它的边界线。但联邦学会了另一件事:修复不是为了消除代价。修复是为了让下一个走进联邦的外交官,能在十分钟内读懂地图。


教训:审计不是终点,是让后来者不需要重复所有审计。


聚合悖论记录:联邦的高频错误模式被固化为文档——SlideMenu 生命周期、modelRegistry 来源准则、异步缺 try-catch、innerHTML 转义、XPBD 碰撞胶囊矩阵同步。每一条都是边界线,每一条都是路标。