modelstage 0.0.0-stage → 0.5.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -2
  3. package/THIRD_PARTY_NOTICES.md +90 -0
  4. package/bin/modelstage.mjs +137 -0
  5. package/config/diag-upload.example.json +7 -0
  6. package/config/models/live2d.assetbundls.json +1 -0
  7. package/config/models/live2d.azurlane.json +1 -0
  8. package/config/models/live2d.azurlane_bundle.json +1 -0
  9. package/config/models/live2d.azurlane_spine.json +1 -0
  10. package/config/models/live2d.bd2.json +1 -0
  11. package/config/models/live2d.bluearchive.json +1 -0
  12. package/config/models/live2d.live2d.json +1 -0
  13. package/config/models/live2d.nikke.json +1 -0
  14. package/config/models/live2d.nikke_2.json +1 -0
  15. package/config/models/live2d.npc.json +1 -0
  16. package/config/models/spine.assetbundls.json +1 -0
  17. package/config/models/spine.azurlane.json +1 -0
  18. package/config/models/spine.azurlane_bundle.json +1 -0
  19. package/config/models/spine.azurlane_spine.json +1 -0
  20. package/config/models/spine.bd2.json +1 -0
  21. package/config/models/spine.bluearchive.json +1 -0
  22. package/config/models/spine.live2d.json +1 -0
  23. package/config/models/spine.nikke.json +1 -0
  24. package/config/models/spine.nikke_2.json +1 -0
  25. package/config/models/spine.npc.json +1 -0
  26. package/config/names/NIKKE.json +1058 -0
  27. package/config/names/README.md +47 -0
  28. package/config/names//345/244/251/345/221/275/344/271/213/345/255/220.json +42897 -0
  29. package/config/names//346/243/225/350/211/262/345/260/230/345/237/2032.json +4016 -0
  30. package/config/names//347/242/247/350/223/235/350/210/252/347/272/277.json +38342 -0
  31. package/config/plugins.json +13 -0
  32. package/config/resources.example.json +40 -0
  33. package/package.json +63 -4
  34. package/plugins/unity-live2d-bundle/README.md +98 -0
  35. package/plugins/unity-live2d-bundle/index.js +891 -0
  36. package/plugins/unity-live2d-bundle/lib/anim-to-motion.js +402 -0
  37. package/plugins/unity-live2d-bundle/lib/bundle-probe.js +244 -0
  38. package/plugins/unity-live2d-bundle/lib/bytes.js +189 -0
  39. package/plugins/unity-live2d-bundle/lib/common-strings.js +46 -0
  40. package/plugins/unity-live2d-bundle/lib/dxt.js +197 -0
  41. package/plugins/unity-live2d-bundle/lib/errors.js +71 -0
  42. package/plugins/unity-live2d-bundle/lib/etc2.js +371 -0
  43. package/plugins/unity-live2d-bundle/lib/extract.js +242 -0
  44. package/plugins/unity-live2d-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
  45. package/plugins/unity-live2d-bundle/lib/lzma-d-min.js +34 -0
  46. package/plugins/unity-live2d-bundle/lib/model3.js +137 -0
  47. package/plugins/unity-live2d-bundle/lib/png.js +424 -0
  48. package/plugins/unity-live2d-bundle/lib/serialized.js +233 -0
  49. package/plugins/unity-live2d-bundle/lib/texdecode.js +190 -0
  50. package/plugins/unity-live2d-bundle/lib/typetree.js +215 -0
  51. package/plugins/unity-live2d-bundle/lib/unityfs.js +396 -0
  52. package/plugins/unity-live2d-bundle/plugin.json +81 -0
  53. package/plugins/unity-live2d-bundle/profiles/unityfs-live2d.json +26 -0
  54. package/plugins/unity-spine-bundle/index.js +535 -0
  55. package/plugins/unity-spine-bundle/lib/bytes.js +189 -0
  56. package/plugins/unity-spine-bundle/lib/common-strings.js +46 -0
  57. package/plugins/unity-spine-bundle/lib/dxt.js +197 -0
  58. package/plugins/unity-spine-bundle/lib/errors.js +79 -0
  59. package/plugins/unity-spine-bundle/lib/etc2.js +371 -0
  60. package/plugins/unity-spine-bundle/lib/extract.js +242 -0
  61. package/plugins/unity-spine-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
  62. package/plugins/unity-spine-bundle/lib/lzma-d-min.js +34 -0
  63. package/plugins/unity-spine-bundle/lib/png.js +424 -0
  64. package/plugins/unity-spine-bundle/lib/serialized.js +233 -0
  65. package/plugins/unity-spine-bundle/lib/texdecode.js +190 -0
  66. package/plugins/unity-spine-bundle/lib/typetree.js +215 -0
  67. package/plugins/unity-spine-bundle/lib/unityfs.js +396 -0
  68. package/plugins/unity-spine-bundle/plugin.json +75 -0
  69. package/src/server/README.md +69 -0
  70. package/src/server/assets/browse.mjs +56 -0
  71. package/src/server/assets/bundle-probe.mjs +94 -0
  72. package/src/server/assets/probe.mjs +184 -0
  73. package/src/server/core/config.mjs +173 -0
  74. package/src/server/core/env-num.mjs +60 -0
  75. package/src/server/core/env.mjs +49 -0
  76. package/src/server/core/fs-portable.mjs +37 -0
  77. package/src/server/core/log.mjs +107 -0
  78. package/src/server/core/paths.mjs +44 -0
  79. package/src/server/data/catalog.mjs +1091 -0
  80. package/src/server/data/entry-states.mjs +499 -0
  81. package/src/server/data/live2d.mjs +272 -0
  82. package/src/server/data/manifest-cache.mjs +58 -0
  83. package/src/server/data/manifest-stamp.mjs +135 -0
  84. package/src/server/data/model-meta.mjs +114 -0
  85. package/src/server/data/names.mjs +106 -0
  86. package/src/server/data/shape-probe-worker.mjs +33 -0
  87. package/src/server/data/shape-probe.mjs +626 -0
  88. package/src/server/data/spine.mjs +336 -0
  89. package/src/server/data/tree.mjs +126 -0
  90. package/src/server/http/atomic-json.mjs +145 -0
  91. package/src/server/http/background-solids.mjs +270 -0
  92. package/src/server/http/backgrounds.mjs +388 -0
  93. package/src/server/http/diag-upload.mjs +264 -0
  94. package/src/server/http/diag.mjs +173 -0
  95. package/src/server/http/ready.mjs +167 -0
  96. package/src/server/http/static.mjs +312 -0
  97. package/src/server/http/write.mjs +654 -0
  98. package/src/server/index.mjs +114 -0
  99. package/src/server/plugins/builtin-providers.json +30 -0
  100. package/src/server/plugins/registry.mjs +650 -0
  101. package/src/server/plugins/slot-catalog.mjs +286 -0
  102. package/src/server/routes/background-solids.mjs +56 -0
  103. package/src/server/routes/backgrounds.mjs +77 -0
  104. package/src/server/routes/config.mjs +38 -0
  105. package/src/server/routes/data.mjs +313 -0
  106. package/src/server/routes/diag.mjs +37 -0
  107. package/src/server/routes/entries.mjs +170 -0
  108. package/src/server/routes/ready.mjs +122 -0
  109. package/src/server/serve.mjs +396 -0
  110. package/src/server/unpack/capacity.mjs +217 -0
  111. package/src/server/unpack/mem-probe.mjs +27 -0
  112. package/src/server/unpack/plugin-runner.mjs +529 -0
  113. package/src/server/unpack/plugin-store.mjs +736 -0
  114. package/src/server/unpack/probe-store.mjs +73 -0
  115. package/src/server/unpack/routes.mjs +359 -0
  116. package/src/server/unpack/runtime/exec.mjs +558 -0
  117. package/src/server/unpack/runtime/index.mjs +141 -0
  118. package/src/server/unpack/runtime/node-vm.mjs +111 -0
  119. package/src/server/unpack/unpack-worker.mjs +105 -0
  120. package/src/server/unpack/worker-client.mjs +184 -0
  121. package/src/server/unpack/write-result.mjs +84 -0
  122. package/src/shared/bundle-shape.mjs +83 -0
  123. package/src/shared/entry-reasons.mjs +119 -0
  124. package/src/shared/names.mjs +45 -0
  125. package/src/shared/type-labels.mjs +31 -0
  126. package/src/web/README.md +193 -0
  127. package/src/web/app/anim-list.js +222 -0
  128. package/src/web/app/background-solids.js +150 -0
  129. package/src/web/app/backgrounds.js +152 -0
  130. package/src/web/app/camera-log.js +28 -0
  131. package/src/web/app/context.js +72 -0
  132. package/src/web/app/dir-picker.js +242 -0
  133. package/src/web/app/entry-report.js +71 -0
  134. package/src/web/app/memory-bars.js +91 -0
  135. package/src/web/app/memory-budget.js +228 -0
  136. package/src/web/app/memory-guard.js +204 -0
  137. package/src/web/app/model-actions.js +682 -0
  138. package/src/web/app/renderer-setting.js +70 -0
  139. package/src/web/app/root-manager.js +254 -0
  140. package/src/web/app/status.js +138 -0
  141. package/src/web/app/topbar-actions.js +357 -0
  142. package/src/web/app/transport-actions.js +297 -0
  143. package/src/web/app/ui-state.js +559 -0
  144. package/src/web/app/url-params.js +171 -0
  145. package/src/web/app/view-settings.js +186 -0
  146. package/src/web/components.css +524 -0
  147. package/src/web/debug/diag-hud.js +95 -0
  148. package/src/web/debug/drag.js +369 -0
  149. package/src/web/debug/log.js +320 -0
  150. package/src/web/debug/model-cmp.js +292 -0
  151. package/src/web/debug/panel.css +474 -0
  152. package/src/web/debug/panel.js +712 -0
  153. package/src/web/debug/race-probe.js +82 -0
  154. package/src/web/debug/upload.js +130 -0
  155. package/src/web/engine/adapters/atlas.js +47 -0
  156. package/src/web/engine/adapters/index.js +22 -0
  157. package/src/web/engine/adapters/renderer.js +223 -0
  158. package/src/web/engine/adapters/skeleton.js +105 -0
  159. package/src/web/engine/adapters/texture.js +64 -0
  160. package/src/web/engine/animation.js +181 -0
  161. package/src/web/engine/appearance.js +136 -0
  162. package/src/web/engine/camera-adapter.js +212 -0
  163. package/src/web/engine/effect-chain.js +138 -0
  164. package/src/web/engine/engine-state.js +120 -0
  165. package/src/web/engine/filters.js +224 -0
  166. package/src/web/engine/fit.js +197 -0
  167. package/src/web/engine/frame-gate.js +33 -0
  168. package/src/web/engine/gl-driver.js +56 -0
  169. package/src/web/engine/gl.js +77 -0
  170. package/src/web/engine/index.js +20 -0
  171. package/src/web/engine/loader.js +55 -0
  172. package/src/web/engine/model-info.js +26 -0
  173. package/src/web/engine/pipeline.js +200 -0
  174. package/src/web/engine/player.js +427 -0
  175. package/src/web/engine/pma/blend.js +76 -0
  176. package/src/web/engine/pma/detect.js +118 -0
  177. package/src/web/engine/pma/index.js +8 -0
  178. package/src/web/engine/pma/resolve.js +112 -0
  179. package/src/web/engine/pma/upload.js +127 -0
  180. package/src/web/engine/pma.js +5 -0
  181. package/src/web/engine/render-frame.js +93 -0
  182. package/src/web/engine/runtime-capabilities.js +209 -0
  183. package/src/web/engine/runtime.js +37 -0
  184. package/src/web/engine/skins.js +165 -0
  185. package/src/web/engine/textures.js +174 -0
  186. package/src/web/index.html +117 -0
  187. package/src/web/infra/api.js +531 -0
  188. package/src/web/infra/diag.js +509 -0
  189. package/src/web/infra/document.js +269 -0
  190. package/src/web/infra/dom.js +31 -0
  191. package/src/web/infra/events.mjs +27 -0
  192. package/src/web/infra/log.js +153 -0
  193. package/src/web/infra/mem-stats.js +112 -0
  194. package/src/web/infra/plugin-host-registration.mjs +285 -0
  195. package/src/web/infra/plugin-host-report.mjs +32 -0
  196. package/src/web/infra/plugin-host-unload.mjs +79 -0
  197. package/src/web/infra/plugin-host-unpack.mjs +899 -0
  198. package/src/web/infra/plugin-host-warmup.mjs +180 -0
  199. package/src/web/infra/plugin-host.js +628 -0
  200. package/src/web/infra/ready.js +461 -0
  201. package/src/web/infra/schedule.js +51 -0
  202. package/src/web/infra/shortcuts.js +129 -0
  203. package/src/web/infra/slot-registry.js +402 -0
  204. package/src/web/infra/vfs.js +247 -0
  205. package/src/web/live2d/bridge.js +400 -0
  206. package/src/web/live2d/model.js +321 -0
  207. package/src/web/live2d/overlay.js +142 -0
  208. package/src/web/live2d/pixi-app.js +111 -0
  209. package/src/web/live2d/playback-probe.js +92 -0
  210. package/src/web/live2d/probe-geometry.js +329 -0
  211. package/src/web/live2d/probe.js +119 -0
  212. package/src/web/live2d/release.js +66 -0
  213. package/src/web/live2d/runtime-adapter.js +289 -0
  214. package/src/web/live2d/stage-ctx.js +152 -0
  215. package/src/web/live2d/stage.js +204 -0
  216. package/src/web/live2d/vfs-settings.js +174 -0
  217. package/src/web/live2d/view.js +89 -0
  218. package/src/web/main.js +629 -0
  219. package/src/web/panel/background-list.js +373 -0
  220. package/src/web/panel/bg-source.js +291 -0
  221. package/src/web/panel/color-picker.css +199 -0
  222. package/src/web/panel/color-picker.js +278 -0
  223. package/src/web/panel/dialog.css +116 -0
  224. package/src/web/panel/dialog.js +173 -0
  225. package/src/web/panel/dom.js +35 -0
  226. package/src/web/panel/icons.js +58 -0
  227. package/src/web/panel/memory-bar.js +195 -0
  228. package/src/web/panel/panel-settings.js +1263 -0
  229. package/src/web/panel/panel.css +1304 -0
  230. package/src/web/panel/render-choice.js +104 -0
  231. package/src/web/panel/schema.js +784 -0
  232. package/src/web/panel/settings-io.js +291 -0
  233. package/src/web/panel/shell-controls.js +274 -0
  234. package/src/web/panel/slider.js +210 -0
  235. package/src/web/panel/toolbar-schema.js +111 -0
  236. package/src/web/panel/toolbar.js +195 -0
  237. package/src/web/panel/transport-picker.js +375 -0
  238. package/src/web/panel/transport.js +321 -0
  239. package/src/web/panel/view.js +553 -0
  240. package/src/web/plugin/unpack-worker.js +185 -0
  241. package/src/web/render/camera/constants.js +60 -0
  242. package/src/web/render/camera/index.js +123 -0
  243. package/src/web/render/camera/spine.js +124 -0
  244. package/src/web/render/capture.js +30 -0
  245. package/src/web/render/interaction.js +527 -0
  246. package/src/web/render/live2d.js +627 -0
  247. package/src/web/render/playback.js +47 -0
  248. package/src/web/render/reset-view.js +29 -0
  249. package/src/web/render/spine.js +386 -0
  250. package/src/web/render/surface-switch.js +176 -0
  251. package/src/web/render/surface.js +571 -0
  252. package/src/web/render/surfaces.js +70 -0
  253. package/src/web/roster/filter.js +31 -0
  254. package/src/web/roster/list.js +276 -0
  255. package/src/web/roster/persist.js +92 -0
  256. package/src/web/roster/reveal.js +77 -0
  257. package/src/web/roster/rows.js +248 -0
  258. package/src/web/roster/snapshot.js +39 -0
  259. package/src/web/roster/type-filter.js +97 -0
  260. package/src/web/roster/view.js +761 -0
  261. package/src/web/spine/canvas-api-groups.js +267 -0
  262. package/src/web/spine/canvas-api.js +97 -0
  263. package/src/web/spine/canvas-document.js +80 -0
  264. package/src/web/spine/canvas-engine.js +193 -0
  265. package/src/web/spine/canvas-entry.js +28 -0
  266. package/src/web/spine/canvas-pixels.js +36 -0
  267. package/src/web/spine/canvas-runtime.js +127 -0
  268. package/src/web/spine/canvas-view.js +108 -0
  269. package/src/web/spine/canvas.js +7 -0
  270. package/src/web/spine/runtime-hint.js +73 -0
  271. package/src/web/spine/runtimes.js +212 -0
  272. package/src/web/style.css +493 -0
  273. package/tool/lib/bundle-probe-node.mjs +121 -0
  274. package/tool/lib/paths.mjs +20 -0
  275. package/tool/lib/runtime-guides.mjs +65 -0
  276. package/tool/lib/runtime-package.mjs +403 -0
  277. package/tool/lib/spine-meta.mjs +227 -0
  278. package/tool/lib/spine-motions.mjs +104 -0
  279. package/tool/parse-jsonc.mjs +86 -0
  280. package/tool/resolve-live2d-runtimes.mjs +276 -0
  281. package/tool/spine-motion-rules.json +101 -0
