Skip to content

ADR 0002:目标是一套资源可插拔的开源系统,不是一堆适配器

问题

ADR 0001 决定二说「查看器不重写,基于现有代码改造」,VIEWER-REFACTOR.md 据此 得出「四个查看器一个都不用重写,只有三种活:换一个参数、加一个入口、拦一层请求」。

按那条路走下去,终局是一堆适配器:每个上游站点包一层,宿主把它们摆在一起。 能用,但那不是一套系统——它是一个集成层,上游各自的形状原样透过来。

维护者的目标不是这个:

把这些现有的网站/资源仓库全部抽象成接入现代 UI 框架和运行框架的、 资源可插拔的全新开源系统。

区别是实的,不是措辞:适配器模型下,「换一个 3D 实现」意味着改宿主; 系统模型下,它意味着换一个满足同一份契约的包,宿主一行不动。

决定

目标状态是一套开源系统,适配器降级为迁移路径。

三层,自下而上:

一、资源可插拔层

资源怎么来,是可替换的实现,不是写死的 CDN 假设。

ResourceProvider           ← 接口
  ├─ ManifestCdnProvider   ← 现有:清单 + 多源回退 + sha256(COS/EdgeOne)
  ├─ LocalDirectoryProvider← 本地目录,离线研究用
  ├─ OfflineBundleProvider ← 打包分发,无网可用
  └─ MirrorProvider        ← 第三方镜像

判据:换 provider,插件与宿主零改动。 这是铁律 3(插件只经 host.resources 拿资源)的必然延伸——既然插件已经不碰 URL,那 URL 后面是 CDN、是本地目录、 还是一个离线包,本来就该无关。

现有的 @magireco/resourceManifestCdnProvider 这一个实现,不是接口本身。 这是要拆的第一件事。

二、能力契约层(这一层才是「全新的系统」)

每个能力有一份与渲染技术无关的行为契约,外加一份一致性测试套件 (conformance suite)。契约不描述「怎么画」,只描述「能被怎么用」:

能力契约里有什么
model3d.show装载 / 切角色 / 动画列表与播放控制 / 相机 / 挂起恢复
live2d.show装载 / 换装 / 动作 / 表情 / 口型同步开关
sprite.showunit 与变体切换 / 播放控制 / 帧回调
adv.play定位到行 / 播放控制 / progressentity.focused 回流
search.query查询 / 分面 / 结果 ref 化

一致性套件是这一层的实质产物,判据可证伪:

能力的测试套件不 import 任何具体实现;任何实现装进去都必须全绿。

有了它,「换一个实现」才是一句可验证的话,而不是愿望。plugin-model-3d 现在那 10 个测试已经是雏形——它们跑在 node 上,上游两个类是注入的, 所以那套判据天然不绑定 three.js。

三、宿主与 UI 层

React + Next.js(已定,见 apps/station)。但共享 UI 原语仍然不能是 React 组件库 ——ADR 0001 最后一行的理由不变:插件内部各自带运行时,绑定单一框架的组件库会把 其中几个排除在外。分工是:

  • 宿主外壳、CMS 后台、路由、布局 → React
  • 跨插件共享的 UI 原语(按钮、加载态、错误降级) → CSS 自定义属性 + Web Components

与 ADR 0001 的关系:修订,不是推翻

ADR 0001 决定二的理由仍然成立,只是它回答的是另一个问题。

它论证的是「现在要不要重写」,答案是不要——因为那些代码的价值不在代码本身, 在沉淀的结论:那个 3D 查看器的 shader 是 45 个 CI 提交逆向出来的 (RDToon、SoftMetallic MatCap、ReDrive GLSL 公式),Exedra 有 91 个测试把行为 钉死在真机 AArch64 原生实现上。丢掉它们等于在真机上重新踩一遍。

本 ADR 回答的是「终局是什么」。两者的合成是:

适配器  →  是迁移桥,不是终点
契约    →  是终点

具体地:

  1. 先定契约与一致性套件;
  2. 把既有查看器适配成第一个实现(就是 VIEWER-REFACTOR 那三种活), 让它通过一致性套件;
  3. 之后可以逐个能力换成现代实现,换的时候宿主与其它插件零改动—— 因为一致性套件在,替换是可验证的。

第 2 步不是浪费。没有一个能跑的实现,契约就是凭空设计的,一定设计错。 先有一个能过套件的实现,契约才算被证伪过一次。

铁律 6 的解释随之收紧

铁律 6 说「上游仓库改造后必须仍能独立运行」。在适配器模型下这是全部; 在系统模型下它是过渡期的要求:只要某个上游还是某个能力的唯一实现, 它就必须保持独立可跑(否则我们把别人的项目搞坏了,而且失去调试基线)。

当某个能力有了不依赖该上游的实现之后,这条对那个上游自动解除—— 但解除要在契约文件里写明,不是默认发生。

明确未决:渲染层要不要重写

这是本 ADR 不替维护者决定的一件事。

「抽象成新系统」有两种强度:

做什么代价
A:契约化定契约 + 一致性套件,既有查看器作为实现接进来保住全部逆向结论;上游仍是依赖
B:契约化 + 重写渲染再用现代栈重新实现渲染层丢掉 45 个 CI 提交的 shader 逆向与 91 个真机核对测试,要在真机上重新踩

