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.
- package/LICENSE +21 -0
- package/README.md +274 -2
- package/THIRD_PARTY_NOTICES.md +90 -0
- package/bin/modelstage.mjs +137 -0
- package/config/diag-upload.example.json +7 -0
- package/config/models/live2d.assetbundls.json +1 -0
- package/config/models/live2d.azurlane.json +1 -0
- package/config/models/live2d.azurlane_bundle.json +1 -0
- package/config/models/live2d.azurlane_spine.json +1 -0
- package/config/models/live2d.bd2.json +1 -0
- package/config/models/live2d.bluearchive.json +1 -0
- package/config/models/live2d.live2d.json +1 -0
- package/config/models/live2d.nikke.json +1 -0
- package/config/models/live2d.nikke_2.json +1 -0
- package/config/models/live2d.npc.json +1 -0
- package/config/models/spine.assetbundls.json +1 -0
- package/config/models/spine.azurlane.json +1 -0
- package/config/models/spine.azurlane_bundle.json +1 -0
- package/config/models/spine.azurlane_spine.json +1 -0
- package/config/models/spine.bd2.json +1 -0
- package/config/models/spine.bluearchive.json +1 -0
- package/config/models/spine.live2d.json +1 -0
- package/config/models/spine.nikke.json +1 -0
- package/config/models/spine.nikke_2.json +1 -0
- package/config/models/spine.npc.json +1 -0
- package/config/names/NIKKE.json +1058 -0
- package/config/names/README.md +47 -0
- package/config/names//345/244/251/345/221/275/344/271/213/345/255/220.json +42897 -0
- package/config/names//346/243/225/350/211/262/345/260/230/345/237/2032.json +4016 -0
- package/config/names//347/242/247/350/223/235/350/210/252/347/272/277.json +38342 -0
- package/config/plugins.json +13 -0
- package/config/resources.example.json +40 -0
- package/package.json +63 -4
- package/plugins/unity-live2d-bundle/README.md +98 -0
- package/plugins/unity-live2d-bundle/index.js +891 -0
- package/plugins/unity-live2d-bundle/lib/anim-to-motion.js +402 -0
- package/plugins/unity-live2d-bundle/lib/bundle-probe.js +244 -0
- package/plugins/unity-live2d-bundle/lib/bytes.js +189 -0
- package/plugins/unity-live2d-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-live2d-bundle/lib/dxt.js +197 -0
- package/plugins/unity-live2d-bundle/lib/errors.js +71 -0
- package/plugins/unity-live2d-bundle/lib/etc2.js +371 -0
- package/plugins/unity-live2d-bundle/lib/extract.js +242 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-live2d-bundle/lib/model3.js +137 -0
- package/plugins/unity-live2d-bundle/lib/png.js +424 -0
- package/plugins/unity-live2d-bundle/lib/serialized.js +233 -0
- package/plugins/unity-live2d-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-live2d-bundle/lib/typetree.js +215 -0
- package/plugins/unity-live2d-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-live2d-bundle/plugin.json +81 -0
- package/plugins/unity-live2d-bundle/profiles/unityfs-live2d.json +26 -0
- package/plugins/unity-spine-bundle/index.js +535 -0
- package/plugins/unity-spine-bundle/lib/bytes.js +189 -0
- package/plugins/unity-spine-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-spine-bundle/lib/dxt.js +197 -0
- package/plugins/unity-spine-bundle/lib/errors.js +79 -0
- package/plugins/unity-spine-bundle/lib/etc2.js +371 -0
- package/plugins/unity-spine-bundle/lib/extract.js +242 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-spine-bundle/lib/png.js +424 -0
- package/plugins/unity-spine-bundle/lib/serialized.js +233 -0
- package/plugins/unity-spine-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-spine-bundle/lib/typetree.js +215 -0
- package/plugins/unity-spine-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-spine-bundle/plugin.json +75 -0
- package/src/server/README.md +69 -0
- package/src/server/assets/browse.mjs +56 -0
- package/src/server/assets/bundle-probe.mjs +94 -0
- package/src/server/assets/probe.mjs +184 -0
- package/src/server/core/config.mjs +173 -0
- package/src/server/core/env-num.mjs +60 -0
- package/src/server/core/env.mjs +49 -0
- package/src/server/core/fs-portable.mjs +37 -0
- package/src/server/core/log.mjs +107 -0
- package/src/server/core/paths.mjs +44 -0
- package/src/server/data/catalog.mjs +1091 -0
- package/src/server/data/entry-states.mjs +499 -0
- package/src/server/data/live2d.mjs +272 -0
- package/src/server/data/manifest-cache.mjs +58 -0
- package/src/server/data/manifest-stamp.mjs +135 -0
- package/src/server/data/model-meta.mjs +114 -0
- package/src/server/data/names.mjs +106 -0
- package/src/server/data/shape-probe-worker.mjs +33 -0
- package/src/server/data/shape-probe.mjs +626 -0
- package/src/server/data/spine.mjs +336 -0
- package/src/server/data/tree.mjs +126 -0
- package/src/server/http/atomic-json.mjs +145 -0
- package/src/server/http/background-solids.mjs +270 -0
- package/src/server/http/backgrounds.mjs +388 -0
- package/src/server/http/diag-upload.mjs +264 -0
- package/src/server/http/diag.mjs +173 -0
- package/src/server/http/ready.mjs +167 -0
- package/src/server/http/static.mjs +312 -0
- package/src/server/http/write.mjs +654 -0
- package/src/server/index.mjs +114 -0
- package/src/server/plugins/builtin-providers.json +30 -0
- package/src/server/plugins/registry.mjs +650 -0
- package/src/server/plugins/slot-catalog.mjs +286 -0
- package/src/server/routes/background-solids.mjs +56 -0
- package/src/server/routes/backgrounds.mjs +77 -0
- package/src/server/routes/config.mjs +38 -0
- package/src/server/routes/data.mjs +313 -0
- package/src/server/routes/diag.mjs +37 -0
- package/src/server/routes/entries.mjs +170 -0
- package/src/server/routes/ready.mjs +122 -0
- package/src/server/serve.mjs +396 -0
- package/src/server/unpack/capacity.mjs +217 -0
- package/src/server/unpack/mem-probe.mjs +27 -0
- package/src/server/unpack/plugin-runner.mjs +529 -0
- package/src/server/unpack/plugin-store.mjs +736 -0
- package/src/server/unpack/probe-store.mjs +73 -0
- package/src/server/unpack/routes.mjs +359 -0
- package/src/server/unpack/runtime/exec.mjs +558 -0
- package/src/server/unpack/runtime/index.mjs +141 -0
- package/src/server/unpack/runtime/node-vm.mjs +111 -0
- package/src/server/unpack/unpack-worker.mjs +105 -0
- package/src/server/unpack/worker-client.mjs +184 -0
- package/src/server/unpack/write-result.mjs +84 -0
- package/src/shared/bundle-shape.mjs +83 -0
- package/src/shared/entry-reasons.mjs +119 -0
- package/src/shared/names.mjs +45 -0
- package/src/shared/type-labels.mjs +31 -0
- package/src/web/README.md +193 -0
- package/src/web/app/anim-list.js +222 -0
- package/src/web/app/background-solids.js +150 -0
- package/src/web/app/backgrounds.js +152 -0
- package/src/web/app/camera-log.js +28 -0
- package/src/web/app/context.js +72 -0
- package/src/web/app/dir-picker.js +242 -0
- package/src/web/app/entry-report.js +71 -0
- package/src/web/app/memory-bars.js +91 -0
- package/src/web/app/memory-budget.js +228 -0
- package/src/web/app/memory-guard.js +204 -0
- package/src/web/app/model-actions.js +682 -0
- package/src/web/app/renderer-setting.js +70 -0
- package/src/web/app/root-manager.js +254 -0
- package/src/web/app/status.js +138 -0
- package/src/web/app/topbar-actions.js +357 -0
- package/src/web/app/transport-actions.js +297 -0
- package/src/web/app/ui-state.js +559 -0
- package/src/web/app/url-params.js +171 -0
- package/src/web/app/view-settings.js +186 -0
- package/src/web/components.css +524 -0
- package/src/web/debug/diag-hud.js +95 -0
- package/src/web/debug/drag.js +369 -0
- package/src/web/debug/log.js +320 -0
- package/src/web/debug/model-cmp.js +292 -0
- package/src/web/debug/panel.css +474 -0
- package/src/web/debug/panel.js +712 -0
- package/src/web/debug/race-probe.js +82 -0
- package/src/web/debug/upload.js +130 -0
- package/src/web/engine/adapters/atlas.js +47 -0
- package/src/web/engine/adapters/index.js +22 -0
- package/src/web/engine/adapters/renderer.js +223 -0
- package/src/web/engine/adapters/skeleton.js +105 -0
- package/src/web/engine/adapters/texture.js +64 -0
- package/src/web/engine/animation.js +181 -0
- package/src/web/engine/appearance.js +136 -0
- package/src/web/engine/camera-adapter.js +212 -0
- package/src/web/engine/effect-chain.js +138 -0
- package/src/web/engine/engine-state.js +120 -0
- package/src/web/engine/filters.js +224 -0
- package/src/web/engine/fit.js +197 -0
- package/src/web/engine/frame-gate.js +33 -0
- package/src/web/engine/gl-driver.js +56 -0
- package/src/web/engine/gl.js +77 -0
- package/src/web/engine/index.js +20 -0
- package/src/web/engine/loader.js +55 -0
- package/src/web/engine/model-info.js +26 -0
- package/src/web/engine/pipeline.js +200 -0
- package/src/web/engine/player.js +427 -0
- package/src/web/engine/pma/blend.js +76 -0
- package/src/web/engine/pma/detect.js +118 -0
- package/src/web/engine/pma/index.js +8 -0
- package/src/web/engine/pma/resolve.js +112 -0
- package/src/web/engine/pma/upload.js +127 -0
- package/src/web/engine/pma.js +5 -0
- package/src/web/engine/render-frame.js +93 -0
- package/src/web/engine/runtime-capabilities.js +209 -0
- package/src/web/engine/runtime.js +37 -0
- package/src/web/engine/skins.js +165 -0
- package/src/web/engine/textures.js +174 -0
- package/src/web/index.html +117 -0
- package/src/web/infra/api.js +531 -0
- package/src/web/infra/diag.js +509 -0
- package/src/web/infra/document.js +269 -0
- package/src/web/infra/dom.js +31 -0
- package/src/web/infra/events.mjs +27 -0
- package/src/web/infra/log.js +153 -0
- package/src/web/infra/mem-stats.js +112 -0
- package/src/web/infra/plugin-host-registration.mjs +285 -0
- package/src/web/infra/plugin-host-report.mjs +32 -0
- package/src/web/infra/plugin-host-unload.mjs +79 -0
- package/src/web/infra/plugin-host-unpack.mjs +899 -0
- package/src/web/infra/plugin-host-warmup.mjs +180 -0
- package/src/web/infra/plugin-host.js +628 -0
- package/src/web/infra/ready.js +461 -0
- package/src/web/infra/schedule.js +51 -0
- package/src/web/infra/shortcuts.js +129 -0
- package/src/web/infra/slot-registry.js +402 -0
- package/src/web/infra/vfs.js +247 -0
- package/src/web/live2d/bridge.js +400 -0
- package/src/web/live2d/model.js +321 -0
- package/src/web/live2d/overlay.js +142 -0
- package/src/web/live2d/pixi-app.js +111 -0
- package/src/web/live2d/playback-probe.js +92 -0
- package/src/web/live2d/probe-geometry.js +329 -0
- package/src/web/live2d/probe.js +119 -0
- package/src/web/live2d/release.js +66 -0
- package/src/web/live2d/runtime-adapter.js +289 -0
- package/src/web/live2d/stage-ctx.js +152 -0
- package/src/web/live2d/stage.js +204 -0
- package/src/web/live2d/vfs-settings.js +174 -0
- package/src/web/live2d/view.js +89 -0
- package/src/web/main.js +629 -0
- package/src/web/panel/background-list.js +373 -0
- package/src/web/panel/bg-source.js +291 -0
- package/src/web/panel/color-picker.css +199 -0
- package/src/web/panel/color-picker.js +278 -0
- package/src/web/panel/dialog.css +116 -0
- package/src/web/panel/dialog.js +173 -0
- package/src/web/panel/dom.js +35 -0
- package/src/web/panel/icons.js +58 -0
- package/src/web/panel/memory-bar.js +195 -0
- package/src/web/panel/panel-settings.js +1263 -0
- package/src/web/panel/panel.css +1304 -0
- package/src/web/panel/render-choice.js +104 -0
- package/src/web/panel/schema.js +784 -0
- package/src/web/panel/settings-io.js +291 -0
- package/src/web/panel/shell-controls.js +274 -0
- package/src/web/panel/slider.js +210 -0
- package/src/web/panel/toolbar-schema.js +111 -0
- package/src/web/panel/toolbar.js +195 -0
- package/src/web/panel/transport-picker.js +375 -0
- package/src/web/panel/transport.js +321 -0
- package/src/web/panel/view.js +553 -0
- package/src/web/plugin/unpack-worker.js +185 -0
- package/src/web/render/camera/constants.js +60 -0
- package/src/web/render/camera/index.js +123 -0
- package/src/web/render/camera/spine.js +124 -0
- package/src/web/render/capture.js +30 -0
- package/src/web/render/interaction.js +527 -0
- package/src/web/render/live2d.js +627 -0
- package/src/web/render/playback.js +47 -0
- package/src/web/render/reset-view.js +29 -0
- package/src/web/render/spine.js +386 -0
- package/src/web/render/surface-switch.js +176 -0
- package/src/web/render/surface.js +571 -0
- package/src/web/render/surfaces.js +70 -0
- package/src/web/roster/filter.js +31 -0
- package/src/web/roster/list.js +276 -0
- package/src/web/roster/persist.js +92 -0
- package/src/web/roster/reveal.js +77 -0
- package/src/web/roster/rows.js +248 -0
- package/src/web/roster/snapshot.js +39 -0
- package/src/web/roster/type-filter.js +97 -0
- package/src/web/roster/view.js +761 -0
- package/src/web/spine/canvas-api-groups.js +267 -0
- package/src/web/spine/canvas-api.js +97 -0
- package/src/web/spine/canvas-document.js +80 -0
- package/src/web/spine/canvas-engine.js +193 -0
- package/src/web/spine/canvas-entry.js +28 -0
- package/src/web/spine/canvas-pixels.js +36 -0
- package/src/web/spine/canvas-runtime.js +127 -0
- package/src/web/spine/canvas-view.js +108 -0
- package/src/web/spine/canvas.js +7 -0
- package/src/web/spine/runtime-hint.js +73 -0
- package/src/web/spine/runtimes.js +212 -0
- package/src/web/style.css +493 -0
- package/tool/lib/bundle-probe-node.mjs +121 -0
- package/tool/lib/paths.mjs +20 -0
- package/tool/lib/runtime-guides.mjs +65 -0
- package/tool/lib/runtime-package.mjs +403 -0
- package/tool/lib/spine-meta.mjs +227 -0
- package/tool/lib/spine-motions.mjs +104 -0
- package/tool/parse-jsonc.mjs +86 -0
- package/tool/resolve-live2d-runtimes.mjs +276 -0
- package/tool/spine-motion-rules.json +101 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
// dsh-skip-quality: install() 是编排型容器函数(只做注册 / 探测 / 日志,本身无实现代码);
|
|
2
|
+
// 硬拆须把 ulb / profileCache / ulbLog 等闭包私有状态提升为模块级,浏览器与 node 两侧都受影响,
|
|
3
|
+
// 与 unity-live2d-bundle 的 install() 同性质同豁免口径。
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// plugins/unity-spine-bundle/index.js —— 「Unity Spine 资源包」插件注册与解析入口
|
|
6
|
+
//
|
|
7
|
+
// 适用对象:**UnityFS 打包的 Spine 资源包**(单文件、通常无扩展名),包内是 Spine 原生三件套:
|
|
8
|
+
// · `<base>.skel` —— TextAsset(**二进制** spine skeleton,含版本串如 "4.1.20")
|
|
9
|
+
// · `<base>.atlas` —— TextAsset(纯文本,首行是贴图页名)
|
|
10
|
+
// · Texture2D —— 贴图(常见 2048² RGBA32 内联)
|
|
11
|
+
//
|
|
12
|
+
// 与 live2d 插件的分工(同族容器,靠包内特征互斥,不靠命名顺序):
|
|
13
|
+
// · live2d:UnityFS + CAB + **有 moc3** → 认领
|
|
14
|
+
// · spine :UnityFS + CAB + **有 .skel TextAsset** → 认领
|
|
15
|
+
// 两个 asset-provider 都声明 fileKinds:['bundle'],宿主按清单顺序逐个问 canHandle 并**取第一个 true**
|
|
16
|
+
// (plugin-runner 的 pickPlugin 不重试),所以判定必须互斥 —— 只判"容器头"会让两个插件抢同一个包。
|
|
17
|
+
//
|
|
18
|
+
// 与游戏无关:解析全部走 lib/ 的通用实现;数据性差异(若有)放 profiles/<profile>.json。
|
|
19
|
+
//
|
|
20
|
+
// 对外契约(契约 插件.md §四 asset-provider):
|
|
21
|
+
// register({ id, name, label, fileKinds, kind, canHandle, resolve })
|
|
22
|
+
// canHandle(file) → boolean 同步纯判定(§3.1):是不是本插件能读的容器
|
|
23
|
+
// resolve(file) → { prefix, files, entry } 容器 → 文件表 + 入口(§4.2)
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
// node 侧可 import(服务端解包):本文件既是浏览器里的注入式经典脚本,也要能在 node 里被 import。
|
|
29
|
+
// 因此顶层**绝不直接引用 window/document** —— node 里没有这两个全局,import 阶段就会
|
|
30
|
+
// ReferenceError 直接炸掉(没有 catch 的机会);宿主改由 install() 内懒获取。
|
|
31
|
+
function install() {
|
|
32
|
+
const hasDom = typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
33
|
+
|
|
34
|
+
// 宿主注入面懒获取。浏览器:window.SpineViewerPlugins(plugin-host.js 在注入本脚本前挂好)。
|
|
35
|
+
// node:没有宿主 → null(不注册,改走 globalThis 导出,见文件末尾)。
|
|
36
|
+
const host = (typeof window !== 'undefined' && window.SpineViewerPlugins) || null;
|
|
37
|
+
if (!host && hasDom) {
|
|
38
|
+
console.warn('[unity-spine-bundle] SpineViewerPlugins 未就绪(plugin-host.js 未加载?),插件未注册');
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
// lib 挂载点:各 lib 文件自己往 globalThis.__ULB__ 挂能力(浏览器里 window 即 globalThis)。
|
|
42
|
+
const NS = (typeof globalThis !== 'undefined' && globalThis.__ULB__) || null;
|
|
43
|
+
if (!NS || !NS.unityfs || !NS.serialized || !NS.extract) {
|
|
44
|
+
if (hasDom) {
|
|
45
|
+
console.warn('[unity-spine-bundle] lib 未加载完整(需要 bytes/common-strings/unityfs/serialized/typetree/extract),插件未注册');
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const PLUGIN_ID = 'unity-spine-bundle';
|
|
51
|
+
// Unity classID:49 = TextAsset(spine 的 .skel/.atlas 都以它承载)、28 = Texture2D(贴图页)。
|
|
52
|
+
// 用 serialized.CLASS_IDS 取而不是写字面量:那两个数字是 Unity 格式常量,散落多处会漂。
|
|
53
|
+
const CLASS_ID_TEXT_ASSET = NS.serialized.CLASS_IDS.TextAsset;
|
|
54
|
+
const CLASS_ID_TEXTURE2D = NS.serialized.CLASS_IDS.Texture2D;
|
|
55
|
+
/** 入口清单的文件名(files 里的键,也是 resolve 返回的 entry)。前端按此键取三件套映射。 */
|
|
56
|
+
const ENTRY_NAME = 'entry.json';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 统一日志出口(契约 §3.4 降级口径):
|
|
60
|
+
* 优先 host.log(level,msg,scope);宿主没提供(node 侧必然没有)时降级 console[level],不静默丢弃。
|
|
61
|
+
* 统一收一处:调用点各写一遍 `host.log || console` 容易被静态检查判成裸 console,且样式会漂。
|
|
62
|
+
*/
|
|
63
|
+
const ulbLog = (level, msg) => {
|
|
64
|
+
const hostLog = (typeof window !== 'undefined' && window.SpineViewerPlugins && window.SpineViewerPlugins.log)
|
|
65
|
+
|| (host && typeof host.log === 'function' ? host.log : null);
|
|
66
|
+
if (hostLog) { hostLog(level, msg, PLUGIN_ID); return; }
|
|
67
|
+
const fn = console[level] || console.log;
|
|
68
|
+
if (fn) fn.call(console, `[${PLUGIN_ID}] ${msg}`);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** 把 file(宿主传入的上下文)规整成内部形状;非对象一律当缺参处理。 */
|
|
72
|
+
function asCtx(file) {
|
|
73
|
+
return (file && typeof file === 'object') ? file : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* lib 层抛的是 ULB_ERR_*(那些 lib 与 live2d 插件逐字节同源,改成 SPI_ 就不再同源),
|
|
78
|
+
* 但**本插件对外声明的码是 SPI_ERR_***(plugin.json 的 errorCodes 是插件自描述,契约 §二)。
|
|
79
|
+
* 所以在插件边界做一次前缀映射:lib 的内部实现细节不外泄,调用方看到的始终是本插件的码。
|
|
80
|
+
* 映射后若落在本插件没声明的码上(如 live2d 专用的 MODEL_BUILD),收敛成 CONTAINER_PARSE
|
|
81
|
+
* 并把原码写进消息 —— 绝不抛一个没声明过的码出去。
|
|
82
|
+
* @param {Error} e
|
|
83
|
+
* @returns {Error} 带 SPI_ 码的错误(原错误不是带码错误时原样返回)
|
|
84
|
+
*/
|
|
85
|
+
function toSpiError(e) {
|
|
86
|
+
const code = e && typeof e.code === 'string' ? e.code : '';
|
|
87
|
+
if (!code.startsWith('ULB_ERR_')) return e;
|
|
88
|
+
const mapped = code.replace(/^ULB_ERR_/, 'SPI_ERR_');
|
|
89
|
+
if (Object.prototype.hasOwnProperty.call(NS.errors.CODES, mapped.replace(/^SPI_ERR_/, ''))) {
|
|
90
|
+
return NS.errors.fail(mapped, e.message);
|
|
91
|
+
}
|
|
92
|
+
return NS.errors.fail(NS.errors.CODES.CONTAINER_PARSE, `${e.message}(原 lib 码 ${code})`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 取容器字节(契约 §4.1 两条通道):① file.bytes 直接用;② 否则 host.fetchFile(rootId, rel)。
|
|
97
|
+
* host 不可用且无 bytes ⇒ SPI_ERR_NO_HOST(可退化,契约 §3.6)。
|
|
98
|
+
*/
|
|
99
|
+
async function readBundleBytes(ctx, hostApi) {
|
|
100
|
+
const given = ctx && ctx.bytes;
|
|
101
|
+
if (given) return given;
|
|
102
|
+
const fetchFile = hostApi && hostApi.fetchFile;
|
|
103
|
+
if (typeof fetchFile !== 'function') {
|
|
104
|
+
throw NS.errors.fail(NS.errors.CODES.NO_HOST, '宿主未提供 fetchFile(rootId, rel) 且 file.bytes 缺失,无法读取资源包');
|
|
105
|
+
}
|
|
106
|
+
return fetchFile(ctx.rootId, ctx.rel);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 从对象表取出 TextAsset 的**原始字节**(按 m_Name 后缀分 .skel / .atlas)。
|
|
111
|
+
*
|
|
112
|
+
* 为什么必须用 typetree 的 rawStrings 模式而不是 extract.findTextAssets:
|
|
113
|
+
* 后者把 m_Script 当 UTF-8 字符串再 `utf8()` 编码回去,**二进制字段会被毁**
|
|
114
|
+
* (实测 .skel:651994 字节 → 641818 字符含 165995 个 U+FFFD → 再编码回来长度都不对)。
|
|
115
|
+
* spine 的 .skel 正是二进制,所以这里走 raw 分支拿 Uint8Array。
|
|
116
|
+
* @param {object} sf serialized.parse() 结果
|
|
117
|
+
* @returns {{skel: Array<{name:string,bytes:Uint8Array}>, atlas: Array<{name:string,bytes:Uint8Array}>}}
|
|
118
|
+
*/
|
|
119
|
+
function extractTextAssetsRaw(sf) {
|
|
120
|
+
const skel = [];
|
|
121
|
+
const atlas = [];
|
|
122
|
+
for (const obj of NS.serialized.byClass(sf, CLASS_ID_TEXT_ASSET)) {
|
|
123
|
+
const fields = NS.typetree.readObject(sf, obj, { rawStrings: true });
|
|
124
|
+
const name = (fields.m_Name && typeof fields.m_Name.text === 'string')
|
|
125
|
+
? fields.m_Name.text : String(fields.m_Name || '');
|
|
126
|
+
const raw = fields.m_Script && fields.m_Script.bytes;
|
|
127
|
+
if (!raw) continue;
|
|
128
|
+
if (/\.skel$/i.test(name)) skel.push({ name, bytes: raw });
|
|
129
|
+
else if (/\.atlas$/i.test(name)) atlas.push({ name, bytes: raw });
|
|
130
|
+
}
|
|
131
|
+
return { skel, atlas };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 从 atlas 文本里解析贴图页名(页名行**行首无空白**且以图片扩展名结尾)。
|
|
136
|
+
* 规则与服务端 src/server/assets/probe.mjs 的 parseAtlasPages 完全一致(那边读文件,这里读内存文本):
|
|
137
|
+
* 两边必须同口径,否则"插件落盘的 png 文件名"与"扫描器去找的页名"会对不上。
|
|
138
|
+
* @param {Uint8Array} atlasBytes
|
|
139
|
+
* @returns {string[]} 页名(保持 atlas 中的顺序)
|
|
140
|
+
*/
|
|
141
|
+
function parseAtlasPages(atlasBytes) {
|
|
142
|
+
// 先剥一次 BOM:JS 的 \s 包含 U+FEFF,而 AssetStudio 导出的 atlas 首行是「BOM + 页名」,
|
|
143
|
+
// 不剥就会把第一页页名当成"有前导空白"整行跳过(多页图集第一页丢失,画面空白)。
|
|
144
|
+
// 服务端 probe.mjs 同口径(那边读文件后剥),前端 textures.js 靠"先 trim"天然免疫 —— 三份一致。
|
|
145
|
+
const text = new TextDecoder('utf-8').decode(atlasBytes).replace(/^\uFEFF/, '');
|
|
146
|
+
const pages = [];
|
|
147
|
+
for (const line of text.split('\n')) {
|
|
148
|
+
if (/^[^\s].*\.(png|jpg|jpeg|webp)\s*$/i.test(line)) pages.push(line.trim());
|
|
149
|
+
}
|
|
150
|
+
return pages;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 读 spine 二进制头部的版本串(形如 "4.1.20"),用于自检与错误信息。
|
|
155
|
+
* .skel 头 8 字节是 hash,随后是版本串(本项目样本里是明文 ASCII)。
|
|
156
|
+
* 读不到不算失败(不同版本布局可能不同),返回 null 由调用方决定怎么处理。
|
|
157
|
+
* @param {Uint8Array} skelBytes
|
|
158
|
+
* @returns {string|null}
|
|
159
|
+
*/
|
|
160
|
+
function readSkelVersion(skelBytes) {
|
|
161
|
+
if (!skelBytes || skelBytes.length < 16) return null;
|
|
162
|
+
const head = NS.bytes.latin1(skelBytes, 0, Math.min(64, skelBytes.length));
|
|
163
|
+
// 用**前瞻**正则在每个位置都试一次(前瞻不消耗字符),因为版本串可能与前一段共享数字:
|
|
164
|
+
// 实测 aim 包头是 "…3.4.1.20…",顺序匹配只能得到 "3.4.1",
|
|
165
|
+
// 而真实版本串是 "4.1.20"(起始于 "3." 之后的那个 4)。取最后一个命中。
|
|
166
|
+
const all = [...head.matchAll(/(?=(\d+\.\d+\.\d+))/g)].map((m) => m[1]);
|
|
167
|
+
return all.length ? all[all.length - 1] : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 容器魔数判定(只读前 8 字节) */
|
|
171
|
+
function looksLikeUnityFs(bytes) {
|
|
172
|
+
if (!bytes || bytes.length < 8) return false;
|
|
173
|
+
return NS.bytes.latin1(bytes, 0, 7) === 'UnityFS' && bytes[7] === 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 容器探测(**不解压数据块**,只读头部 + blocksInfo + 对象表字段名)。
|
|
178
|
+
*
|
|
179
|
+
* 为什么只读块表与类型树就够:判定"是不是 spine 包"只需要看 TextAsset 的 m_Name 后缀,
|
|
180
|
+
* 不需要 m_Script 的内容,也不需要解压 CAB 里的数据块(纹理 16MB 和解压 20MB 都省掉)。
|
|
181
|
+
*
|
|
182
|
+
* @param {Uint8Array} bytes
|
|
183
|
+
* @returns {{ok:boolean, reason?:string, code?:string, skel?:string[], atlas?:string[], texCount?:number}}
|
|
184
|
+
*/
|
|
185
|
+
function probeContainer(bytes) {
|
|
186
|
+
if (!bytes || !bytes.length) return { ok: false, code: NS.errors.CODES.NO_FILE, reason: '文件为空' };
|
|
187
|
+
if (!looksLikeUnityFs(bytes)) {
|
|
188
|
+
// 明确区分「不是 UnityFS」与「已加密的原始包」:NIKKE 等游戏的原始 AssetBundle 是加密的,
|
|
189
|
+
// 解密后才是明文 UnityFS。这里只处理明文;加密包在头部就不是 UnityFS 魔数。
|
|
190
|
+
return { ok: false, code: NS.errors.CODES.NOT_UNITYFS, reason: '文件头不是 UnityFS(若来自加密游戏包,需先解密)' };
|
|
191
|
+
}
|
|
192
|
+
let head;
|
|
193
|
+
try {
|
|
194
|
+
head = NS.unityfs.readHeader(bytes);
|
|
195
|
+
} catch (e) {
|
|
196
|
+
return { ok: false, code: NS.errors.CODES.CONTAINER_PARSE, reason: `容器头解析失败:${e.message}` };
|
|
197
|
+
}
|
|
198
|
+
const cab = head.nodes.find((n) => /^CAB-/.test(n.path));
|
|
199
|
+
if (!cab) return { ok: false, code: NS.errors.CODES.NO_CAB, reason: '容器里没有 CAB-* 节点(不是资产包)' };
|
|
200
|
+
|
|
201
|
+
// 读 CAB 的 SerializedFile 对象表。注意这里**必须**用 parse 拿数据区:
|
|
202
|
+
// node 的 offset/size 是相对"解压后拼接数据"的,与文件偏移不同。
|
|
203
|
+
let sf;
|
|
204
|
+
try {
|
|
205
|
+
const parsed = NS.unityfs.parse(bytes);
|
|
206
|
+
const cabBytes = NS.unityfs.nodeBytes(parsed, cab.path);
|
|
207
|
+
sf = NS.serialized.parse(cabBytes);
|
|
208
|
+
} catch (e) {
|
|
209
|
+
return { ok: false, code: NS.errors.CODES.CONTAINER_PARSE, reason: `对象表解析失败:${e.message}` };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const skel = [];
|
|
213
|
+
const atlas = [];
|
|
214
|
+
let texCount = 0;
|
|
215
|
+
try {
|
|
216
|
+
for (const obj of NS.serialized.byClass(sf, CLASS_ID_TEXT_ASSET)) {
|
|
217
|
+
const fields = NS.typetree.readObject(sf, obj);
|
|
218
|
+
const name = typeof fields.m_Name === 'string' ? fields.m_Name : String(fields.m_Name || '');
|
|
219
|
+
if (/\.skel$/i.test(name)) skel.push(name);
|
|
220
|
+
else if (/\.atlas$/i.test(name)) atlas.push(name);
|
|
221
|
+
}
|
|
222
|
+
texCount = NS.serialized.byClass(sf, CLASS_ID_TEXTURE2D).length;
|
|
223
|
+
} catch (e) {
|
|
224
|
+
return { ok: false, code: NS.errors.CODES.CONTAINER_PARSE, reason: `读取 TextAsset 名称失败:${e.message}` };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!skel.length) {
|
|
228
|
+
return { ok: false, code: NS.errors.CODES.NO_SKEL, reason: '包里没有 .skel(不是 Spine 资源包)', atlas, texCount };
|
|
229
|
+
}
|
|
230
|
+
return { ok: true, skel, atlas, texCount };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 契约 §3.1:canHandle 必须是**同步纯布尔**、无 I/O、不抛异常。
|
|
235
|
+
*
|
|
236
|
+
* 三种情形(§3.1 表格):
|
|
237
|
+
* ① 有 bytes 且「UnityFS + CAB + 有 .skel TextAsset」→ true
|
|
238
|
+
* ② 有 bytes 且上述不成立 → false
|
|
239
|
+
* ③ 没有 bytes → true(保守放行:判 false 会让无扩展名的包永远无法被选中)
|
|
240
|
+
*
|
|
241
|
+
* 注意不能写成 async:async 返回 Promise,而 Promise 恒为真值,
|
|
242
|
+
* 宿主按 `if (canHandle(file))` 判断时所有文件都会被放行,判定彻底失效。
|
|
243
|
+
*/
|
|
244
|
+
function canHandle(file) {
|
|
245
|
+
const ctx = asCtx(file);
|
|
246
|
+
const bytes = ctx && ctx.bytes ? ctx.bytes : null;
|
|
247
|
+
if (!bytes) return true;
|
|
248
|
+
try {
|
|
249
|
+
return probeContainer(bytes).ok === true;
|
|
250
|
+
} catch (e) {
|
|
251
|
+
// §3.1/§3.7:内部异常必须吞成 false 且**不得静默** —— 但这里 probeContainer 已把所有
|
|
252
|
+
// 已知路径都转成 {ok:false},走到 catch 说明有未预料的错(如 bytes 类型不符 §1.1)。
|
|
253
|
+
// 用 warn 而非 debug:这种情况通常是调用方用法问题,只有 warn 才查得动。
|
|
254
|
+
ulbLog('warn', `canHandle 内部异常,按不可读处理:${e.message}`
|
|
255
|
+
+ '(若 bytes 不是契约 §1.1 要求的 Uint8Array,请修宿主侧归一)');
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 契约 §4.2:resolve 产出 { prefix, files, entry };失败抛带 code 的错误,不返回缺字段半成品(§五.8)。
|
|
262
|
+
*
|
|
263
|
+
* 产物(键名全部取自包内真实名称,**不自己拼**):
|
|
264
|
+
* · `<base>.skel` —— TextAsset 原名,值是该字段的**原始字节**(二进制,禁字符串回编码)
|
|
265
|
+
* · `<base>.atlas` —— TextAsset 原名,值是该字段的原始字节
|
|
266
|
+
* · `<页名>` —— **照 atlas 里声明的页名**(NIKKE 实测 `c200_01.png` ↔ Texture2D 名 `c200_01`)
|
|
267
|
+
* · `entry.json` —— { skeletonFile, atlasFile, textureFiles[] },entry 指向它
|
|
268
|
+
*
|
|
269
|
+
* 为什么 files 的值一律给 Blob 而不给 string:契约 §4.2 两种都允许,但宿主侧字节化实现
|
|
270
|
+
* (plugin-runner 的 toBytes)对 string 走的是 `new Uint8Array(str)`,会得到"长度对、
|
|
271
|
+
* 内容全 0"的假字节。Blob 是唯一不会在这条路上被误解的形态。
|
|
272
|
+
*/
|
|
273
|
+
async function resolve(file) {
|
|
274
|
+
const ctx = asCtx(file);
|
|
275
|
+
if (!ctx || !ctx.rootId || !ctx.rel) {
|
|
276
|
+
throw NS.errors.fail(NS.errors.CODES.BAD_ARGS, 'resolve() 缺少必选字段:需要 file.rootId 与 file.rel');
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
const hostApi = ctx.host || host;
|
|
280
|
+
const bytes = await readBundleBytes(ctx, hostApi);
|
|
281
|
+
|
|
282
|
+
// —— 先走一遍探测:容器/对象表/.skel 存在性都在那里判过,错误码已经分好类 ——
|
|
283
|
+
const probe = probeContainer(bytes);
|
|
284
|
+
if (!probe.ok) throw NS.errors.fail(probe.code, probe.reason || '不是可解包的 Spine 资源');
|
|
285
|
+
|
|
286
|
+
// —— 容器 → 对象表(probeContainer 内部已 parse 过一次,这里再取一次是为了拿节点字节;
|
|
287
|
+
// parse 是纯 CPU、单包一次,重复一次比把中间态透出来更简单)——
|
|
288
|
+
const parsed = NS.unityfs.parse(bytes);
|
|
289
|
+
const cabNode = parsed.nodes.find((n) => /^CAB-/.test(n.path));
|
|
290
|
+
const resSNode = parsed.nodes.find((n) => /\.resS$/i.test(n.path));
|
|
291
|
+
const sf = NS.serialized.parse(NS.unityfs.nodeBytes(parsed, cabNode.path));
|
|
292
|
+
const resSBytes = resSNode ? NS.unityfs.nodeBytes(parsed, resSNode.path) : null;
|
|
293
|
+
|
|
294
|
+
// —— 素材:skel / atlas 取原始字节 ——
|
|
295
|
+
const { skel, atlas } = extractTextAssetsRaw(sf);
|
|
296
|
+
if (!skel.length) throw NS.errors.fail(NS.errors.CODES.NO_SKEL, '包里没有 .skel TextAsset');
|
|
297
|
+
|
|
298
|
+
// 按「去扩展名的同名」配对(c200_01.skel ↔ c200_01.atlas);配不上就退第一个,不猜内容
|
|
299
|
+
const stemOf = (n) => String(n).replace(/\.[^.\\/]+$/, '');
|
|
300
|
+
const skelPick = skel[0];
|
|
301
|
+
const stem = stemOf(skelPick.name);
|
|
302
|
+
const atlasPick = atlas.find((a) => stemOf(a.name) === stem) || atlas[0];
|
|
303
|
+
if (!atlasPick) throw NS.errors.fail(NS.errors.CODES.NO_ATLAS, `有 ${skelPick.name} 但包里没有 .atlas`);
|
|
304
|
+
|
|
305
|
+
// 版本串读不到只记录不失败:不同 Spine 版本的头部布局可能不同,硬失败会误拒可用的包
|
|
306
|
+
const spineVersion = readSkelVersion(skelPick.bytes);
|
|
307
|
+
if (!spineVersion) ulbLog('warn', `.skel 头部未识别出 Spine 版本串(${skelPick.name}),继续解包`);
|
|
308
|
+
|
|
309
|
+
// —— 纹理:按 atlas 页名找同名 Texture2D,解码后编 PNG ——
|
|
310
|
+
const pages = parseAtlasPages(atlasPick.bytes);
|
|
311
|
+
if (!pages.length) throw NS.errors.fail(NS.errors.CODES.NO_ATLAS, 'atlas 里没有解析到贴图页名');
|
|
312
|
+
const allTextures = NS.extract.findTextures(sf);
|
|
313
|
+
|
|
314
|
+
const files = new Map();
|
|
315
|
+
const textureFiles = [];
|
|
316
|
+
for (const page of pages) {
|
|
317
|
+
const pageStem = stemOf(page);
|
|
318
|
+
const tex = allTextures.find((t) => t.name === pageStem)
|
|
319
|
+
|| allTextures.find((t) => String(t.name).toLowerCase() === pageStem.toLowerCase());
|
|
320
|
+
if (!tex) {
|
|
321
|
+
throw NS.errors.fail(NS.errors.CODES.EXTRACT,
|
|
322
|
+
`atlas 声明了贴图页「${page}」,但包里找不到同名 Texture2D(${pageStem})`);
|
|
323
|
+
}
|
|
324
|
+
// 取压缩数据(.resS 切片或对象内联)→ 解码成 RGBA(flipY: Unity 自下而上存储,
|
|
325
|
+
// atlas 坐标自上而下,翻过来才是正确朝向)→ 编 PNG
|
|
326
|
+
const compressed = NS.extract.textureCompressedBytes(tex, resSBytes);
|
|
327
|
+
const rgba = NS.texdecode.decode(
|
|
328
|
+
{ format: tex.format, width: tex.width, height: tex.height, name: tex.name },
|
|
329
|
+
compressed,
|
|
330
|
+
{ flipY: true },
|
|
331
|
+
);
|
|
332
|
+
let pngBytes;
|
|
333
|
+
try {
|
|
334
|
+
pngBytes = await NS.png.encodePngAsync(rgba, tex.width, tex.height);
|
|
335
|
+
} catch (e) {
|
|
336
|
+
throw NS.errors.fail(NS.errors.CODES.PNG_ENCODE,
|
|
337
|
+
`贴图页 ${page} 的 PNG 编码失败:${e.message}`);
|
|
338
|
+
}
|
|
339
|
+
files.set(page, new Blob([pngBytes], { type: 'image/png' }));
|
|
340
|
+
textureFiles.push(page);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// —— 骨架 / 图集(原始字节,绝不走字符串回编码)——
|
|
344
|
+
files.set(skelPick.name, new Blob([skelPick.bytes], { type: 'application/octet-stream' }));
|
|
345
|
+
files.set(atlasPick.name, new Blob([atlasPick.bytes], { type: 'application/octet-stream' }));
|
|
346
|
+
|
|
347
|
+
// —— 入口清单:字段名与前端约定一致(skeletonFile / atlasFile / textureFiles)——
|
|
348
|
+
const entryJson = {
|
|
349
|
+
skeletonFile: skelPick.name,
|
|
350
|
+
atlasFile: atlasPick.name,
|
|
351
|
+
textureFiles,
|
|
352
|
+
};
|
|
353
|
+
const entryBytes = new TextEncoder().encode(JSON.stringify(entryJson, null, 2));
|
|
354
|
+
files.set(ENTRY_NAME, new Blob([entryBytes], { type: 'application/json' }));
|
|
355
|
+
|
|
356
|
+
// —— prefix(契约 §4.2):vfs://<providerId>/<rootId>/<相对目录>/ ——
|
|
357
|
+
const relDir = String(ctx.rel).replace(/\/+$/, '').split('/').slice(0, -1).join('/');
|
|
358
|
+
const dirSeg = relDir ? `${relDir}/` : '';
|
|
359
|
+
const prefix = `vfs://${PLUGIN_ID}/${String(ctx.rootId)}/${dirSeg}`;
|
|
360
|
+
|
|
361
|
+
ulbLog('info', `已解包 ${ctx.rel}:${files.size} 个文件(skel=${skelPick.name} `
|
|
362
|
+
+ `${skelPick.bytes.length}B${spineVersion ? ' v' + spineVersion : ''},`
|
|
363
|
+
+ `atlas=${atlasPick.name},贴图=${textureFiles.join('/') || '无'})`);
|
|
364
|
+
|
|
365
|
+
return { prefix, files, entry: ENTRY_NAME };
|
|
366
|
+
} catch (e) {
|
|
367
|
+
// 统一在边界把 lib 的 ULB_ 码映射成本插件的 SPI_ 码(见 toSpiError 注释)
|
|
368
|
+
throw toSpiError(e);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ---------------- 内存声明(契约 插件.md §2.2;估得准不准由本插件负责)----------------
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* 运行期固定开销(MB):解包之外、与容器大小无关的那部分。
|
|
376
|
+
* 量级依据:项目的内存门禁 M4 认定「固定项 ≈103MB」(低于它要提示漏算),
|
|
377
|
+
* 而 live2d 插件最小档也报 147MB —— 两者同源(同一套宿主/渲染/V8 开销)。
|
|
378
|
+
* 这是本插件的责任项:声明后核心直接采信、不再叠加安全垫(§2.2 规则四)。
|
|
379
|
+
*/
|
|
380
|
+
const MEMORY_FIXED_OVERHEAD_MB = 103;
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* 每 1MB 解压后容器对应的额外峰值(MB)。
|
|
384
|
+
*
|
|
385
|
+
* 实测依据(3 个 NIKKE 包;`--expose-gc` 采样 + 逐项分配量核算):
|
|
386
|
+
* 构成和 = 源 6.6 + 解压 20.7 + RGBA 16.0 + PNG 3.9 = 47.1MB,÷ 解压 20.7MB ≈ 2.27,取 2.3 留余量。
|
|
387
|
+
*
|
|
388
|
+
* **已知局限(别当成通用公式)**:该比值只对「纹理总量与容器大小相近」的包成立。
|
|
389
|
+
* 若纹理远大于容器(live2d 侧实测 adaerbote_3:解压 42MB 但纹理 RGBA 134MB,比值 4.1),
|
|
390
|
+
* 本式会低估。缓解手段都不在本轮范围:
|
|
391
|
+
* ① resolve 完成后宿主把 rgbaBytes 回填 probe.json,measuredPeakBytes 会用真值修正;
|
|
392
|
+
* ② 攒够样本后把线性式换成分段表(live2d 就是 19 个样本标定的分段表)。
|
|
393
|
+
*/
|
|
394
|
+
const MEMORY_RATIO_PER_DECOMPRESSED_MB = 2.3;
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* LZMA 峰值落点:解压后 MB → **峰值**(MB,未乘余量)。
|
|
398
|
+
*
|
|
399
|
+
* 基线:`6bcbb22` 换成"写入预分配 Uint8Array"的解码器之后实测的三个点
|
|
400
|
+
* `2.8MB → 97.8 | 12.86MB → 131.0 | 33.4MB → 218.1`
|
|
401
|
+
* ⇒ 新解码器下峰值几乎是平的(不再随解压量近似线性暴涨),与旧记录的 46 倍膨胀不是一回事。
|
|
402
|
+
*
|
|
403
|
+
* 取值原则:**宽松的整数**,不再按密集实测点插值。旧表是旧解码器的实测点(2.8→171 … 33.4→995),
|
|
404
|
+
* 换解码器后高估 1.7~4.6 倍、准入过严会把本来解得动的包挡掉;现只留五个整数落点、严格递增:
|
|
405
|
+
* 2MB→128 | 8MB→192 | 16MB→224 | 32MB→288 | 64MB→416
|
|
406
|
+
*
|
|
407
|
+
* 测量口径(与准入同一个量):
|
|
408
|
+
* `峰值 = 起始 RSS + 本次 VmHWM 增量` —— 与 plugin-store 的 rssNow / recordMeasuredPeak 同式,
|
|
409
|
+
* 即"服务端解这个包会记录的峰值";标定工具 tool/fit-memory-peak.mjs 按"每包独立干净进程"测。
|
|
410
|
+
*
|
|
411
|
+
* ⚠️ 本表与 `unity-live2d-bundle/index.js` 里的同名表**必须逐值相同**(同一物理事实):
|
|
412
|
+
* test/memory-peak-parity.test.mjs 会导入两边实现逐值比对,不一致就红。
|
|
413
|
+
* ⚠️ 将来重标时不要把"进程内连续解压"的结果混进来(VmHWM 只增不减,后测会被前测带偏)。
|
|
414
|
+
*/
|
|
415
|
+
const LZMA_PEAK_POINTS = [[2, 128], [8, 192], [16, 224], [32, 288], [64, 416]];
|
|
416
|
+
/** 峰值安全垫:1.0 —— 表中已是宽松整数上界,不再叠乘系数 */
|
|
417
|
+
const PEAK_MARGIN = 1.0;
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* LZMA 路径的峰值(MB):落点之间线性内插、超出最大落点按最后一段斜率外推。
|
|
421
|
+
* 落点已按整数宽松取值且严格递增,所以查表结果天然单调(包络是空操作,留着只为兜底)。
|
|
422
|
+
* @param {number} decompressedMb
|
|
423
|
+
* @returns {number}
|
|
424
|
+
*/
|
|
425
|
+
function peakFromLzma(decompressedMb) {
|
|
426
|
+
if (!(decompressedMb > 0)) return 0;
|
|
427
|
+
// 累进上包络:即使将来有人手工往表里加了下降的点,也保证"更大的包不会估得更少"。
|
|
428
|
+
// 现值表严格递增,这一步因此是空操作。
|
|
429
|
+
const pts = [];
|
|
430
|
+
let env = 0;
|
|
431
|
+
for (const [mb, peak] of LZMA_PEAK_POINTS) {
|
|
432
|
+
if (peak > env) env = peak;
|
|
433
|
+
pts.push([mb, env]);
|
|
434
|
+
}
|
|
435
|
+
// 比最小落点还小:按最小落点算(LZMA 字典/窗口开销固定,小包不会更省)
|
|
436
|
+
if (decompressedMb <= pts[0][0]) return Math.ceil(pts[0][1] * PEAK_MARGIN);
|
|
437
|
+
for (let i = 1; i < pts.length; i += 1) {
|
|
438
|
+
if (decompressedMb <= pts[i][0]) {
|
|
439
|
+
const [m0, v0] = pts[i - 1];
|
|
440
|
+
const [m1, v1] = pts[i];
|
|
441
|
+
const t = (decompressedMb - m0) / (m1 - m0);
|
|
442
|
+
// 线性插值,并钳到不低于下界值(防御:表若被手工改成下降,也不让结果低于更小的包)。
|
|
443
|
+
return Math.ceil(Math.max(v0, v0 + (v1 - v0) * t) * PEAK_MARGIN);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
const [ma, va] = pts[pts.length - 2];
|
|
447
|
+
const [mb, vb] = pts[pts.length - 1];
|
|
448
|
+
const slope = Math.max(0, (vb - va) / (mb - ma));
|
|
449
|
+
return Math.ceil((vb + (decompressedMb - mb) * slope) * PEAK_MARGIN);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* 认定某压缩类型是否走 LZMA 表。
|
|
454
|
+
* 认不出(未传 / 混合 -1 / 未来新增类型)⇒ **按 LZMA 算(更保守)**:
|
|
455
|
+
* 低估会放行 → 真冲高 → OOM 或被守护中止;高估只是"这次被拒/排队",代价不对等。
|
|
456
|
+
* @param {number} t
|
|
457
|
+
* @returns {boolean}
|
|
458
|
+
*/
|
|
459
|
+
function isLzmaPeak(t) {
|
|
460
|
+
// 白名单:只有明确已知的非 LZMA 类型(NONE=0 / LZ4=2 / LZ4HC=3)才不用 LZMA 表;
|
|
461
|
+
// 未传 / 混合 -1 / 未来新增类型 一律按 LZMA(保守)。
|
|
462
|
+
return !(t === 0 || t === 2 || t === 3);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* 内存代价声明(契约 §2.2 第②段:`decompressedBytes` 必须能从容器块表推出,不许填经验值)。
|
|
467
|
+
*
|
|
468
|
+
* 口径要点:
|
|
469
|
+
* · `decompressedBytes` 走 `unityfs.blocksSummary` —— **只读头 + blocksInfo,不解压数据块**;
|
|
470
|
+
* · **不用 TextAsset 的文本长度**:`.skel` 二进制经 UTF-8 往返会膨胀
|
|
471
|
+
* (实测 651994 字节 → 980446 字节,1.5 倍),拿它估内存会系统性高估。本插件一律 raw 字节口径;
|
|
472
|
+
* · 宿主可能只给 256KB 头部(`size:0`):块表在尾部的包(NIKKE 是 0x80 置位)解析会抛错
|
|
473
|
+
* ⇒ 这里返回 null ⇒ 宿主回退全量读 —— 属正常路径,不是失败。
|
|
474
|
+
*
|
|
475
|
+
* @param {{bytes?: Uint8Array}} file
|
|
476
|
+
* @returns {{decompressedBytes:number, compressedBytes:number, peakBytes:number}|null}
|
|
477
|
+
*/
|
|
478
|
+
function estimateMemory(file) {
|
|
479
|
+
try {
|
|
480
|
+
const bytes = file && file.bytes;
|
|
481
|
+
if (!bytes || !bytes.length) return null;
|
|
482
|
+
const summary = NS.unityfs.blocksSummary(bytes);
|
|
483
|
+
if (!(summary.decompressedBytes > 0)) return null;
|
|
484
|
+
const decompressedMb = summary.decompressedBytes / (1024 * 1024);
|
|
485
|
+
// 压缩类型取自块表(blocksSummary 顺手带出,不额外扫包);LZMA 走实测折线表,
|
|
486
|
+
// 其余走原线性式(那是按 LZ4 包标定的)。
|
|
487
|
+
const peakMb = isLzmaPeak(summary.compression)
|
|
488
|
+
? peakFromLzma(decompressedMb)
|
|
489
|
+
: MEMORY_FIXED_OVERHEAD_MB + decompressedMb * MEMORY_RATIO_PER_DECOMPRESSED_MB;
|
|
490
|
+
return {
|
|
491
|
+
decompressedBytes: summary.decompressedBytes,
|
|
492
|
+
compressedBytes: summary.compressedBytes,
|
|
493
|
+
compressionType: summary.compression,
|
|
494
|
+
peakBytes: Math.ceil(peakMb * 1024 * 1024),
|
|
495
|
+
};
|
|
496
|
+
} catch {
|
|
497
|
+
return null; // 算不出来就让核心走「源包大小粗估」回落,绝不因此阻断加载
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (host) {
|
|
502
|
+
host.register({
|
|
503
|
+
id: PLUGIN_ID,
|
|
504
|
+
name: 'Unity Spine 资源包',
|
|
505
|
+
label: 'Spine包',
|
|
506
|
+
canHandle,
|
|
507
|
+
resolve,
|
|
508
|
+
// 契约 §2.2:让宿主与检查器都能发现"本插件能自己算内存代价"
|
|
509
|
+
estimateMemory,
|
|
510
|
+
// kind 的唯一权威来源是 plugin.json 的 produces.kind(契约 §3.2),这里带上只为调试可读。
|
|
511
|
+
kind: 'spine',
|
|
512
|
+
fileKinds: ['bundle'],
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// node 侧返回解析入口:服务端解包直接从返回值取 canHandle/resolve,不经过宿主注册。
|
|
517
|
+
// __test 只给单测用(纯函数、无副作用、宿主不读它):parseAtlasPages 是闭包内私有函数,
|
|
518
|
+
// 没有它就无法为"带 BOM 的 atlas 首行"补单测(这正是 NIKKE 根 34 份带 BOM 的 atlas 丢第一页贴图的根因)。
|
|
519
|
+
return { canHandle, resolve, estimateMemory, probeContainer, PLUGIN_ID, __test: { parseAtlasPages } };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// 执行安装。为什么不用 `export { canHandle }`:那会把文件变成 ESM,
|
|
523
|
+
// 浏览器以 <script> 注入时 export 是语法错误 ⇒ 整个插件加载失败。保持经典脚本形态,
|
|
524
|
+
// node 侧用条件挂载暴露。
|
|
525
|
+
//
|
|
526
|
+
// 顶层常量必须带插件前缀:多个插件的脚本都注入到**同一个全局作用域**,
|
|
527
|
+
// 用 `ulb` / `api` / `ns` 这类通用名会与别的插件撞车,
|
|
528
|
+
// 表现为先加载的插件正常、后加载的插件报 Uncaught SyntaxError: Identifier 'x' has already been declared,
|
|
529
|
+
// 而且那条错在插件自己的代码之外,插件整个不执行(实测踩过:本插件叫 ulb 时把 live2d 插件整挂了)。
|
|
530
|
+
const spiApi = install();
|
|
531
|
+
if (spiApi && typeof window === 'undefined' && typeof globalThis !== 'undefined') {
|
|
532
|
+
// node 侧挂载键:__PLUGIN_API__ 是统一契约,__ULB_NODE__ 保留兼容旧 runner
|
|
533
|
+
globalThis.__PLUGIN_API__ = spiApi;
|
|
534
|
+
globalThis.__ULB_NODE__ = spiApi;
|
|
535
|
+
}
|