@@ -0,0 +1,650 @@
1
+ // plugins.mjs —— 展示类型插件的发现与开关(config/plugins.json)
2
+ //
3
+ // 插件是「放目录即加能力」:这里只负责把 plugins/<id>/plugin.json 读成清单,
4
+ // 并回答「某个 fileKind 当前有没有启用的插件」。
5
+ import fs from 'node:fs';
6
+ import crypto from 'node:crypto';
7
+ import { createLogger } from '../core/log.mjs';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { parseJsonc } from '../../../tool/parse-jsonc.mjs';
11
+ // 插槽目录与校验(S1-a):目录是契约事实,放代码常量表;校验与 /api/plugins 下发读同一份
12
+ import { validateDeclaredSlots, validateDeclaredSettings } from './slot-catalog.mjs';
13
+
14
+ /**
15
+ * 插件角色集合(契约 插件契约 §一):五类角色,**别处不得自创角色名**。
16
+ * 一个插件可以同时担任多个角色(provides 是数组)。
17
+ */
18
+ export const PLUGIN_ROLES = ['asset-provider', 'renderer', 'editor', 'exporter', 'panel'];
19
+
20
+ // ---------------- 契约 §九(受限扩展点 effects / controls)的共用校验 ----------------
21
+ // 这份实现同时被「运行期注册」(本文件 readPluginManifest)与「离线门禁」(tool/check-plugin.mjs)
22
+ // 调用 —— 契约 §9.6 要求两处同口径,各写一遍必然漂移。
23
+
24
+ /** 效果 id 命名规则(契约 §9.1:只允许 [a-z][a-z0-9_-]*) */
25
+ const EFFECT_ID_RE = /^[a-z][a-z0-9_-]*$/;
26
+ /** 参数键的 uniform 命名空间前缀(避免与内建着色器变量撞名,契约 §9.1) */
27
+ const EFFECT_PARAM_KEY_PREFIX = 'u_';
28
+ /** 当前允许的参数类型(契约 §9.1:先只开 float) */
29
+ const EFFECT_PARAM_TYPES = ['float'];
30
+ /** 声明了 effects 就必须在插件脚本里找得到的注册调用(契约 §9.6) */
31
+ const EFFECT_REGISTER_CALL = 'registerEffect(';
32
+
33
+ /**
34
+ * 校验单个效果的 params 数组。
35
+ * @param {unknown} params 原始 params
36
+ * @param {string} where 报错前缀(如 `effects[0]`)
37
+ * @returns {string|null} 错误文本;null 表示通过
38
+ */
39
+ function validateEffectParams(params, where) {
40
+ if (params !== undefined && !Array.isArray(params)) return `${where}.params 必须是数组`;
41
+ const list = Array.isArray(params) ? params : [];
42
+ for (let i = 0; i < list.length; i += 1) {
43
+ const param = list[i];
44
+ const paramWhere = `${where}.params[${i}]`;
45
+ if (!param || typeof param !== 'object' || Array.isArray(param)) return `${paramWhere} 必须是对象`;
46
+ const key = String(param.key || '');
47
+ if (!key) return `${paramWhere}.key 必填`;
48
+ if (!key.startsWith(EFFECT_PARAM_KEY_PREFIX)) {
49
+ return `${paramWhere}.key「${key}」必须以 ${EFFECT_PARAM_KEY_PREFIX} 开头(uniform 命名空间)`;
50
+ }
51
+ if (!EFFECT_PARAM_TYPES.includes(param.type)) {
52
+ return `${paramWhere}.type 非法:${param.type}(当前只允许 ${EFFECT_PARAM_TYPES.join(' / ')})`;
53
+ }
54
+ for (const field of ['min', 'max', 'default']) {
55
+ if (typeof param[field] !== 'number' || !Number.isFinite(param[field])) {
56
+ return `${paramWhere}.${field} 必填且必须是有限数字`;
57
+ }
58
+ }
59
+ if (!(param.min < param.max)) return `${paramWhere} 要求 min < max(当前 ${param.min} / ${param.max})`;
60
+ if (param.default < param.min || param.default > param.max) {
61
+ return `${paramWhere}.default=${param.default} 越界(应在 [${param.min}, ${param.max}])`;
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+
67
+ /**
68
+ * 校验 plugin.json 的 `effects[]`(契约 §9.1 / §9.6)。
69
+ *
70
+ * 三条容易漏的语义:
71
+ * · 声明了 panel 角色却没有任何 effects ⇒ 报错(控件必须对应一个效果,不许出现"点了没反应"的控件)
72
+ * · 声明了 effects,但脚本里 grep 不到 `registerEffect(` ⇒ 报错(不是静默忽略)
73
+ * · id 必须**跨插件全局唯一** —— 因此 `seenIds` 由调用方跨插件持有并传进来
74
+ *
75
+ * @param {unknown} effects 清单里的 effects(可缺省)
76
+ * @param {{provides?: string[], scriptText?: string|null, seenIds?: Set<string>|null}} opts
77
+ * scriptText 为 null 表示"不检查脚本调用"(离线门禁里没读源码时用)
78
+ * @returns {string|null} 错误文本;null 表示通过
79
+ */
80
+ export function validateEffects(effects, opts = {}) {
81
+ const { provides = [], scriptText = null, seenIds = null } = opts;
82
+ if (effects !== undefined && !Array.isArray(effects)) return 'effects 必须是数组';
83
+ const list = Array.isArray(effects) ? effects : [];
84
+ const hasPanelRole = Array.isArray(provides) && provides.includes('panel');
85
+ if (!list.length) {
86
+ return hasPanelRole ? '声明了 panel 角色却没有 effects:控件必须对应一个效果(契约 §9.6)' : null;
87
+ }
88
+ const localIds = new Set();
89
+ for (let i = 0; i < list.length; i += 1) {
90
+ const effect = list[i];
91
+ const where = `effects[${i}]`;
92
+ if (!effect || typeof effect !== 'object' || Array.isArray(effect)) return `${where} 必须是对象`;
93
+ const id = String(effect.id || '');
94
+ if (!id) return `${where}.id 必填`;
95
+ if (!EFFECT_ID_RE.test(id)) return `${where}.id「${id}」命名非法(只允许 [a-z][a-z0-9_-]*)`;
96
+ if (localIds.has(id)) return `${where}.id「${id}」在同一插件内重复`;
97
+ if (seenIds && seenIds.has(id)) return `${where}.id「${id}」与其他插件的效果 id 重复(必须跨插件唯一)`;
98
+ if (typeof effect.label !== 'string' || !effect.label.trim()) return `${where}.label 必填且非空字符串`;
99
+ const paramsErr = validateEffectParams(effect.params, where);
100
+ if (paramsErr) return paramsErr;
101
+ // 全部通过后再登记,避免"半合法"的 id 污染跨插件唯一性判断
102
+ localIds.add(id);
103
+ if (seenIds) seenIds.add(id);
104
+ }
105
+ // 形状全通过后再查脚本调用:否则"脚本缺注册调用"会掩盖真正的形状错误(id/params 才是首要原因)
106
+ if (scriptText !== null && !stripComments(String(scriptText)).includes(EFFECT_REGISTER_CALL)) {
107
+ return `声明了 effects 但插件脚本里没有 ${EFFECT_REGISTER_CALL} 调用(契约 §9.6)`;
108
+ }
109
+ return null;
110
+ }
111
+
112
+ /**
113
+ * 去掉 JS 注释后再判"有没有调用某函数"。
114
+ * 为什么必须剥:注释里写一句「这里要调 registerEffect(」就会让朴素 includes 假通过 ——
115
+ * 本仓库踩到过(样本的说明注释直接让一份非法清单过检)。只剥注释,不做完整语法解析。
116
+ * @param {string} code 源码文本
117
+ * @returns {string} 去掉注释后的文本
118
+ */
119
+ function stripComments(code) {
120
+ return code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
121
+ }
122
+
123
+ /**
124
+ * PL1 校验:`provides` 必选,且只能含已知角色。
125
+ *
126
+ * 为什么必须严格:`provides` 是"这个插件能干什么"的唯一声明,核心按它决定把插件挂到哪个注册表。
127
+ * 未声明或写错角色时,插件会变成一个"注册了但没人认识"的黑盒——静默消失最难查,
128
+ * 所以这里宁可拒绝注册并打印可读原因。
129
+ * @param {unknown} provides
130
+ * @returns {string|null} null = 通过;否则返回可读原因
131
+ */
132
+ export function validateProvides(provides) {
133
+ if (!Array.isArray(provides) || !provides.length) {
134
+ return `未声明 provides(必选;合法角色:${PLUGIN_ROLES.join(' / ')})`;
135
+ }
136
+ const unknown = provides.filter((r) => !PLUGIN_ROLES.includes(String(r)));
137
+ if (unknown.length) {
138
+ return `provides 含未知角色「${unknown.join('、')}」(合法角色:${PLUGIN_ROLES.join(' / ')})`;
139
+ }
140
+ return null;
141
+ }
142
+
143
+ /** @param {{pluginsDir: string, pluginSwitchFile: string}} p */
144
+
145
+ /**
146
+ * 读一个带 plugin.json 清单的插件目录。
147
+ * 清单里的展示/契约字段原样透传给前端:label(角色库变体标签文字)、version、
148
+ * assets(插件自己的数据文件,如 profiles/<游戏>.json)、fileKinds(能读哪几类资源,
149
+ * 服务端据此决定条目可见性);produces/errorCodes 是可选能力自描述,未声明时给 null/[]。
150
+ * @returns {object|null} 不可用(清单坏/没有存在的 js/被禁用)时返回 null
151
+ */
152
+ /** 服务端日志(统一形状 {at,level,tag,msg,detail}) */
153
+ const log = createLogger('plugins');
154
+
155
+ /**
156
+ * 插件被开关禁用时的统一日志文案。
157
+ * 抽出来不是为了省字,而是这三处(清单扫描 / 平铺单文件 / 无清单目录)必须说同一句话 ——
158
+ * 分散成三个字面量后,改文案就得改三处,漏一处日志口径就不一致了。
159
+ */
160
+ const LOG_DISABLED = '已按 config/plugins.json 禁用';
161
+
162
+ /**
163
+ * 归一化清单里的 runtime 字段。
164
+ * 缺省 "node" = 历史行为(vm 沙盒里按 files[] 顺序跑 classic script);
165
+ * "exec" = 插件目录里有一个可执行文件,宿主 spawn 它并用帧协议通信(**没有 js 要加载**)。
166
+ * @param {unknown} raw 清单里的原始值
167
+ * @returns {string}
168
+ */
169
+ function normalizeRuntime(raw) {
170
+ if (raw === undefined || raw === null || raw === '') return 'node';
171
+ return String(raw);
172
+ }
173
+
174
+ /**
175
+ * 按 runtime 算出"这个插件要加载哪些 js 文件",并做对应的存在性校验。
176
+ *
177
+ * 为什么必须分流:node 插件靠 files[] 顺序加载脚本,而 exec 插件根本没有 js
178
+ * (它的入口是 entry 指向的可执行文件)。若不分流,兜底逻辑会把 entry(如 bin/unpack)
179
+ * 当成 js 读进沙盒,用户看到的是一堆看不懂的语法错误而不是"清单写错了"。
180
+ * @param {{m: object, runtime: string, dirName: string, isFile: Function}} p
181
+ * @returns {{ok: true, files: string[]} | {ok: false, reason: string}}
182
+ */
183
+ function resolveLoadFiles({ m, runtime, dirName, isFile }) {
184
+ if (runtime === 'exec') {
185
+ if (!m.entry) return { ok: false, reason: 'runtime:"exec" 的插件缺 entry(相对插件目录的可执行路径),跳过' };
186
+ return { ok: true, files: [] };
187
+ }
188
+ const declared = Array.isArray(m.files) && m.files.length ? m.files : (m.entry ? [m.entry] : ['index.js']);
189
+ const files = declared.map((f) => String(f).replace(/^\.?\//, '')).filter((f) => isFile(`${dirName}/${f}`));
190
+ if (!files.length) return { ok: false, reason: '清单里没有任何存在的 js,跳过' };
191
+ return { ok: true, files };
192
+ }
193
+
194
+ export function readPluginManifest(pluginsDir, dirName, isFile, disabled, seenEffectIds = null) {
195
+ let m = null;
196
+ try {
197
+ m = JSON.parse(fs.readFileSync(path.join(pluginsDir, dirName, 'plugin.json'), 'utf8'));
198
+ } catch (err) {
199
+ log.warn('plugin.json 解析失败,跳过该插件', { plugin: dirName, err: err.message });
200
+ return null;
201
+ }
202
+ const runtime = normalizeRuntime(m.runtime);
203
+ // 按 runtime 决定"要加载哪些 js"(exec 插件不加载任何 js),并做对应的存在性校验
204
+ const filesResult = resolveLoadFiles({ m, runtime, dirName, isFile });
205
+ if (!filesResult.ok) {
206
+ log.warn(filesResult.reason, { plugin: dirName });
207
+ return null;
208
+ }
209
+ const files = filesResult.files;
210
+ const pid = String(m.id || dirName);
211
+ // 插槽与设置的声明校验(S1-a):**逐项降级**——非法项只记日志并进 errors,
212
+ // 绝不 return null(插件因为一个拼错的插槽名就从 /api/plugins 消失,用户只会看到"插件没了")。
213
+ // 位置必须在 pid 之后:settings 的 key 前缀校验要拿 pluginId(`plugin.<id>.`)。
214
+ const slotCheck = validateDeclaredSlots(m.slots);
215
+ const settingsCheck = validateDeclaredSettings(m.settings, pid);
216
+ if (slotCheck.errors.length) log.warn('插槽声明有非法项(已逐项忽略,插件仍注册)', { plugin: pid, errors: slotCheck.errors });
217
+ if (settingsCheck.errors.length) log.warn('设置声明有非法项(已逐项忽略,插件仍注册)', { plugin: pid, errors: settingsCheck.errors });
218
+ if (isPluginDisabled(disabled, pid, dirName)) {
219
+ log.info(LOG_DISABLED, { plugin: pid });
220
+ return null;
221
+ }
222
+ // PL1:provides 必选且角色合法,否则整体不注册(并打印可读原因,避免静默消失)
223
+ const badProvides = validateProvides(m.provides);
224
+ if (badProvides) {
225
+ log.warn('注册失败', { plugin: pid, reason: badProvides });
226
+ return null;
227
+ }
228
+ // 契约 §九:effects 与 provides 的 panel 角色互相约束,非法就不进入运行期(不静默忽略)
229
+ // 只在**声明了 effects** 时才读脚本内容做 grep(无 effects 的插件零额外 I/O)
230
+ let scriptText = null;
231
+ if (Array.isArray(m.effects) && m.effects.length) {
232
+ try {
233
+ scriptText = files.map((f) => fs.readFileSync(path.join(pluginsDir, dirName, f), 'utf8')).join('\n');
234
+ } catch (e) {
235
+ log.warn('效果校验:读取脚本失败,按"找不到 registerEffect(" 处理', { plugin: pid, err: e.message });
236
+ scriptText = '';
237
+ }
238
+ }
239
+ const badEffects = validateEffects(m.effects, { provides: m.provides, scriptText, seenIds: seenEffectIds });
240
+ if (badEffects) {
241
+ log.warn('注册失败', { plugin: pid, reason: badEffects });
242
+ return null;
243
+ }
244
+ return {
245
+ id: pid,
246
+ name: String(m.name || dirName),
247
+ label: m.label ? String(m.label) : null,
248
+ /** 插件角色(契约 §一):决定核心把它挂到哪个注册表 */
249
+ provides: m.provides.map(String),
250
+ version: m.version === undefined ? null : m.version,
251
+ assets: Array.isArray(m.assets) ? m.assets.map(String) : [],
252
+ fileKinds: Array.isArray(m.fileKinds) ? m.fileKinds.map(String) : [],
253
+ /**
254
+ * 插槽声明(S1-a):只放**已通过校验**的那几项(名字在目录里、阶段已开放、risk 与目录一致)。
255
+ * 非法项不进这里、也不会让插件消失 —— 而是进 slotErrors。
256
+ * 老插件没有 slots 字段 ⇒ 空数组,行为零变化。
257
+ */
258
+ slots: slotCheck.slots,
259
+ /** 插槽声明的问题项(why: unknown-slot / slot-not-open / bad-shape) */
260
+ slotErrors: slotCheck.errors,
261
+ /** 声明式设置键(S1-a,只 enum / boolean):只放通过校验的,字段已归一 */
262
+ settings: settingsCheck.settings,
263
+ /** 设置声明的问题项(why: bad-shape / bad-type / bad-options / bad-default / duplicate-key) */
264
+ settingsErrors: settingsCheck.errors,
265
+ /**
266
+ * 展示色(插件自声明,如 "#2b6cb0"):列表徽标用它上色,前端不再按 kind 硬编码。
267
+ * 只接受 #rgb / #rrggbb / #rrggbbaa,非法值一律按"未声明"处理(给 null ⇒ 前端走中性色),
268
+ * 绝不替插件猜一个颜色 —— 猜错就等于用颜色撒谎。
269
+ */
270
+ accent: /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(String(m.accent || '')) ? String(m.accent) : null,
271
+ /**
272
+ * 匹配优先级(数字,越大越靠前):**只用于展示归属的排序**,不参与装载决策。
273
+ * 多个插件都声明能读同一类资源(fileKinds 都是 bundle)时,解包前无法判定谁对,
274
+ * 于是"已解包过的包以实际命中的插件为准,没解包过的一律中性色",
275
+ * 优先级只决定候选列表在提示里的先后。
276
+ */
277
+ matchPriority: Number.isFinite(Number(m.matchPriority)) ? Number(m.matchPriority) : 0,
278
+ produces: m.produces && typeof m.produces === 'object' ? m.produces : null,
279
+ errorCodes: Array.isArray(m.errorCodes) ? m.errorCodes.map(String) : [],
280
+ files: files.map((f) => `${dirName}/${f}`),
281
+ /** 运行时("node" | "exec",缺省 node):决定宿主用哪个适配器装载本插件 */
282
+ runtime,
283
+ /** exec 运行时的可执行入口(相对插件目录);node 运行时为 null(入口由 files[] 末项决定) */
284
+ entry: m.entry ? String(m.entry) : null,
285
+ /** exec 运行时的帧协议版本;node 运行时为 null */
286
+ frameProtocol: m.frameProtocol === undefined || m.frameProtocol === null ? null : Number(m.frameProtocol),
287
+ /**
288
+ * exec 运行时:解释器可执行文件(缺省由适配器按常见路径探)。
289
+ * 为什么要在清单里声明:不同插件的依赖装在不同 Python 版本里,宿主猜不出来。
290
+ */
291
+ interpreter: m.interpreter ? String(m.interpreter) : null,
292
+ /**
293
+ * exec 运行时:依赖目录。**支持绝对路径** —— 本项目仓库常在 CIFS 上(noexec、
294
+ * 且不能 utime .so),带 C 扩展的依赖装进去会 PermissionError,只能放本地盘,
295
+ * 由插件在自己的 README 写明位置。相对路径按插件目录解析。
296
+ */
297
+ depsDir: m.depsDir ? String(m.depsDir) : null,
298
+ /** exec 运行时:单次请求超时(毫秒);缺省由适配器给 120s */
299
+ requestTimeoutMs: Number.isFinite(Number(m.requestTimeoutMs)) ? Number(m.requestTimeoutMs) : null,
300
+ /** 受限扩展点(契约 §九):空数组表示"没声明",前端据此建能力键白名单 */
301
+ effects: Array.isArray(m.effects) ? m.effects : [],
302
+ manifest: true,
303
+ };
304
+ }
305
+
306
+ /**
307
+ * 扫描展示类型插件目录,返回**按序加载**的插件清单。
308
+ *
309
+ * **只支持一种落盘形态**:`plugins/<id>/plugin.json` 清单
310
+ * (`{ id, name, provides[], entry, files[] }`,files 里的 js 按数组顺序加载;
311
+ * 未写 files 时用 entry;都没有则退回 index.js)。
312
+ *
313
+ * 历史上的另外两种形态——目录内顶层 js(无清单)、平铺 `<name>.js`——**不再注册**:
314
+ * 它们无法声明 `provides`,而角色是"这个插件能干什么"的唯一声明(契约 插件契约 §二 / PL1);
315
+ * 缺了它,插件就是一个"注册了但没人认识"的黑盒,静默消失最难查。
316
+ * 所以这两种形态一律跳过,并打印可读原因(不是静默忽略)。
317
+ *
318
+ * 只做**只读发现**:不解析 js 内容、不校验语法(加载与隔离由前端 plugin-host.js 负责)。
319
+ * files 用相对 pluginsDir 的 POSIX 风格路径(前端拼 /plugins/<rel>)。
320
+ *
321
+ * @returns {Array<{id:string,name:string,files:string[],manifest:boolean}>}
322
+ */
323
+ export function readPlugins(pluginsDir, switchFile) {
324
+ // 磁盘插件 + 内置提供者,**同一列表**返回:调用方(/api/plugins、catalog 聚合、角色索引)
325
+ // 不需要知道"谁是内置的" —— 契约《条目状态与展示归属》§一·五 要求两者平权;
326
+ // 靠 if (kind==='spine') 之类分支区分,就退化回"两套平行体系"了。
327
+ // 顺序:磁盘插件在前(保持既有稳定顺序),内置项在后(展示先后由 matchPriority 决定)。
328
+ //
329
+ // 缓存:/api/catalog 一次请求会调本函数多次(实测 3 次),每次都重新扫目录、读全部
330
+ // plugin.json、还要重算内置项的内容哈希 ≈ 16ms/请求,纯浪费。这里按"插件目录 + 开关文件 +
331
+ // 每个 plugin.json 的 mtime/size"做签名缓存。
332
+ // ⚠️ 不能只看目录 mtime:改 plugin.json 的**内容**不改目录 mtime(只有增删条目才改),
333
+ // 那样会出现"插件升级了但清单还是旧的" —— 比不缓存更危险。
334
+ const sig = pluginsSignature(pluginsDir, switchFile);
335
+ if (readPluginsCache.sig === sig) return readPluginsCache.list.slice();
336
+ const list = readDiskPlugins(pluginsDir, switchFile).concat(readBuiltinProviders());
337
+ readPluginsCache = { sig, list };
338
+ return list.slice();
339
+ }
340
+
341
+ /** readPlugins 的进程内缓存(签名不符才重建) */
342
+ let readPluginsCache = { sig: null, list: [] };
343
+
344
+ /**
345
+ * 插件清单的指纹:目录项 + 每个 plugin.json 的 (mtime, size) + 开关文件的 (mtime, size)。
346
+ * 任一项变化 ⇒ 签名变 ⇒ 缓存失效重扫。
347
+ * @param {string} pluginsDir
348
+ * @param {string} switchFile
349
+ * @returns {string}
350
+ */
351
+ function pluginsSignature(pluginsDir, switchFile) {
352
+ const parts = [String(pluginsDir)];
353
+ try {
354
+ const st = fs.statSync(switchFile);
355
+ parts.push(`sw:${st.mtimeMs}:${st.size}`);
356
+ } catch { parts.push('sw:-'); }
357
+ let ents = [];
358
+ try { ents = fs.readdirSync(pluginsDir, { withFileTypes: true }); }
359
+ catch { return `${parts.join('|')}|dir:-`; }
360
+ for (const e of ents.sort((a, b) => a.name.localeCompare(b.name))) {
361
+ if (!e.isDirectory()) continue;
362
+ try {
363
+ const st = fs.statSync(path.join(pluginsDir, e.name, 'plugin.json'));
364
+ parts.push(`${e.name}:${st.mtimeMs}:${st.size}`);
365
+ } catch { parts.push(`${e.name}:-`); }
366
+ }
367
+ return parts.join('|');
368
+ }
369
+
370
+ /**
371
+ * 只读磁盘插件(原 readPlugins 的逻辑,未改一行)。
372
+ * @param {string} pluginsDir
373
+ * @param {string} switchFile
374
+ */
375
+ function readDiskPlugins(pluginsDir, switchFile) {
376
+ const out = [];
377
+ const disabled = readPluginSwitch(switchFile).disabled;
378
+ if (!fs.existsSync(pluginsDir) || !fs.statSync(pluginsDir).isDirectory()) return out;
379
+ let entries = [];
380
+ try { entries = fs.readdirSync(pluginsDir, { withFileTypes: true }); } catch { return out; }
381
+ entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
382
+
383
+ // 效果 id 必须跨插件全局唯一(契约 §9.1)⇒ 整个扫描共享一个已见集合
384
+ const seenEffectIds = new Set();
385
+
386
+ const isFile = (rel) => {
387
+ const abs = path.join(pluginsDir, rel);
388
+ try { return fs.existsSync(abs) && fs.statSync(abs).isFile(); } catch { return false; }
389
+ };
390
+
391
+ for (const e of entries) {
392
+ if (e.name.startsWith('.')) continue;
393
+ if (e.isFile() && e.name.toLowerCase().endsWith('.js')) {
394
+ const id = e.name.replace(/\.js$/i, '');
395
+ if (isPluginDisabled(disabled, id, id)) { log.info(LOG_DISABLED, { plugin: id }); continue; }
396
+ // 平铺单文件没有清单 ⇒ provides 无从声明 ⇒ 按契约 PL1 不注册(日志说清,别静默消失)
397
+ log.warn('未注册:缺少 plugin.json,provides 无法声明', { plugin: id, roles: PLUGIN_ROLES.join(' / ') });
398
+ continue;
399
+ }
400
+ if (!e.isDirectory()) continue;
401
+
402
+ const manifestPath = path.join(pluginsDir, e.name, 'plugin.json');
403
+ if (fs.existsSync(manifestPath) && fs.statSync(manifestPath).isFile()) {
404
+ const entry = readPluginManifest(pluginsDir, e.name, isFile, disabled, seenEffectIds);
405
+ if (entry) out.push(entry);
406
+ continue;
407
+ }
408
+
409
+ let inner = [];
410
+ try { inner = fs.readdirSync(path.join(pluginsDir, e.name), { withFileTypes: true }); } catch { continue; }
411
+ const js = inner.filter((x) => x.isFile() && x.name.toLowerCase().endsWith('.js')).map((x) => x.name).sort();
412
+ if (!js.length) continue;
413
+ if (isPluginDisabled(disabled, e.name, e.name)) { log.info(LOG_DISABLED, { plugin: e.name }); continue; }
414
+ // 目录里没有 plugin.json ⇒ 同上,provides 无从声明 ⇒ 不注册
415
+ log.warn('未注册:目录内缺少 plugin.json,provides 无法声明', { plugin: e.name, roles: PLUGIN_ROLES.join(' / ') });
416
+ continue;
417
+ }
418
+ return out;
419
+ }
420
+
421
+ /**
422
+ * 读插件开关配置(config/plugins.json,JSONC)。
423
+ *
424
+ * 为什么用配置文件而不是 env:这是**结构性列表**(可增删多个条目),按项目约定
425
+ * 结构化配置一律 JSON 作唯一权威,env 只承担部署差异单值。
426
+ * 容错:文件缺失、解析失败、字段类型不对 → 一律当作「全部启用」并告警,不因配置写错让插件静默消失。
427
+ *
428
+ * @returns {{disabled: Set<string>}}
429
+ */
430
+ export function readPluginSwitch(switchFile) {
431
+ const empty = { disabled: new Set() };
432
+ if (!fs.existsSync(switchFile)) return empty;
433
+ let cfg = null;
434
+ try { cfg = parseJsonc(fs.readFileSync(switchFile, 'utf8'), 'config/plugins.json'); }
435
+ catch (err) { log.warn('config/plugins.json 解析失败,按全部启用处理', { err: err.message }); return empty; }
436
+ const list = Array.isArray(cfg && cfg.disabled) ? cfg.disabled : [];
437
+ const disabled = new Set(list.map((x) => String(x).trim()).filter(Boolean));
438
+ if (disabled.size) log.info('config/plugins.json 禁用插件', { count: disabled.size, plugins: [...disabled] });
439
+ return { disabled };
440
+ }
441
+
442
+ /** 插件是否被禁用:id 与目录名任一命中即禁用(清单里的 id 可能与目录名不同)。 */
443
+ export function isPluginDisabled(disabled, id, dirName) {
444
+ return disabled.has(String(id)) || (dirName ? disabled.has(String(dirName)) : false);
445
+ }
446
+
447
+ /**
448
+ * 当前**启用**的插件声明的 fileKinds 集合(如 'bundle' = Unity 资源包单文件)。
449
+ *
450
+ * 用途:资源条目的可见性由「有没有插件能读它」决定 —— 插件未启用时,它提供的能力整体消失,
451
+ * 角色库里就不该再出现这类条目(条目本身是插件能力的一部分)。
452
+ *
453
+ * @returns {Set<string>}
454
+ */
455
+ export function enabledPluginKinds(pluginsDir, switchFile) {
456
+ const kinds = new Set();
457
+ for (const p of readPlugins(pluginsDir, switchFile)) {
458
+ for (const k of (Array.isArray(p.fileKinds) ? p.fileKinds : [])) kinds.add(String(k));
459
+ }
460
+ return kinds;
461
+ }
462
+
463
+ /**
464
+ * 按**魔数**识别资源包容器(kind:"bundle" 的资源根用)。
465
+ *
466
+ * 为什么不能按扩展名:碧蓝航线的 Live2D 资源包就是**无扩展名**的单文件(实测样本 `adaerbote_3`),
467
+ * 按扩展名过滤会一个都列不出来。容器标识在文件头,读前 8 字节即可判定,成本可忽略。
468
+ *
469
+ * @param {string} abs 文件绝对路径
470
+ * @returns {'unityfs'|null} 'unityfs' = UnityFS AssetBundle(Unity 5+ 的打包格式)
471
+ */
472
+ export function detectBundleContainer(abs) {
473
+ let fd = null;
474
+ try {
475
+ fd = fs.openSync(abs, 'r');
476
+ const buf = Buffer.alloc(8);
477
+ const n = fs.readSync(fd, buf, 0, 8, 0);
478
+ if (n < 8) return null;
479
+ if (buf.toString('latin1') === 'UnityFS\0') return 'unityfs';
480
+ return null;
481
+ } catch {
482
+ return null;
483
+ } finally {
484
+ if (fd !== null) { try { fs.closeSync(fd); } catch { /* 忽略 */ } }
485
+ }
486
+ }
487
+
488
+ /**
489
+ * 绑定型适配器:把插件目录与开关文件固定,调用方无需重复传参。
490
+ * @param {{pluginsDir: string, pluginSwitchFile: string}} p
491
+ */
492
+ export function createPlugins(p) {
493
+ // 绑一次读取函数:readPlugins 与 roleIndex 都在同一个闭包里复用它,
494
+ // 而不是各自写一遍 `readPlugins(p.pluginsDir, p.pluginSwitchFile)`(两处实参完全相同,
495
+ // 改一处漏一处就会变成"目录换了、角色表还是旧的")。
496
+ // 实际扫描频率由 readPlugins 自己的签名缓存兜住(同一请求内多次调用只扫一次)。
497
+ const loadPlugins = () => readPlugins(p.pluginsDir, p.pluginSwitchFile);
498
+ return {
499
+ readPlugins: loadPlugins,
500
+ /**
501
+ * 角色注册表:角色 → 插件 id 列表(契约 §一 五类角色)。
502
+ * 只列**已注册成功**的插件(PL1 校验通过的),所以这里是"核心真正认得的角色面"。
503
+ * @returns {Record<string, string[]>}
504
+ */
505
+ roleIndex: () => buildRoleIndex(loadPlugins()),
506
+ readPluginSwitch: () => readPluginSwitch(p.pluginSwitchFile),
507
+ isPluginDisabled,
508
+ enabledPluginKinds: () => enabledPluginKinds(p.pluginsDir, p.pluginSwitchFile),
509
+ detectBundleContainer,
510
+ };
511
+ }
512
+
513
+ /**
514
+ * 由插件清单构建角色注册表(角色 → 插件 id 列表)。
515
+ * @param {Array<{id: string, provides?: string[]}>} list
516
+ * @returns {Record<string, string[]>} 五个角色键都齐全(无插件时为空数组)
517
+ */
518
+ export function buildRoleIndex(list) {
519
+ const index = {};
520
+ for (const role of PLUGIN_ROLES) index[role] = [];
521
+ for (const p of list || []) {
522
+ for (const role of (p && p.provides) || []) {
523
+ if (index[role] && !index[role].includes(p.id)) index[role].push(p.id);
524
+ }
525
+ }
526
+ return index;
527
+ }
528
+
529
+ // ============================================================================
530
+ // 内置提供者(契约《条目状态与展示归属》§一·五)
531
+ //
532
+ // 内置 spine/live2d 装载器没有插件目录、没有 plugin.json,但它对"认领范围 / 展示色 / 优先级"
533
+ // 的声明必须与插件同源、同表 —— 否则 catalog 又要写 if (kind==='spine'),契约就白定了。
534
+ // 所以给它们一份**同形声明文件**(builtin-providers.json),在这里并入 readPlugins 的结果。
535
+ //
536
+ // version 不写死:由 builtinVersionHash() 按"判定相关文件"的内容哈希派生 ——
537
+ // 判定逻辑一改,指纹自动变,旧的"不支持/失败"结论整批失效(契约 §三 规则①)。
538
+ // 免人工递增版本号,也免"改了判据忘了升版本"导致旧 ⚠ 一直误导。
539
+ // ============================================================================
540
+
541
+ /** 项目根(本文件在 src/server/plugins/ ⇒ 三层) */
542
+ const REGISTRY_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
543
+
544
+ /**
545
+ * 参与内置项版本哈希的文件。
546
+ * 选它们的理由:这三处共同决定"目录型条目能不能装"的结论 ——
547
+ * · builtin-providers.json —— 声明本身(accent/label/fileKinds 变了,展示与聚合就变了)
548
+ * · src/server/data/catalog.mjs —— 服务端目录型 support 判定(缺件 missingTextures 在此算)
549
+ * · src/web/spine/probe.mjs —— 前端目录型认领判定(决定 loadResult 是否还有效)
550
+ * 缺文件就跳过(不炸):新环境里某个模块被挪走,也不该让插件列表整个读不出来。
551
+ */
552
+ const BUILTIN_VERSION_FILES = [
553
+ 'src/server/plugins/builtin-providers.json',
554
+ 'src/server/data/catalog.mjs',
555
+ 'src/web/spine/probe.mjs',
556
+ ];
557
+
558
+ /**
559
+ * 内置提供者的版本号:判定相关文件内容哈希的前 8 位。
560
+ * @param {string} [root] 项目根(缺省按本文件位置推)
561
+ * @returns {string}
562
+ */
563
+ export function builtinVersionHash(root = REGISTRY_ROOT) {
564
+ // 缓存:参与哈希的文件里有 src/server/data/catalog.mjs(770 行),每算一次要读它 + 做 sha256
565
+ // ≈1.8ms。readPlugins 每请求会被调多次 ⇒ 不缓存等于白烧 CPU。
566
+ // 缓存键用 (mtime, size):文件内容一变就重算,不会返回过期版本号。
567
+ let sig = '';
568
+ const stats = [];
569
+ for (const rel of BUILTIN_VERSION_FILES) {
570
+ try {
571
+ const st = fs.statSync(path.join(root, rel));
572
+ stats.push({ rel, ok: true });
573
+ sig += `${rel}:${st.mtimeMs}:${st.size};`;
574
+ } catch {
575
+ stats.push({ rel, ok: false });
576
+ sig += `${rel}:-;`;
577
+ }
578
+ }
579
+ if (builtinHashCache.sig === sig) return builtinHashCache.value;
580
+
581
+ const h = crypto.createHash('sha256');
582
+ let used = 0;
583
+ for (const { rel, ok } of stats) {
584
+ if (!ok) continue;
585
+ try {
586
+ h.update(rel);
587
+ h.update(fs.readFileSync(path.join(root, rel)));
588
+ used += 1;
589
+ } catch { /* 单个文件读不了不影响其它文件 */ }
590
+ }
591
+ const hashValue = used ? h.digest('hex').slice(0, 8) : '0';
592
+ builtinHashCache = { sig, value: hashValue };
593
+ return hashValue;
594
+ }
595
+
596
+ /** builtinVersionHash 的进程内缓存(签名 = 参与文件的 mtime/size 串) */
597
+ let builtinHashCache = { sig: null, value: '0' };
598
+
599
+ /**
600
+ * 读内置提供者声明,返回与磁盘插件**同形**的对象(可直接被 catalog / 角色索引消费)。
601
+ * 字段口径与 readPluginManifest 对齐:缺省值一律给"未声明"(null/[]),不替它猜。
602
+ * @param {string} [root]
603
+ * @returns {Array<object>}
604
+ */
605
+ export function readBuiltinProviders(root = REGISTRY_ROOT) {
606
+ const file = path.join(root, 'src/server/plugins/builtin-providers.json');
607
+ let doc = null;
608
+ try {
609
+ if (!fs.existsSync(file)) return [];
610
+ // 声明文件允许带 _comment(团队注释),解析仍走项目的 JSONC 解析器,与插件清单同一条路径
611
+ doc = parseJsonc(fs.readFileSync(file, 'utf8'), 'builtin-providers.json');
612
+ } catch (err) {
613
+ log.warn('内置提供者声明解析失败,按"没有内置项"处理', { err: err.message });
614
+ return [];
615
+ }
616
+ const list = (doc && Array.isArray(doc.providers)) ? doc.providers : [];
617
+ const version = builtinVersionHash(root);
618
+ const out = [];
619
+ for (const m of list) {
620
+ if (!m || !m.id) continue;
621
+ out.push({
622
+ id: String(m.id),
623
+ name: String(m.name || m.id),
624
+ label: m.label ? String(m.label) : null,
625
+ // 内置装载器只提供 asset-provider 这一个角色(它们不渲染面板、不挂 UI)
626
+ provides: ['asset-provider'],
627
+ version,
628
+ assets: [],
629
+ fileKinds: Array.isArray(m.fileKinds) ? m.fileKinds.map(String) : [],
630
+ accent: /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(String(m.accent || '')) ? String(m.accent) : null,
631
+ matchPriority: Number.isFinite(Number(m.matchPriority)) ? Number(m.matchPriority) : 0,
632
+ produces: m.produces && typeof m.produces === 'object' ? m.produces : null,
633
+ errorCodes: [],
634
+ files: [],
635
+ runtime: null,
636
+ entry: null,
637
+ frameProtocol: null,
638
+ effects: [],
639
+ // 内置装载器不渲染面板 ⇒ 四个字段恒为空数组;但**必须有**(缺字段会迫使消费方写特例)
640
+ slots: [],
641
+ slotErrors: [],
642
+ settings: [],
643
+ settingsErrors: [],
644
+ manifest: false,
645
+ /** 唯一用于区分来源的标记(诊断展示用;判定与聚合一律不读它,避免变成第二套分支) */
646
+ builtin: true,
647
+ });
648
+ }
649
+ return out;
650
+ }