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,499 @@
1
+ // server/data/entry-states.mjs —— 条目状态的**服务端权威存储**(契约《条目状态与展示归属》§三)
2
+ //
3
+ // 它只干三件事:
4
+ // ① 记住"最近一次真实装载尝试"的结果(loadResult)
5
+ // ② 判定这条记录是否还有效(三条失效规则)
6
+ // ③ 由 ①+② 与调用方给的上下文,**算出** display / support / loadResult 三个字段(recompute)
7
+ //
8
+ // 刻意不做的事:不读资源包、不扫目录、不知道 catalog 长什么样。
9
+ // ctx 是调用方喂进来的(provider 注册表、候选、缺件列表、依赖包结论…),
10
+ // 这样它可以被单测直接构造,也保证"展示三字段只由服务端产出"(INV-D1)不是靠约定。
11
+ //
12
+ // 键形态(契约 §三 裁定):统一 `${rootId}/${rel}`
13
+ // 目录型 rel = srcDir + 文件名;容器型 rel = 包相对路径。
14
+ // 不用条目 id:容器 id 是把 rel 的 / 换成 _ 派生的、不可逆、可能撞键。
15
+
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { normalizeReason, reasonText, REASONS } from '../../shared/entry-reasons.mjs';
20
+
21
+ const DEFAULT_DEBOUNCE_MS = 300;
22
+ const DEFAULT_MAX_KEYS = 5000;
23
+
24
+ /**
25
+ * 指纹归一:`undefined`/`null` 一律变 `null`(= "写入方没给"),其余转字符串。
26
+ *
27
+ * 为什么必须专门归一而不是直接 String():`String(null)` 得到的是 `'null'`(四个字符),
28
+ * 会被当成一个**真实指纹**落进记录里 —— 它既骗过"这条结论有没有指纹"的判断,
29
+ * 又可能和别的脏值撞成相等,于是规则①静默失效(最难查的一类)。
30
+ * 归一成 null 之后,读路径才能把它判为"过期"。
31
+ * @param {unknown} v
32
+ * @returns {string|null}
33
+ */
34
+ function fingerprintOrNull(v) {
35
+ return v === undefined || v === null ? null : String(v);
36
+ }
37
+
38
+ /**
39
+ * 哪些失败原因属于"确定性判定",可以直接升格成 support=unsupported。
40
+ *
41
+ * 判据:这个原因说明**资源本身**就是这样(换台机器、换个时刻照样失败);
42
+ * 而 insufficient-memory / bad-args / not-found / unpack-failed 是环境或请求问题,
43
+ * 标成 unsupported 会让用户以为模型坏了(下次环境好了它其实能装)。
44
+ */
45
+ const DEFINITE_UNSUPPORTED_REASONS = new Set([
46
+ REASONS.ASSET_DEPENDENCY, // 美术依赖包:包内就没有骨架,永远装不动
47
+ REASONS.MISSING_TEXTURES, // 贴图缺件:文件不在就是不在
48
+ REASONS.TEX_FORMAT_UNSUPPORTED, // 不支持的纹理格式:能力缺口
49
+ REASONS.NOT_UNITYFS, // 不是 UnityFS:格式不对
50
+ REASONS.NO_CAB, // 没有资产节点:不是资产包
51
+ ]);
52
+
53
+ /** 把 provider 列表压成失效指纹串(① 用):按 id 排序 ⇒ 顺序无关、可比对 */
54
+ /**
55
+ * @param {Array<{id: string, version?: unknown}>} providers
56
+ * @returns {string} 形如 'builtin-live2d@1|unity-spine-bundle@4.0.1'
57
+ */
58
+ export function providerFingerprintOf(providers) {
59
+ return (Array.isArray(providers) ? providers : [])
60
+ .filter((p) => p && p.id)
61
+ .map((p) => `${p.id}@${p.version === undefined || p.version === null ? '?' : String(p.version)}`)
62
+ .sort()
63
+ .join('|');
64
+ }
65
+
66
+ /**
67
+ * 建一个条目状态存储。
68
+ * @param {{file: string, log?: Function, now?: () => number, debounceMs?: number, maxKeys?: number}} opts
69
+ */
70
+ export function createEntryStates(opts = {}) {
71
+ const file = String(opts.file || '');
72
+ const log = typeof opts.log === 'function' ? opts.log : () => {};
73
+ const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
74
+ const debounceMs = Number.isFinite(opts.debounceMs) ? Number(opts.debounceMs) : DEFAULT_DEBOUNCE_MS;
75
+ const maxKeys = Number.isFinite(opts.maxKeys) ? Number(opts.maxKeys) : DEFAULT_MAX_KEYS;
76
+
77
+ /** @type {Map<string, any>} key → { key, loadResult, fingerprints, updatedAt } */
78
+ const table = new Map();
79
+ let loaded = false;
80
+ let dirty = false;
81
+ let timer = null;
82
+ let writing = null;
83
+ let lastFlushAt = null;
84
+
85
+ /** ③ 服务端重启:从 json 读回;读不回 = 空表(可接受,重测即得) */
86
+ function load() {
87
+ if (loaded) return;
88
+ loaded = true;
89
+ if (!file) return;
90
+ try {
91
+ if (!fs.existsSync(file)) return;
92
+ const raw = fs.readFileSync(file, 'utf8');
93
+ const data = JSON.parse(raw);
94
+ const entries = (data && data.entries) || {};
95
+ for (const [key, record] of Object.entries(entries)) {
96
+ if (record && typeof record === 'object') table.set(key, { key, ...record });
97
+ }
98
+ log('info', 'entry-states 已读回', { keys: table.size });
99
+ } catch (e) {
100
+ // 坏文件不当致命:整表按空处理(契约 §三 规则③)
101
+ log('warn', `entry-states 读取失败,按空表处理:${e.message}`, { file });
102
+ table.clear();
103
+ }
104
+ }
105
+
106
+ function markDirty() {
107
+ dirty = true;
108
+ if (timer) return;
109
+ timer = setTimeout(() => { timer = null; flush().catch(() => {}); }, debounceMs);
110
+ if (typeof timer.unref === 'function') timer.unref();
111
+ }
112
+
113
+ /** 落盘:合并去抖(CIFS 上禁止一请求一写) */
114
+ async function flush(force = false) {
115
+ if (!file) return false;
116
+ // 上一轮还在写盘:**必须先等它结束再判 dirty**。
117
+ // 早前写成 `if (writing) return writing;` 且放在 `dirty = false` 之后 —— 于是
118
+ // 第二轮请求直接拿到上一轮的 promise 返回,而脏标记已被清掉 ⇒ 新写入的记录
119
+ // 永远不会落盘(实测:连续两次 put,文件里只有第一条)。
120
+ if (writing) {
121
+ try { await writing; } catch { /* 上一轮自己会记日志 */ }
122
+ }
123
+ if (!dirty && !force) return false;
124
+ dirty = false;
125
+ const snapshot = {};
126
+ for (const [key, record] of table) snapshot[key] = record;
127
+ const payload = JSON.stringify({ version: 1, savedAt: now(), entries: snapshot });
128
+ writing = (async () => {
129
+ try {
130
+ // mkdirSync({recursive:true}) 本身幂等:不需要先 existsSync 探一次(那是白多一次 stat,
131
+ // 而且探完到写之间目录仍可能被删,探测并不能保证什么)。
132
+ fs.mkdirSync(path.dirname(file), { recursive: true });
133
+ const tmp = `${file}.tmp-${process.pid}`;
134
+ fs.writeFileSync(tmp, payload, 'utf8');
135
+ fs.renameSync(tmp, file); // 原子替换:写一半崩掉不会留半张表
136
+ lastFlushAt = now();
137
+ return true;
138
+ } catch (e) {
139
+ dirty = true; // 写失败 ⇒ 保持脏,下次再试(不丢数据)
140
+ log('warn', `entry-states 写盘失败:${e.message}`, { file });
141
+ return false;
142
+ } finally {
143
+ writing = null;
144
+ }
145
+ })();
146
+ return writing;
147
+ }
148
+
149
+ /** 超限淘汰:按 updatedAt 最旧先丢(运行数据,丢了只是"回到未试过") */
150
+ function evictIfNeeded() {
151
+ if (table.size <= maxKeys) return;
152
+ const list = [...table.values()].sort((a, b) => (a.updatedAt || 0) - (b.updatedAt || 0));
153
+ const drop = table.size - maxKeys;
154
+ for (let i = 0; i < drop; i += 1) table.delete(list[i].key);
155
+ log('info', 'entry-states 超限淘汰', { dropped: drop, maxKeys });
156
+ }
157
+
158
+ /**
159
+ * 三条失效规则 + 一致性约束的核心:由「已存记录 + 当前上下文」算出三字段。
160
+ *
161
+ * @param {any|null} state 已存记录(可为 null = 没试过)
162
+ * @param {{providers?: Array, candidates?: string[], missingTextures?: string[],
163
+ * assetDependency?: {reason?: string, message?: string}|null,
164
+ * providerFingerprint?: string, assetFingerprint?: string|null}} ctx
165
+ * @returns {{display: {accent: string|null, provider: string|null, candidates: string[]},
166
+ * support: {state: 'ready'|'unknown'|'unsupported', detail: {reason: string, message: string}|null},
167
+ * loadResult: {result: 'ok'|'failed', reason: string|null, message: string|null,
168
+ * provider: string|null, at: number, source: 'server'|'client'}|null}}
169
+ */
170
+ function recompute(state, ctx = {}) {
171
+ const providers = Array.isArray(ctx.providers) ? ctx.providers : [];
172
+ const accentOf = (id) => {
173
+ const p = providers.find((x) => x && x.id === id);
174
+ return p && p.accent ? String(p.accent) : null;
175
+ };
176
+ const candidates = Array.isArray(ctx.candidates) ? ctx.candidates.slice() : [];
177
+ /** 是否有结论因能力/文件变化而作废、需要交给 §八 后台探测(INV-D6/D7) */
178
+ let pendingProbe = false;
179
+
180
+ // ── 规则①/②:**每条结论各自带指纹**,逐条判是否过期(INV-D6)
181
+ //
182
+ // 为什么按"结论"而不是按"记录"存指纹:一条记录里可能同时有
183
+ // `loadResult`(当事端上报的上次装载结果)与 `probe`(后台形态探测结论),
184
+ // 两者产出时刻不同、依据的能力版本也可能不同。若共用一个记录级字段,
185
+ // 重探写回时会把旧装载结论**顺带洗白成有效**(最隐蔽的一类错)。
186
+ const fp = fingerprintOrNull(ctx.providerFingerprint);
187
+ const assetFp = fingerprintOrNull(ctx.assetFingerprint);
188
+ /** 这条结论是否仍然可信(提供者能力没变过) */
189
+ const sameProvider = (one) => {
190
+ if (!one || fp === null) return true; // 没给当前指纹 ⇒ 无从比对,不判过期
191
+ const got = one.providerFingerprint;
192
+ // 存量记录(接线断过 / 老版本写的)没带指纹 ⇒ **视为过期**。
193
+ // 不能让历史 null 永久豁免规则①,否则插件升级后旧"不支持"永远挂着(实测现象)。
194
+ if (got === null || got === undefined) return false;
195
+ return String(got) === fp;
196
+ };
197
+ /** 源文件是否还是同一份(规则②:size 变了 ⇒ 该条结论失效) */
198
+ const sameAsset = (one) => {
199
+ if (!one || assetFp === null) return true;
200
+ const got = one.assetFingerprint;
201
+ if (got === null || got === undefined) return true; // 老记录没写 asset 指纹 ⇒ 规则②不参与判定
202
+ return String(got) === String(assetFp);
203
+ };
204
+
205
+ const storedLoad = state && state.loadResult ? state.loadResult : null;
206
+ let loadResult = (storedLoad && sameProvider(storedLoad) && sameAsset(storedLoad)) ? { ...storedLoad } : null;
207
+ // 结论因"能力变了"或"文件换了"作废 ⇒ 置待探测(§八 消费),**不删历史**
208
+ if (storedLoad && (!sameProvider(storedLoad) || !sameAsset(storedLoad))) pendingProbe = true;
209
+ if (loadResult) {
210
+ loadResult.reason = loadResult.reason === null ? null : normalizeReason(loadResult.reason);
211
+ if (!loadResult.message && loadResult.reason) loadResult.message = reasonText(loadResult.reason);
212
+ }
213
+ // 后台探测结论:只填 support,绝不覆盖 loadResult(契约 §八"不做什么")
214
+ const storedProbe = state && state.probe ? state.probe : null;
215
+ const probeLive = (storedProbe && sameProvider(storedProbe)) ? storedProbe : null;
216
+ if (storedProbe && !sameProvider(storedProbe)) pendingProbe = true;
217
+
218
+ // ── 一致性约束:ok 的 loadResult 决定归属与颜色(INV-D2:写入路径保证,不在读取时拼装)
219
+ let provider = null;
220
+ let accent = null;
221
+ if (loadResult && loadResult.result === 'ok' && loadResult.provider) {
222
+ provider = loadResult.provider;
223
+ accent = accentOf(provider); // 该插件没声明色 ⇒ null(中性色),不猜
224
+ }
225
+
226
+ // ── support:先由上下文判,再被 loadResult.ok 覆盖(契约:两者不能并存)
227
+ let support = { state: 'unknown', detail: null };
228
+ const missing = Array.isArray(ctx.missingTextures) ? ctx.missingTextures.filter(Boolean) : [];
229
+ if (missing.length) {
230
+ support = {
231
+ state: 'unsupported',
232
+ detail: {
233
+ reason: REASONS.MISSING_TEXTURES,
234
+ message: `缺少贴图:${missing.slice(0, 3).join('、')}${missing.length > 3 ? ` 等 ${missing.length} 个` : ''}`,
235
+ },
236
+ };
237
+ } else if (ctx.assetDependency && ctx.assetDependency.reason) {
238
+ // 容器型形态探测(美术依赖包):理由与文案由调用方给(与装载失败时同源)
239
+ support = {
240
+ state: 'unsupported',
241
+ detail: {
242
+ reason: normalizeReason(ctx.assetDependency.reason),
243
+ message: String(ctx.assetDependency.message || reasonText(ctx.assetDependency.reason)),
244
+ },
245
+ };
246
+ } else if (probeLive && probeLive.result === 'unsupported' && probeLive.reason) {
247
+ // 后台形态探测的结论(契约 §八):reason/message 与装载失败**同源**
248
+ // (都来自 src/shared/bundle-shape.mjs),所以列表 ⚠ 与点击后错误文案天然一致(S4)。
249
+ support = {
250
+ state: 'unsupported',
251
+ detail: {
252
+ reason: normalizeReason(probeLive.reason),
253
+ message: String(probeLive.message || reasonText(probeLive.reason)),
254
+ },
255
+ };
256
+ } else if (Array.isArray(ctx.missingTextures) && ctx.missingTextures.length === 0) {
257
+ // 目录型且明确"没有缺件" ⇒ ready(契约:原 missingTextures 为空即 ready)
258
+ // 判据用"传了数组且为空",而不是"缺件数 0":没传这个字段表示"调用方没做资产检查",
259
+ // 那种情况下必须是 unknown —— 把"没检查"当成"检查通过"是最容易骗人的假 ready。
260
+ support = { state: 'ready', detail: null };
261
+ }
262
+
263
+ // 契约:support=unsupported 与 loadResult.ok 不能并存(后者说明"其实装得动")
264
+ if (loadResult && loadResult.result === 'ok') support = { state: 'ready', detail: null };
265
+
266
+ // 未定归属恒为 null(INV-D4):绝不按候选顺序猜色
267
+ if (!provider) { accent = null; }
268
+
269
+ // 失败记录本身就是一条**判定依据**:解包时已经用包内形态判出"这是美术依赖包",
270
+ // 那么下次列表不必再解析包体(契约 S4 裁定:同步路径禁止解析包体)。
271
+ // 注意只对"确定性判定"生效:环境类失败(内存不足、路径消失)不该让条目永久标 unsupported。
272
+ if (loadResult && loadResult.result === 'failed') {
273
+ const definite = DEFINITE_UNSUPPORTED_REASONS.has(loadResult.reason);
274
+ if (definite && support.state !== 'unsupported') {
275
+ support = {
276
+ state: 'unsupported',
277
+ detail: { reason: loadResult.reason, message: loadResult.message || reasonText(loadResult.reason) },
278
+ };
279
+ }
280
+ }
281
+
282
+ return {
283
+ display: { accent, provider, candidates },
284
+ support,
285
+ loadResult,
286
+ // 结论已作废 / 从没结论 ⇒ 需要 §八 后台探测(调用方据此排队,不在同步路径解析包体)
287
+ pendingProbe,
288
+ };
289
+ }
290
+
291
+ /**
292
+ * 写入一次装载结果(定序:at 更晚者胜;不晚于已存 ⇒ 不覆盖)。
293
+ * @param {string} key `${rootId}/${rel}`
294
+ * @param {{result: 'ok'|'failed', reason?: string|null, message?: string|null,
295
+ * provider?: string|null, at?: number, source?: 'server'|'client'}} incoming
296
+ * @param {object} ctx 同 recompute
297
+ * @returns {{applied: boolean, state: any, computed: object}}
298
+ */
299
+ function put(key, incoming, ctx = {}) {
300
+ load();
301
+ const k = String(key || '');
302
+ if (!k) return { applied: false, state: null, computed: recompute(null, ctx) };
303
+
304
+ const at = Number.isFinite(incoming && incoming.at) ? Number(incoming.at) : now();
305
+ const source = incoming && incoming.source === 'server' ? 'server' : 'client';
306
+ const prev = table.get(k) || null;
307
+
308
+ // 定序:incoming 不晚于已存 ⇒ 不覆盖,返回当前状态
309
+ if (prev && prev.loadResult && Number(prev.loadResult.at) >= at) {
310
+ return { applied: false, state: prev, computed: recompute(prev, ctx) };
311
+ }
312
+
313
+ const result = incoming && incoming.result === 'ok' ? 'ok' : 'failed';
314
+ const reason = result === 'ok' ? null : normalizeReason(incoming && incoming.reason);
315
+ const message = incoming && incoming.message
316
+ ? String(incoming.message)
317
+ : (result === 'ok' ? null : reasonText(reason));
318
+ // ok 但没 provider ⇒ 归属无法确定,按 failed 记(否则会违反"ok ⇒ display.provider 必须有值")
319
+ let finalResult = result;
320
+ let finalProvider = incoming && incoming.provider ? String(incoming.provider) : null;
321
+ let finalReason = reason;
322
+ let finalMessage = message;
323
+ if (result === 'ok' && !finalProvider) {
324
+ finalResult = 'failed';
325
+ finalReason = REASONS.UNKNOWN;
326
+ finalMessage = '装载成功但未报出提供者,无法确定归属';
327
+ }
328
+
329
+ const next = {
330
+ key: k,
331
+ loadResult: {
332
+ result: finalResult,
333
+ reason: finalResult === 'ok' ? null : finalReason,
334
+ message: finalMessage,
335
+ // 契约 §二:provider 是"实际解出/装载它的提供者",只在成功时有意义。
336
+ // 失败时一律 null —— 否则前端可能拿它去涂色,把"没装成"的条目染成某个插件的颜色。
337
+ provider: finalResult === 'ok' ? finalProvider : null,
338
+ at,
339
+ source,
340
+ // 指纹跟着**这条结论**走(INV-D6):读路径靠它判"这条结论是否还可信"。
341
+ // null = 写入方没给(接线漏了/老记录)⇒ 读时按过期处理,不会被永久豁免。
342
+ providerFingerprint: fingerprintOrNull(ctx.providerFingerprint),
343
+ assetFingerprint: fingerprintOrNull(ctx.assetFingerprint),
344
+ },
345
+ // 探测结论是**另一条独立结论**:写装载结果不能把它冲掉(两者由不同执行者产生)
346
+ ...(prev && prev.probe ? { probe: prev.probe } : {}),
347
+ updatedAt: at,
348
+ };
349
+ table.set(k, next);
350
+ evictIfNeeded();
351
+ markDirty();
352
+ return { applied: true, state: next, computed: recompute(next, ctx) };
353
+ }
354
+
355
+ /**
356
+ * 写入一次**后台形态探测**的结论(契约 §八)。
357
+ *
358
+ * 与 put 的分工:put 记"当事端的装载尝试"(loadResult),本函数只记"不用点也能知道的形态"
359
+ * (probe)。两条结论各自带指纹、各自判过期、互不覆盖 —— 探测绝不能把用户点出来的
360
+ * 装载结果冲掉(§八"不做什么"第三条)。
361
+ * @param {string} key `${rootId}/${rel}`
362
+ * @param {{result?: 'unsupported'|'unknown', reason?: string|null, message?: string|null, at?: number}} probe
363
+ * @param {{providerFingerprint?: string, assetFingerprint?: string|null}} ctx
364
+ * @returns {{applied: boolean, state: any, computed: object}}
365
+ */
366
+ function putProbe(key, probe, ctx = {}) {
367
+ load();
368
+ const k = String(key || '');
369
+ if (!k) return { applied: false, state: null, computed: recompute(null, ctx) };
370
+ const at = Number.isFinite(probe && probe.at) ? Number(probe.at) : now();
371
+ const prev = table.get(k) || {};
372
+ if (prev.probe && Number(prev.probe.at) >= at) {
373
+ return { applied: false, state: prev, computed: recompute(prev, ctx) };
374
+ }
375
+ const result = probe && probe.result === 'unsupported' ? 'unsupported' : 'unknown';
376
+ const reason = result === 'unsupported' ? normalizeReason(probe && probe.reason) : null;
377
+ const next = {
378
+ ...prev,
379
+ key: k,
380
+ probe: {
381
+ result,
382
+ reason,
383
+ // 探不出形态也**必须落一条**(result:'unknown'):否则每次启动都会把它当成
384
+ // "还没结论"再探一遍,就违反了"平时零开销"(S10)。"探过但没结论"也是结论。
385
+ message: result === 'unsupported' ? String((probe && probe.message) || reasonText(reason)) : null,
386
+ at,
387
+ providerFingerprint: fingerprintOrNull(ctx.providerFingerprint),
388
+ assetFingerprint: fingerprintOrNull(ctx.assetFingerprint),
389
+ },
390
+ updatedAt: at,
391
+ };
392
+ table.set(k, next);
393
+ evictIfNeeded();
394
+ markDirty();
395
+ return { applied: true, state: next, computed: recompute(next, ctx) };
396
+ }
397
+
398
+ /**
399
+ * 该条目是否"没有可用结论、需要后台探测"(§八 的触发判据,供调度侧排队用)。
400
+ *
401
+ * 判据:没有任何**与当前指纹匹配**的结论(loadResult 或 probe 之一)。
402
+ * · "探过但没结论"(probe.result='unknown')也算有结论 ⇒ 不再重探(S10 零开销的前提)
403
+ * · 给不出当前指纹时一律返回 false:没有判据还反复跑,就是把 IO 打满(INV-D7 的反面)
404
+ * @param {any|null} state 已存记录
405
+ * @param {{providerFingerprint?: string, assetFingerprint?: string|null}} ctx
406
+ * @returns {boolean}
407
+ */
408
+ function needsProbe(state, ctx = {}) {
409
+ const fp = ctx.providerFingerprint === undefined ? null : String(ctx.providerFingerprint);
410
+ if (fp === null) return false;
411
+ if (!state) return true;
412
+ const assetFp = ctx.assetFingerprint === undefined ? null : ctx.assetFingerprint;
413
+ /** 这条结论是否是"当前能力 + 当前文件"下得出的 */
414
+ const fresh = (one) => {
415
+ if (!one) return false;
416
+ const got = one.providerFingerprint;
417
+ if (got === null || got === undefined || String(got) !== fp) return false;
418
+ if (assetFp === null) return true;
419
+ const gotAsset = one.assetFingerprint;
420
+ if (gotAsset === null || gotAsset === undefined) return true; // 老结论没记 asset ⇒ 不据此否决
421
+ return String(gotAsset) === String(assetFp);
422
+ };
423
+ return !(fresh(state.loadResult) || fresh(state.probe));
424
+ }
425
+
426
+ function get(key) {
427
+ load();
428
+ return table.get(String(key || '')) || null;
429
+ }
430
+
431
+ function stats() {
432
+ return { keys: table.size, dirty, lastFlushAt };
433
+ }
434
+
435
+ return { load, get, put, putProbe, needsProbe, recompute, flush, stats };
436
+ }
437
+
438
+ /**
439
+ * 资源指纹(规则②的判据):**只用 size**。
440
+ *
441
+ * 为什么不用 mtime:三个写入方里只有服务端能拿到 mtime(前端只知道 size)。
442
+ * 混用两种格式会让同一份文件在两条路上算出不同指纹 ⇒ 每次切换都判"文件变了"、
443
+ * 结论反复失效(这类漂移最难查:单看每条路径都对)。
444
+ * 统一取"所有写入方都能给出、且规则②真正需要"的那个量。
445
+ * @param {unknown} size 字节数
446
+ * @returns {string|null} 拿不到有效值返回 null(= 该次写入不参与规则②)
447
+ */
448
+ export function assetFingerprintOf(size) {
449
+ const n = Number(size);
450
+ return Number.isFinite(n) && n >= 0 ? String(n) : null;
451
+ }
452
+
453
+ // ============================================================================
454
+ // 共享实例(同一进程内**必须只有一个**)
455
+ //
456
+ // 为什么必须单例:这个存储会被两条路写(服务端 resolve 与前端上报路由)。
457
+ // 如果各自 createEntryStates 一个实例,两个实例各持一份内存表、各自 flush,
458
+ // 后写的整表覆盖先写的 ⇒ 记录随机丢失。所以统一从这里取。
459
+ //
460
+ // 路径与 core/paths.mjs 的 dataDir 同口径:<MODELSTAGE_DATA_ROOT || 项目根>/data/entry-states.json
461
+ // —— 测试实例(MODELSTAGE_DATA_ROOT=runtime/test-instance)自然写到它自己的数据根下,
462
+ // 不会污染主实例的 data/。
463
+ // ============================================================================
464
+
465
+ /** 项目根(本文件在 src/server/data/ ⇒ 三层) */
466
+ // 用 fileURLToPath 而不是 new URL().pathname:后者**不做百分号解码**,
467
+ // 项目根含中文目录(工作区)时会变成 %E5%B7%A5… ⇒ 写出一个谁都不认识的路径。
468
+ const ENTRY_STATES_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
469
+
470
+ /**
471
+ * 按与 core/paths.mjs 相同的规则算 entry-states.json 的绝对路径。
472
+ * @param {string} [root] 项目根
473
+ * @returns {string}
474
+ */
475
+ export function resolveEntryStatesPath(root = ENTRY_STATES_ROOT) {
476
+ const dataRoot = (process.env.MODELSTAGE_DATA_ROOT || '').trim();
477
+ const data = dataRoot ? path.resolve(dataRoot) : root;
478
+ return path.join(data, 'data', 'entry-states.json');
479
+ }
480
+
481
+ let shared = null;
482
+
483
+ /**
484
+ * 取(或首次创建)进程内共享的 entry-states 实例。
485
+ * @param {{root?: string, log?: Function}} [opts]
486
+ * @returns {ReturnType<typeof createEntryStates>}
487
+ */
488
+ export function getSharedEntryStates(opts = {}) {
489
+ if (shared) return shared;
490
+ const file = resolveEntryStatesPath(opts.root || ENTRY_STATES_ROOT);
491
+ shared = createEntryStates({ file, log: opts.log });
492
+ shared.load();
493
+ return shared;
494
+ }
495
+
496
+ /** 单测/测试实例用:丢掉共享实例(不写盘) */
497
+ export function resetSharedEntryStates() {
498
+ shared = null;
499
+ }