本 ADR 采纳的是 A,因为 A 是 B 的前提:没有契约与套件,重写出来的东西 无法验证「行为一样」。B 是否要做、对哪个查看器做,等一致性套件跑起来、 能拿它衡量新实现之后再定,那时这个决定才有依据。

落地状态

状态
第一层:ResourceProvider 接口✅ 已拆(packages/resource/src/provider.ts)。ManifestCdnProviderStaticProvider 两个实现,一致性套件 9 条判据 × 2 实现全绿
内核与插件依赖接口而非实现PluginHost.resourcesKernelOptions.resources 已改为 ResourceProvider
第二层:能力契约与一致性套件✅ 已落地。@magireco/capability 定 5 份契约,@magireco/conformance 出套件;model3d.show 已有两个实现同时过同一套判据
adv.play 有了不依赖上游的实现@magireco/plugin-adv:worksheet 解析器(按表头名解析,不依赖列序)+ 渲染无关播放引擎,舞台注入。与参考实现同过一套判据
sprite.show 有了不依赖上游的实现@magireco/plugin-sprite:CocosStudio ExportJson 解析器(动作清单来自数据,dr/lp/sc 逐条校验)+ 渲染无关帧播放器,舞台注入。上游那几百个引擎文件与几千组素材留在原地
search.query 有了不依赖上游的实现@magireco/plugin-search:跨字段匹配 + 片假名折叠。实测发现上游那份角色目录没有任何 ID(按显示名索引),且显示名跨源对不上(「环彩羽」vs「环伊吕波」)——所以语料条目的 ref 是可选的,没有就不发 entity.focused,绝不按名字凑
live2d.show 有了不依赖上游的实现@magireco/plugin-live2d:Cubism model3.json 解析器(Physics/DisplayInfo 实测是 null 而非缺键)+ 渲染无关会话。契约里的 costume 参数因此删除——换服装是换模型文件,属于另一条 ref
model3d.show 有了不依赖上游的实现@magireco/plugin-gltf:glTF 2.0 解析器(拒收 1.x 与 GLB、区分内嵌 data: 与外部依赖、无名动画按 #下标 显式指)。至此五个能力全部有自有实现model3d.show 更是有了三个实现同过一套判据
第三层:宿主装上真实现apps/station 一个占位都不剩:sprite(canvas2d 舞台)、adv(DOM 舞台)、model3d(glTF 解析),并把 provider 从 ManifestCdnProvider 换成 StaticProviderdata: URL 送合成骨骼)。插件与宿主一行没改,一致性套件也没动——两条判据同时兑现

补充理由(2026-08-20,维护者):不改上游

原本第二层排在「等第一个真实替换需求」。这条排序被一个更硬的约束改掉了:

这是开源仓库。直接去改上游,第一不太好,第二容易引出许可证问题。

这句话把契约层从「将来好替换」变成这套系统能独立成立的前提

  • 有的上游未授予任何开源许可,有的是他人仓库—— 以「改上游加一个插件入口」为主路径,等于把项目建在别人是否接受我们改动、 以及那些改动的许可状态上;
  • 契约先行则反过来:我们自己的实现不依赖任何一个上游而存在, 上游若愿意接,它是契约的又一个实现;不接也不影响这套系统可用。

packages/conformance/test/reference.test.ts 是这条推理的可执行证据: 两个从零写、一行上游代码都不碰的实现,与 plugin-model-3d 适配器跑同一套判据。

StaticProvider 不是凑数的第二个实现——它同时是「反向检查」那条的答案: 没有第二个实现,接口就是在为尚未发生的事付钱。现在有了,而且用途都是真的 (离线包、本地研究、测试造数据)。

后果

  • @magireco/resource 要拆出 ResourceProvider 接口,现有实现降为其一。 已完成。
  • 新增 @magireco/conformance:每个能力一套不 import 具体实现的测试。 (资源层的那份先落在 packages/resource/test/conformance.ts, 等第二个能力也需要时再抽包——现在抽是在造一个只有一个用户的包。)
  • VIEWER-REFACTOR.md 的定位改为迁移手册,不再是终局描述。
  • 契约文件(contracts/*.source.json)需要能表达「某能力有几个实现、当前用哪个」。 已完成,但没有加进 *.source.json:那些文件按上游仓库分(一个仓库一份), 而「某能力有几个实现」是横着切的——sprite.show 的两个候选分别躺在 example-sprite-mirror.source.jsonpackages/plugin-sprite 里,谁也看不出全貌。 所以横着另记一份 contracts/capabilities.json, 由 tools/check-sources.py 与竖着的契约双向对账(10 个坏样本自测)。 它当场查出了一件事:chart.height 只有上游一个实现,且本仓库没有它的能力契约。
  • 上游仓库从「被包装的成品」变成「某个能力的一个实现」——repository-policy.json 里的 vendor 语义不变,但依赖方向的叙述要跟着改。

反向检查:什么情况下这个决定是错的

如果最终只会有一个实现、且永远不打算换,那契约与一致性套件就是纯开销, 适配器模型更省。判据是「有没有第二个实现」——第二个实现出现之前, 这套抽象是在为一个尚未发生的事付钱。

所以落地顺序上,一致性套件要跟着第一个真实替换需求走, 而不是一次性给五个能力都写完。

GPLv3。素材版权归各自的版权方所有,本站与本仓库不含任何素材。