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,396 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// plugins/unity-live2d-bundle/lib/unityfs.js —— UnityFS 容器解析
|
|
3
|
+
//
|
|
4
|
+
// 职责:把 UnityFS(Unity 5+ AssetBundle)容器解开,得到
|
|
5
|
+
// ① 头信息(版本/引擎版本/归档标志)
|
|
6
|
+
// ② blocksInfo:数据块表 + **节点表**(每个节点 = 一个内嵌文件,如 CAB-xxx 与 .resS)
|
|
7
|
+
// ③ 数据区起点(节点数据在整份文件里的绝对偏移)与拼接后的数据字节
|
|
8
|
+
//
|
|
9
|
+
// 结构(UnityFS,v6+ 的 read_fs 路径):
|
|
10
|
+
// signature "UnityFS\0" | version u32 | playerVer cstring | engineVer cstring
|
|
11
|
+
// size i64 | blocksInfoCompressedSize u32 | blocksInfoUncompressedSize u32 | dataflags u32
|
|
12
|
+
// [2019.4.15+ 或 version>=7:对齐到 16 字节] ← 未压缩包里会看到一段填充
|
|
13
|
+
// blocksInfo(长度 = blocksInfoCompressedSize,可能被压缩):
|
|
14
|
+
// 16B hash | i32 blockCount | blockCount×{ u32 uncompressedSize, u32 compressedSize, u16 flags }
|
|
15
|
+
// i32 nodeCount | nodeCount×{ i64 offset, i64 size, u32 flags, cstring path }
|
|
16
|
+
// 若 dataflags 带 BlockInfoNeedPaddingAtStart:再对齐到 16 字节
|
|
17
|
+
// blocks:blockCount 段数据(各自按 flags 压缩),拼接后即所有节点的数据源
|
|
18
|
+
//
|
|
19
|
+
// 压缩分支:flags 低 6 位是压缩类型(0=none,1=LZMA,2=LZ4,3=LZ4HC,4=LZHAM)。
|
|
20
|
+
// 本插件实现 none 与 LZ4;其余(LZMA/LZHAM)**抛可读错误**而不是猜,
|
|
21
|
+
// 避免解出错数据后被后面几层误当成"模型损坏"。
|
|
22
|
+
//
|
|
23
|
+
// 挂载:globalThis.__ULB__.unityfs
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
{
|
|
29
|
+
const root = globalThis; // 原 IIFE 的 root 引用(脚本级裸块内保持同名)
|
|
30
|
+
const NS = (root.__ULB__ = root.__ULB__ || {});
|
|
31
|
+
const B = NS.bytes;
|
|
32
|
+
|
|
33
|
+
// 归档标志位(ArchiveFlags,Unity 2020.3 及以后;名字沿用 Unity 源码里的 kArchive*)
|
|
34
|
+
const ARCHIVE = {
|
|
35
|
+
CompressionTypeMask: 0x3f,
|
|
36
|
+
BlocksAndDirectoryInfoCombined: 0x40,
|
|
37
|
+
BlocksInfoAtTheEnd: 0x80,
|
|
38
|
+
OldWebPluginCompatibility: 0x100,
|
|
39
|
+
BlockInfoNeedPaddingAtStart: 0x200,
|
|
40
|
+
UsesAssetBundleEncryption: 0x1400,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// 关于 BlocksAndDirectoryInfoCombined(0x40):**不参与解析判断**。
|
|
44
|
+
//
|
|
45
|
+
// 它名义上表示「blocks 与 directoryInfo 合并为一段」,但权威实现(AssetStudio 的
|
|
46
|
+
// BundleFile.cs → ReadBlocksInfoAndDirectory)的判断链里**从不读这一位**,
|
|
47
|
+
// 实际只按 0x80(BlocksInfoAtTheEnd)区分两种布局。详见 doc/参考/README.md 结论一。
|
|
48
|
+
//
|
|
49
|
+
// 不能拿「0x40 置位」当拒绝/不支持的依据:实测正常包 dataflags = 0x240 本身就含 0x40
|
|
50
|
+
// (0x240 & 0x40 = 0x40),按它拒绝会把**全部正常包一起拒掉**。
|
|
51
|
+
// 名义语义与实现语义不一致时,以实现的判断链为准。
|
|
52
|
+
|
|
53
|
+
const COMPRESSION = { NONE: 0, LZMA: 1, LZ4: 2, LZ4HC: 3, LZHAM: 4 };
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* LZ4 块解压(LZ4 block format,不含帧头)。
|
|
57
|
+
*
|
|
58
|
+
* 为什么自己实现:容器允许 LZ4 压缩的数据块,浏览器端没有内置 LZ4。
|
|
59
|
+
* 与 UnityPy 的 lz4.block.decompress 对齐:入参是裸块,输出固定为 expectedSize。
|
|
60
|
+
*/
|
|
61
|
+
function lz4Decompress(src, expectedSize, target, targetOffset = 0) {
|
|
62
|
+
// target:若调用方已预分配好整段缓冲(多块拼接场景),直接写到它的对应区间,
|
|
63
|
+
// 省掉"每块各分配一份 + 最后 concat 再复制一份"的第二份内存。
|
|
64
|
+
// 安全性:LZ4 block 的匹配段只引用**同一块内**已解出的字节,不跨块 ⇒ 写到大缓冲的任意 offset 都成立。
|
|
65
|
+
const dst = target ? target.subarray(targetOffset, targetOffset + expectedSize) : new Uint8Array(expectedSize);
|
|
66
|
+
let si = 0;
|
|
67
|
+
let di = 0;
|
|
68
|
+
while (si < src.length) {
|
|
69
|
+
const token = src[si++];
|
|
70
|
+
// ① 字面量段
|
|
71
|
+
let litLen = token >>> 4;
|
|
72
|
+
if (litLen === 15) {
|
|
73
|
+
let b;
|
|
74
|
+
do { b = src[si++]; litLen += b; } while (b === 255);
|
|
75
|
+
}
|
|
76
|
+
for (let i = 0; i < litLen; i++) dst[di++] = src[si++];
|
|
77
|
+
if (si >= src.length) break; // 末段只有字面量
|
|
78
|
+
// ② 匹配段:2 字节小端偏移,长度 4 + 扩展
|
|
79
|
+
const offset = src[si] | (src[si + 1] << 8);
|
|
80
|
+
si += 2;
|
|
81
|
+
if (offset === 0) throw NS.errors.fail('ULB_ERR_CONTAINER_PARSE', 'LZ4 数据损坏:匹配偏移为 0');
|
|
82
|
+
let matchLen = token & 15;
|
|
83
|
+
if (matchLen === 15) {
|
|
84
|
+
let b;
|
|
85
|
+
do { b = src[si++]; matchLen += b; } while (b === 255);
|
|
86
|
+
}
|
|
87
|
+
matchLen += 4;
|
|
88
|
+
const start = di - offset;
|
|
89
|
+
if (start < 0) throw NS.errors.fail('ULB_ERR_CONTAINER_PARSE', 'LZ4 数据损坏:匹配位置越界');
|
|
90
|
+
for (let i = 0; i < matchLen; i++) dst[di++] = dst[start + i];
|
|
91
|
+
}
|
|
92
|
+
if (di !== expectedSize) {
|
|
93
|
+
throw NS.errors.fail('ULB_ERR_CONTAINER_PARSE', `LZ4 解压长度不符:得到 ${di},期望 ${expectedSize}`);
|
|
94
|
+
}
|
|
95
|
+
return dst;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* LZMA 块解压。
|
|
100
|
+
*
|
|
101
|
+
* **格式取证(从真实包读出,不是按文档推测)**:UnityFS 的 LZMA 块是
|
|
102
|
+
* `[5 字节 props][raw LZMA1 流]` —— **没有** .lzma(alone) 那 8 字节未压缩长度。
|
|
103
|
+
* props 5 字节 = 1 字节 lc/lp/pb + 4 字节 dictSize(LE),实测全部样本恒为 `5d 00 00 08 00`
|
|
104
|
+
* (lc=3 / lp=0 / pb=2 / dictSize=512KB,即 LZMA 标准默认值)。
|
|
105
|
+
*
|
|
106
|
+
* 做法:补上那 8 字节长度(用容器给的 uncompressedSize,小端)拼成标准 .lzma(alone),
|
|
107
|
+
* 再交给 vendored 的 `LZMA.decompress`。这样不必用 raw API —— 任何支持 alone 的解码器都能接。
|
|
108
|
+
*
|
|
109
|
+
* **为什么必须自己校验长度**:实测把 uncompressedSize 传错(误用 blocksInfo 的 91 而非本块的
|
|
110
|
+
* 8677760)时,解码器**不报错**,静默解出 91 字节就返回。不校验会让半截数据流到下游,
|
|
111
|
+
* 表现成"模型损坏",极难定位。
|
|
112
|
+
*
|
|
113
|
+
* 调用方注意:blocksInfo 传**头的 usz**,block 数据传**该块自己的 uncompressedSize**(两者不同)。
|
|
114
|
+
* @param {Uint8Array} src 原始压缩字节(含开头 5 字节 props)
|
|
115
|
+
* @param {number} uncompressedSize 本段声明解压后大小
|
|
116
|
+
* @param {Uint8Array} [target] 预分配目标缓冲(多块拼接场景)
|
|
117
|
+
* @param {number} [targetOffset]
|
|
118
|
+
* @returns {Uint8Array} 解压后的字节
|
|
119
|
+
*/
|
|
120
|
+
function lzmaDecompress(src, uncompressedSize, target, targetOffset = 0) {
|
|
121
|
+
// 解码器由 lib/lzma-d-min.js 挂到全局(classic script,靠 plugin.json 的 files[] 顺序加载)
|
|
122
|
+
const LZMA = root.LZMA;
|
|
123
|
+
if (!LZMA || typeof LZMA.decompress !== 'function') {
|
|
124
|
+
throw NS.errors.fail('ULB_ERR_LZMA_UNAVAILABLE',
|
|
125
|
+
'LZMA 解码器未就绪:lib/lzma-d-min.js 需在 plugin.json 的 files[] 中排在 lib/unityfs.js 之前');
|
|
126
|
+
}
|
|
127
|
+
if (!src || src.length < 6) {
|
|
128
|
+
throw NS.errors.fail('ULB_ERR_LZMA_FAIL',
|
|
129
|
+
`LZMA 块数据过短:${src ? src.length : 0} 字节(至少需 5 字节 props + 压缩数据)`);
|
|
130
|
+
}
|
|
131
|
+
// 拼 13 字节 alone 头:5 props + 8 字节未压缩长度(小端)
|
|
132
|
+
const alone = new Uint8Array(13 + src.length - 5);
|
|
133
|
+
alone.set(src.subarray(0, 5), 0);
|
|
134
|
+
new DataView(alone.buffer, alone.byteOffset).setBigUint64(5, BigInt(uncompressedSize), true);
|
|
135
|
+
alone.set(src.subarray(5), 13);
|
|
136
|
+
|
|
137
|
+
let out;
|
|
138
|
+
try {
|
|
139
|
+
out = LZMA.decompress(alone);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
throw NS.errors.fail('ULB_ERR_LZMA_FAIL', `LZMA 解压失败:${(e && e.message) || e}`);
|
|
142
|
+
}
|
|
143
|
+
// 上游返回普通 Array(且跨 realm)⇒ 统一归一成 Uint8Array,调用方只处理一种类型
|
|
144
|
+
const bytes = out instanceof Uint8Array ? out : Uint8Array.from(out || []);
|
|
145
|
+
if (bytes.length !== uncompressedSize) {
|
|
146
|
+
throw NS.errors.fail('ULB_ERR_LZMA_FAIL',
|
|
147
|
+
`LZMA 解压长度不符:声明 ${uncompressedSize},实际 ${bytes.length}(解码器不校验长度,必须由本层兜住)`);
|
|
148
|
+
}
|
|
149
|
+
if (target) {
|
|
150
|
+
target.set(bytes, targetOffset);
|
|
151
|
+
return target.subarray(targetOffset, targetOffset + bytes.length);
|
|
152
|
+
}
|
|
153
|
+
return bytes;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 按压缩类型解压一段数据(flags 低 6 位) */
|
|
157
|
+
function decompress(bytes, uncompressedSize, flags, where, target, targetOffset = 0) {
|
|
158
|
+
const type = flags & ARCHIVE.CompressionTypeMask;
|
|
159
|
+
if (type === COMPRESSION.NONE) {
|
|
160
|
+
if (bytes.length !== uncompressedSize) {
|
|
161
|
+
throw NS.errors.fail('ULB_ERR_CONTAINER_PARSE', `${where || '数据块'} 声明未压缩但长度不符:实际 ${bytes.length},声明 ${uncompressedSize}`);
|
|
162
|
+
}
|
|
163
|
+
// 未压缩块没有"解压"这步,只能拷进目标区间(语义与拼接一致,不再多留一份视图)
|
|
164
|
+
if (target) { target.set(bytes, targetOffset); return target.subarray(targetOffset, targetOffset + bytes.length); }
|
|
165
|
+
return bytes;
|
|
166
|
+
}
|
|
167
|
+
if (type === COMPRESSION.LZ4 || type === COMPRESSION.LZ4HC) {
|
|
168
|
+
return lz4Decompress(bytes, uncompressedSize, target, targetOffset);
|
|
169
|
+
}
|
|
170
|
+
// LZMA 按**每块自己的 flags** 判(Unity 允许 blocksInfo 与 block 用不同压缩:
|
|
171
|
+
// 实测 57 个包 blocksInfo 是 LZ4HC/LZ4/none 而 block 是 LZMA)⇒ 不能按整包类型一刀切
|
|
172
|
+
if (type === COMPRESSION.LZMA) {
|
|
173
|
+
return lzmaDecompress(bytes, uncompressedSize, target, targetOffset);
|
|
174
|
+
}
|
|
175
|
+
const label = type === COMPRESSION.LZHAM ? 'LZHAM' : `未知(${type})`;
|
|
176
|
+
throw NS.errors.fail('ULB_ERR_CONTAINER_PARSE', `${where || '数据块'} 使用 ${label} 压缩,本插件尚未支持该分支(已支持 none / LZ4 / LZMA)`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 解析 UnityFS 容器。
|
|
181
|
+
* @param {Uint8Array} u8 整个文件字节
|
|
182
|
+
* @returns {{
|
|
183
|
+
* signature:string, version:number, playerVersion:string, engineVersion:string,
|
|
184
|
+
* dataflags:number, blocksInfoAtEnd:boolean, usesEncryption:boolean,
|
|
185
|
+
* blocks:{uncompressedSize:number,compressedSize:number,flags:number}[],
|
|
186
|
+
* nodes:{path:string,offset:number,size:number,flags:number}[],
|
|
187
|
+
* blocksInfoBytes:number, dataStart:number, data:Uint8Array
|
|
188
|
+
* }}
|
|
189
|
+
*/
|
|
190
|
+
/**
|
|
191
|
+
* 只读容器头 + blocksInfo 表(**不解压任何数据块**)。
|
|
192
|
+
*
|
|
193
|
+
* 为什么单拎出来:准入判断只需要"解压后总共多大",而解压数据块是全流程最贵的一步
|
|
194
|
+
* (LZ4 包 19MB→42MB 实测 150ms+)。有了它就能在一份块表上求和,P90 < 5ms。
|
|
195
|
+
* @param {Uint8Array} u8
|
|
196
|
+
* @returns {object} parse 返回里的"头部字段"(不含 data / blockSpans / totalSize)
|
|
197
|
+
*/
|
|
198
|
+
function readHeader(u8) {
|
|
199
|
+
// UnityFS 的容器头与 blocksInfo 表都是**大端**(与内嵌 SerializedFile 的小端不同)
|
|
200
|
+
const r = new B.Reader(u8, { endian: '>' });
|
|
201
|
+
const signature = r.cstring('signature');
|
|
202
|
+
if (signature !== 'UnityFS') throw NS.errors.fail('ULB_ERR_CONTAINER_PARSE', `不是 UnityFS 容器(signature="${signature}")`);
|
|
203
|
+
const version = r.u32();
|
|
204
|
+
const playerVersion = r.cstring('playerVersion');
|
|
205
|
+
const engineVersion = r.cstring('engineVersion');
|
|
206
|
+
|
|
207
|
+
// 逻辑长度:**标准包 == 文件长度**;带尾随数据的包(实测 NIKKE 在 UnityFS 内容后追加 16 字节 trailer)
|
|
208
|
+
// 则小于文件长度。定位 blocksInfo 必须用它,不能用文件长度(见下方 blocksInfoAtEnd 分支)。
|
|
209
|
+
const bundleSize = r.i64('bundleSize');
|
|
210
|
+
const blocksInfoCompressedSize = r.u32();
|
|
211
|
+
const blocksInfoUncompressedSize = r.u32();
|
|
212
|
+
const dataflags = r.u32();
|
|
213
|
+
|
|
214
|
+
if (dataflags & ARCHIVE.UsesAssetBundleEncryption) {
|
|
215
|
+
throw NS.errors.fail('ULB_ERR_CONTAINER_PARSE', '该资源包使用了 AssetBundle 加密(UsesAssetBundleEncryption),本插件无法读取');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// version>=7(或 2019.4.15+)时,blocksInfo 之前要对齐到 16 字节
|
|
219
|
+
if (version >= 7) r.align(16);
|
|
220
|
+
|
|
221
|
+
const blocksInfoAtEnd = (dataflags & ARCHIVE.BlocksInfoAtTheEnd) !== 0;
|
|
222
|
+
const start = r.pos;
|
|
223
|
+
let blocksInfoRaw;
|
|
224
|
+
if (blocksInfoAtEnd) {
|
|
225
|
+
// 从「逻辑末尾」往前取 blocksInfo,用 bundleSize 而不是文件长度 r.length:
|
|
226
|
+
// 标准包两者相等(行为不变);但实测 NIKKE 包在 UnityFS 内容之后又追加了 16 字节 trailer
|
|
227
|
+
// (三样本一致),此时 r.length 会把 trailer 算进来 ⇒ 起点后移 16 字节 ⇒ LZ4 解出垃圾,
|
|
228
|
+
// 报「LZ4 数据损坏:匹配偏移为 0」,与真实原因相距极远。
|
|
229
|
+
// 边界:bundleSize 不合理(非正数 / 超出文件长度)时回退 r.length,
|
|
230
|
+
// 避免坏包让 seek 落到非法位置(那种情况的报错会更难读)。
|
|
231
|
+
const logicalEnd = (bundleSize > 0 && bundleSize <= r.length) ? bundleSize : r.length;
|
|
232
|
+
r.seek(logicalEnd - blocksInfoCompressedSize);
|
|
233
|
+
blocksInfoRaw = r.bytes(blocksInfoCompressedSize, 'blocksInfo');
|
|
234
|
+
r.seek(start);
|
|
235
|
+
} else {
|
|
236
|
+
blocksInfoRaw = r.bytes(blocksInfoCompressedSize, 'blocksInfo');
|
|
237
|
+
}
|
|
238
|
+
const blocksInfoBytes = decompress(blocksInfoRaw, blocksInfoUncompressedSize, dataflags & ARCHIVE.CompressionTypeMask, 'blocksInfo');
|
|
239
|
+
|
|
240
|
+
const bi = new B.Reader(blocksInfoBytes, { baseOffset: start, endian: '>' });
|
|
241
|
+
const hash = bi.bytes(16, 'blocksInfo.hash');
|
|
242
|
+
const blockCount = bi.i32();
|
|
243
|
+
const blocks = [];
|
|
244
|
+
for (let i = 0; i < blockCount; i++) {
|
|
245
|
+
blocks.push({
|
|
246
|
+
uncompressedSize: bi.u32(),
|
|
247
|
+
compressedSize: bi.u32(),
|
|
248
|
+
flags: bi.u16(),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
const nodeCount = bi.i32();
|
|
252
|
+
const nodes = [];
|
|
253
|
+
for (let i = 0; i < nodeCount; i++) {
|
|
254
|
+
nodes.push({
|
|
255
|
+
offset: bi.i64('node.offset'),
|
|
256
|
+
size: bi.i64('node.size'),
|
|
257
|
+
flags: bi.u32(),
|
|
258
|
+
path: bi.cstring('node.path'),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
const blocksInfoContentEnd = bi.absOffset; // 表**内容**结束位置(不含表尾填充)
|
|
262
|
+
// 数据区起点(依据 AssetStudio BundleFile.cs ReadBlocksInfoAndDirectory,d158e86):
|
|
263
|
+
// · blocksInfo **内联在头部**(未置 0x80)时,该段按**声明长度** blocksInfoCompressedSize
|
|
264
|
+
// 占位——未压缩包的声明长度可能比表内容大,差值即表尾对齐填充
|
|
265
|
+
// (实测样本 160 - 153 = 7 字节),故需把游标推到 start + compressedSize 才是数据起点。
|
|
266
|
+
// 别把「表内容结束」当成「数据开始」:那会整体错位 7 字节,moc3 的 sha256 立刻不对。
|
|
267
|
+
// · blocksInfo **在文件尾**(置 0x80)时,数据块紧接 header 之后开始;
|
|
268
|
+
// 上面读 blocksInfo 前已保存 position、读后已 seek 回来,此处**不能再加 declaredEnd**,
|
|
269
|
+
// 否则会把数据起点整体推后 compressedSize 字节 → 数据块错位 → LZ4 解出垃圾
|
|
270
|
+
// (报"LZ4 数据损坏:匹配位置越界",与真实原因相距极远,极难排查)。
|
|
271
|
+
// 实测踩坑:live2d/aijier_3(Unity 2018.4 / version 6 / LZ4 / flags 0xC2)正是此情形,
|
|
272
|
+
// 19 个样本里唯一一个 blocksInfo 在尾的包,旧代码一直没覆盖到。
|
|
273
|
+
if (!blocksInfoAtEnd) {
|
|
274
|
+
const declaredEnd = start + blocksInfoCompressedSize;
|
|
275
|
+
if (declaredEnd > r.pos) r.seek(declaredEnd);
|
|
276
|
+
}
|
|
277
|
+
// ── 内联 blocksInfo 与数据区之间的**对齐填充**(实测 3 个 version 6 / Unity 5.x.x 包)──
|
|
278
|
+
//
|
|
279
|
+
// 实测(aidang_2 / banrenma_2 / xianghe_2,三者结构完全同型):
|
|
280
|
+
// header 50 字节 + blocksInfo 91 字节 = 141,但数据块实际从 **144** 开始
|
|
281
|
+
// (141 后跟 3 字节填充,144 正好 16 字节对齐)。
|
|
282
|
+
// 这三个包的 node.offset 都写着 **3** —— 恰好等于填充量,
|
|
283
|
+
// 说明节点偏移是以「blocksInfo 末尾」为基准记录的,而不是对齐后的数据起点。
|
|
284
|
+
// 验证(算术闭合):144 + node.size == 文件长度
|
|
285
|
+
// aidang_2 144 + 18666148 = 18666292 ✓
|
|
286
|
+
// banrenma_2 144 + 6115708 = 6115852 ✓
|
|
287
|
+
// xianghe_2 144 + 11740648 = 11740792 ✓
|
|
288
|
+
// 不处理会怎样:数据区少取 3 字节(末 3 字节被切掉),而节点又按 3 起算 ⇒
|
|
289
|
+
// 报「节点 CAB-xxx 越界:offset=3 size=18666148 数据区=18666148」,与真实原因相距极远。
|
|
290
|
+
//
|
|
291
|
+
// 判据用**数据自证**而不是猜标志位:这三包的 dataflags = 0x40,并没有置 0x200
|
|
292
|
+
// (BlockInfoNeedPaddingAtStart),所以不能靠标志位判断填充是否存在。
|
|
293
|
+
// 这里用「文件里剩下的字节 - 各块声明压缩大小之和」当填充量:
|
|
294
|
+
// 正常包该差值为 0(行为完全不变);这三个包为 3。
|
|
295
|
+
const logicalEndForPad = (bundleSize > 0 && bundleSize <= r.length) ? bundleSize : r.length;
|
|
296
|
+
let dataPadding = 0;
|
|
297
|
+
if (!blocksInfoAtEnd) {
|
|
298
|
+
const declaredData = blocks.reduce((a, b) => a + b.compressedSize, 0);
|
|
299
|
+
const pad = logicalEndForPad - r.pos - declaredData;
|
|
300
|
+
// 判据必须**两条同时成立**,否则会把"文件尾 trailer"误当成"头部填充":
|
|
301
|
+
// ① 0 < pad < 16(对齐填充的量级);
|
|
302
|
+
// ② **最小节点偏移 == pad** —— 这是决定性判据:
|
|
303
|
+
// 头部填充时,节点偏移以 blocksInfo 末尾为基准,首个节点正好落在填充之后(offset==pad);
|
|
304
|
+
// 尾部 trailer 时,节点偏移本来就以数据起点为基准(首个节点 offset==0),
|
|
305
|
+
// 差值全在文件末尾。实测:不收紧这条会误判 26 个包、把节点偏移减成负数。
|
|
306
|
+
const minNodeOffset = nodes.length ? Math.min(...nodes.map((n) => n.offset)) : 0;
|
|
307
|
+
if (pad > 0 && pad < 16 && minNodeOffset === pad) {
|
|
308
|
+
dataPadding = pad;
|
|
309
|
+
// 节点偏移统一减掉填充 ⇒ 全部改为「相对数据起点」,与下面解压出的缓冲同一基准
|
|
310
|
+
for (const n of nodes) n.offset -= pad;
|
|
311
|
+
r.seek(r.pos + pad);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (dataflags & ARCHIVE.BlockInfoNeedPaddingAtStart) r.align(16);
|
|
315
|
+
const dataStart = r.pos;
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
signature, version, playerVersion, engineVersion, dataflags, blocksInfoAtEnd,
|
|
319
|
+
usesEncryption: false, blocks, nodes, blocksInfoContentEnd, dataPadding,
|
|
320
|
+
blocksInfoBytes: blocksInfoContentEnd - start, dataStart, blocksInfoHash: B.toHex(hash),
|
|
321
|
+
reader: r, // 游标停在 dataStart,parse 解压数据块时要用;对外返回前剔除
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* 解析 UnityFS 容器(**含数据块解压**)。头部与块表复用 readHeader。
|
|
327
|
+
* @param {Uint8Array} u8 整个文件字节
|
|
328
|
+
*/
|
|
329
|
+
function parse(u8) {
|
|
330
|
+
const h = readHeader(u8);
|
|
331
|
+
const { blocks, dataStart, reader: r } = h;
|
|
332
|
+
|
|
333
|
+
// 数据块:**预分配整段**,逐块直接解压到对应 offset。
|
|
334
|
+
// 为什么不再用「每块各解一份 + concat」:那会让峰值 = 解压总量 ×2
|
|
335
|
+
// (实测 19MB 包:解压 42.1MB + concat 复制 42.1MB = 84.2MB),而块只有一个时又白留一份。
|
|
336
|
+
// 节点的 offset/size 相对这份整段缓冲,语义与拼接完全相同。
|
|
337
|
+
const totalUncompressed = blocks.reduce((a, b) => a + b.uncompressedSize, 0);
|
|
338
|
+
const data = new Uint8Array(totalUncompressed);
|
|
339
|
+
const blockSpans = [];
|
|
340
|
+
let cursor = 0;
|
|
341
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
342
|
+
const b = blocks[i];
|
|
343
|
+
const raw = r.bytes(b.compressedSize, `block[${i}]`);
|
|
344
|
+
decompress(raw, b.uncompressedSize, b.flags, `block[${i}]`, data, cursor);
|
|
345
|
+
blockSpans.push({ start: cursor, end: cursor + b.uncompressedSize });
|
|
346
|
+
cursor += b.uncompressedSize;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const { reader, ...head } = h; // reader 是内部游标,不对外暴露
|
|
350
|
+
return { ...head, blockSpans, data, totalSize: u8.length };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* 轻量摘要:只读头 + blocksInfo,**不解压数据块**。
|
|
355
|
+
* 供准入判断与插件声明内存用(见 docs/设计/解包内存预估.md)。
|
|
356
|
+
* @param {Uint8Array} u8
|
|
357
|
+
* @returns {{decompressedBytes:number, compressedBytes:number, blockCount:number, cabBytes:number, resSBytes:number}}
|
|
358
|
+
*/
|
|
359
|
+
function blocksSummary(u8) {
|
|
360
|
+
const h = readHeader(u8);
|
|
361
|
+
let decompressedBytes = 0;
|
|
362
|
+
let compressedBytes = 0;
|
|
363
|
+
for (const b of h.blocks) { decompressedBytes += b.uncompressedSize; compressedBytes += b.compressedSize; }
|
|
364
|
+
// serialized file 节点的判据是**结构形式**:CAB- + 32 位十六进制、无后缀。
|
|
365
|
+
// 旧写法 `CAB- && !.resS` 漏掉了 `.resource`(魔数 FSB5 = FMOD 音频银行)——
|
|
366
|
+
// 把它当 serialized file 解会读到 2^60 量级的垃圾 fileSize。
|
|
367
|
+
const cab = h.nodes.find((n) => /^CAB-[0-9a-f]{32}$/i.test(n.path));
|
|
368
|
+
const resS = h.nodes.find((n) => /\.resS$/i.test(n.path));
|
|
369
|
+
// 压缩类型:每块的 flags 低 6 位(Unity 的 CompressionType)。
|
|
370
|
+
// 为什么要在这里给出来:内存峰值**按压缩类型不同**(LZMA 实测是 LZ4 的 ~1.8 倍,见 index.js 的表),
|
|
371
|
+
// 而块表本来就已经解析好了 —— 从这里取是"顺手带上",不需要为它再扫一遍包。
|
|
372
|
+
const types = [];
|
|
373
|
+
for (const b of h.blocks) {
|
|
374
|
+
const t = b.flags & 0x3f;
|
|
375
|
+
if (!types.includes(t)) types.push(t);
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
decompressedBytes, compressedBytes, blockCount: h.blocks.length,
|
|
379
|
+
cabBytes: cab ? cab.size : 0, resSBytes: resS ? resS.size : 0,
|
|
380
|
+
// 全部块只会有一种压缩类型(Unity 打包即如此);万一是混合,保留全部,由调用方取最保守的
|
|
381
|
+
compressionTypes: types,
|
|
382
|
+
compression: types.length === 1 ? types[0] : (types.length ? -1 : 0),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function nodeBytes(parsed, path) {
|
|
387
|
+
const node = parsed.nodes.find((n) => n.path === path);
|
|
388
|
+
if (!node) throw NS.errors.fail('ULB_ERR_CONTAINER_PARSE', `容器内没有节点: ${path}`);
|
|
389
|
+
if (node.offset + node.size > parsed.data.length) {
|
|
390
|
+
throw NS.errors.fail('ULB_ERR_CONTAINER_PARSE', `节点 ${path} 越界:offset=${node.offset} size=${node.size} 数据区=${parsed.data.length}`);
|
|
391
|
+
}
|
|
392
|
+
return parsed.data.subarray(node.offset, node.offset + node.size);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
NS.unityfs = { parse, readHeader, blocksSummary, nodeBytes, lz4Decompress, ARCHIVE, COMPRESSION };
|
|
396
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "unity-spine-bundle",
|
|
3
|
+
"name": "Unity Spine 资源包",
|
|
4
|
+
"provides": [
|
|
5
|
+
"asset-provider"
|
|
6
|
+
],
|
|
7
|
+
"label": "Spine包",
|
|
8
|
+
"accent": "#2b6cb0",
|
|
9
|
+
"matchPriority": 20,
|
|
10
|
+
"version": 1,
|
|
11
|
+
"runtime": "node",
|
|
12
|
+
"entry": "index.js",
|
|
13
|
+
"fileKinds": [
|
|
14
|
+
"bundle"
|
|
15
|
+
],
|
|
16
|
+
"produces": {
|
|
17
|
+
"kind": "spine",
|
|
18
|
+
"entry": "resolve(file)",
|
|
19
|
+
"schema": "docs/契约/插件.md",
|
|
20
|
+
"fields": [
|
|
21
|
+
"prefix",
|
|
22
|
+
"files",
|
|
23
|
+
"entry"
|
|
24
|
+
],
|
|
25
|
+
"note": "resolve(file) 按契约 插件.md §4.2 返回 { prefix, files, entry }:prefix 形如 vfs://unity-spine-bundle/<rootId>/<相对目录>/,files 为 relPath→Blob|string 映射(<base>.skel / <base>.atlas / <页名>.png / entry.json,全部平铺无子目录),entry 指向 files 里的入口清单",
|
|
26
|
+
"memory": {
|
|
27
|
+
"via": "estimateMemory(file)",
|
|
28
|
+
"fields": [
|
|
29
|
+
"decompressedBytes",
|
|
30
|
+
"rgbaBytes",
|
|
31
|
+
"peakBytes"
|
|
32
|
+
],
|
|
33
|
+
"supported": [
|
|
34
|
+
"decompressedBytes",
|
|
35
|
+
"peakBytes"
|
|
36
|
+
],
|
|
37
|
+
"note": "decompressedBytes 由容器块表求和得出(blocksSummary 只读头+blocksInfo,不解压数据块;不用 TextAsset 文本长度,.skel 二进制经 UTF-8 往返会膨胀 1.5 倍);peakBytes = 103MB 固定开销 + 解压后 MB × 2.3(3 个 NIKKE 包实测标定)。rgbaBytes 需解 CAB 才能拿,准入路径不声明,由 resolve 完成后回填到 data/unpacked/<fp>/probe.json"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"errorCodes": [
|
|
41
|
+
"SPI_ERR_BAD_ARGS",
|
|
42
|
+
"SPI_ERR_NO_FILE",
|
|
43
|
+
"SPI_ERR_IO",
|
|
44
|
+
"SPI_ERR_NO_HOST",
|
|
45
|
+
"SPI_ERR_NOT_UNITYFS",
|
|
46
|
+
"SPI_ERR_NO_CAB",
|
|
47
|
+
"SPI_ERR_CONTAINER_PARSE",
|
|
48
|
+
"SPI_ERR_LZMA_FAIL",
|
|
49
|
+
"SPI_ERR_LZMA_UNAVAILABLE",
|
|
50
|
+
"SPI_ERR_NO_SKEL",
|
|
51
|
+
"SPI_ERR_NO_ATLAS",
|
|
52
|
+
"SPI_ERR_SKEL_INVALID",
|
|
53
|
+
"SPI_ERR_EXTRACT",
|
|
54
|
+
"SPI_ERR_TEX_FORMAT_UNSUPPORTED",
|
|
55
|
+
"SPI_ERR_TEX_DATA_SHORT",
|
|
56
|
+
"SPI_ERR_TEX_DECODE",
|
|
57
|
+
"SPI_ERR_PNG_ENCODE",
|
|
58
|
+
"SPI_ERR_ENTRY_BUILD"
|
|
59
|
+
],
|
|
60
|
+
"files": [
|
|
61
|
+
"lib/errors.js",
|
|
62
|
+
"lib/bytes.js",
|
|
63
|
+
"lib/common-strings.js",
|
|
64
|
+
"lib/lzma-d-min.js",
|
|
65
|
+
"lib/unityfs.js",
|
|
66
|
+
"lib/serialized.js",
|
|
67
|
+
"lib/typetree.js",
|
|
68
|
+
"lib/extract.js",
|
|
69
|
+
"lib/etc2.js",
|
|
70
|
+
"lib/dxt.js",
|
|
71
|
+
"lib/texdecode.js",
|
|
72
|
+
"lib/png.js",
|
|
73
|
+
"index.js"
|
|
74
|
+
]
|
|
75
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# src/server/ —— 共用服务端数据层
|
|
2
|
+
|
|
3
|
+
只读数据逻辑抽成**不依赖 http、不读端口**的模块,可被任意入口复用。
|
|
4
|
+
|
|
5
|
+
过渡期仍由 `server.mjs` 继续服务(本目录与旧实现并行存在),切换在后续步骤进行。
|
|
6
|
+
|
|
7
|
+
## 装配方式
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import { createServerData } from './src/server/index.mjs';
|
|
11
|
+
|
|
12
|
+
const data = createServerData({ root: <项目根绝对路径> });
|
|
13
|
+
|
|
14
|
+
data.config.readConfig() // 资源根配置
|
|
15
|
+
data.tree.listTree(id, rel) // → { status, body }
|
|
16
|
+
data.models.getModels(cfg, force) // Spine 模型清单(文件缓存)
|
|
17
|
+
data.live2d.getLive2DModels(cfg, f) // Live2D 模型清单(文件缓存)
|
|
18
|
+
data.modelMeta.readModelMeta(id, p) // → Promise<{ status, body }>
|
|
19
|
+
data.bundleProbe.probeBundle(id, p) // → Promise<{ status, body }>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
约定:
|
|
23
|
+
|
|
24
|
+
- **只读**接口统一返回 `{ status, body }`,由调用方决定怎么发响应(JSON 序列化方式保持
|
|
25
|
+
`JSON.stringify` 无缩进,与 `server.mjs` 的 `sendJson` 一致,便于逐字节比对);
|
|
26
|
+
- 项目根由调用方注入(`createPaths({ root })`),模块内不写死绝对路径;
|
|
27
|
+
- 写接口(`/api/roots` POST、`/api/config/import`、`/api/diag`)**不在此层**:过渡期保持
|
|
28
|
+
「单写者」(服务进程独占写 `config/` 与 `data/`),避免多进程并行写产生竞态。
|
|
29
|
+
|
|
30
|
+
## 模块划分
|
|
31
|
+
|
|
32
|
+
| 文件 | 职责 |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `paths.mjs` | 由项目根推导 config/plugins/tool/data 下的位置 |
|
|
35
|
+
| `env.mjs` | `.env` 极简解析 + `1/0,true/false,on/off,yes/no` 归一 |
|
|
36
|
+
| `config.mjs` | 资源根配置读写(保留注释头、tmp+rename)、按 name 归并的分组视图、配置导出 |
|
|
37
|
+
| `plugins.mjs` | 展示类型插件发现、启停开关、`fileKind` 可用性、资源包容器魔数识别 |
|
|
38
|
+
| `probe.mjs` | 版本号/atlas 键/贴图页探测、越界路径解析、MIME 表(纯函数) |
|
|
39
|
+
| `models.mjs` | Spine 模型扫描与清单缓存(`config/models.<rootId>.json`) |
|
|
40
|
+
| `live2d.mjs` | Live2D 模型扫描与清单缓存(`config/live2d.<rootId>.json`)、运行时库探测 |
|
|
41
|
+
| `names.mjs` | 角色名表读取(`config/index/<id>.index.json`;规则在 `src/shared/names.mjs`) |
|
|
42
|
+
| `tree.mjs` | 目录树列举(`/api/tree` 数据层:Spine 三件套识别、bundle 可见性) |
|
|
43
|
+
| `model-meta.mjs` | Spine 元数据(`/api/model-meta` 数据层,按需 import 解析器 + 动作规则缓存) |
|
|
44
|
+
| `bundle-probe.mjs` | UnityFS 资源包预检(`/api/bundle/probe` 数据层) |
|
|
45
|
+
| `browse.mjs` | 服务端目录浏览(`/api/browse` 数据层) |
|
|
46
|
+
| `index.mjs` | 装配入口 `createServerData({ root })` |
|
|
47
|
+
|
|
48
|
+
## 等价性验证
|
|
49
|
+
|
|
50
|
+
抽离后必须证明「响应逐字节不变」,验证方式是:用本目录模块重放同一批 URL 的响应体,
|
|
51
|
+
与迁移前实现的真实响应比 `sha256`(历史交付记录)。
|
|
52
|
+
|
|
53
|
+
## 技术债(抽离阶段刻意保留原行为,不在重构中顺带修改)
|
|
54
|
+
|
|
55
|
+
1. **6 个接口未校验 HTTP method**:`/api/tree`、`/api/spine/models`、`/api/live2d/models`、
|
|
56
|
+
`/api/names`、`/api/browse`、`/api/file` 不检查 `req.method`,POST 也会照常返回数据。
|
|
57
|
+
抽离阶段以「行为逐字不变」为唯一目标,未修正;后续单独任务处理(补 405)。
|
|
58
|
+
2. **无 `Range` / `HEAD` 支持**:静态文件与 `/api/file` 等大文件一律 `Content-Length` + 全量流,
|
|
59
|
+
HEAD 请求也会带 body。做断点续传/视频拖动时需要单独实现(属于独立任务)。
|
|
60
|
+
3. **写接口未进本层**:`/api/roots`(POST)、`/api/config/import`、`/api/diag` 仍留在 `server.mjs`,
|
|
61
|
+
过渡期维持单写者;等 8804 完全独立后再评估(需先解决两进程写同一份 `config/` 的竞态)。
|
|
62
|
+
4. **静态托管与首页注入仍在 `server.mjs`**:`index.html` 的 store 锚点注入、防闪屏 CSS、
|
|
63
|
+
`window.__APP_BRAND__` 这类浏览器宿主的特化逻辑,共用层不应知道这些。
|
|
64
|
+
5. **`/api/names` 未切到本层(保留 `server.mjs` 旧实现)**:本层的 `getNameTable()` 带
|
|
65
|
+
**进程内缓存**,且只有「有时返回表 / 无时返回 null」两态;而旧路由是「**每次现读文件** +
|
|
66
|
+
文件缺失返回 404 + 解析失败返回 500」三态。直接切换会改变可观测行为(结果被缓存化、404 变 200),
|
|
67
|
+
与「抽离阶段行为逐字不变」冲突,故保留旧实现。
|
|
68
|
+
将来若要统一:需在本层新增一个「无缓存 + 三态返回」的读取函数(属于新增逻辑,不在抽离范围内,
|
|
69
|
+
也需先与调用方确认 404/500 的对外契约是否保留)。
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// browse.mjs —— 浏览服务端本机目录(/api/browse 的数据层)
|
|
2
|
+
//
|
|
3
|
+
// 用途:网页里选择「部署设备上的资源根目录」。浏览器 File System Access API 只能选
|
|
4
|
+
// 用户本机文件且拿不到绝对路径,而资源在 NAS 上,所以必须由服务端列目录。
|
|
5
|
+
// 只列目录与名称,不返回文件内容。
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @returns {{status: number, body: object}}
|
|
11
|
+
*/
|
|
12
|
+
export function browseDir(dirParam) {
|
|
13
|
+
const dir = dirParam || '/';
|
|
14
|
+
let abs;
|
|
15
|
+
try {
|
|
16
|
+
abs = path.resolve(dir);
|
|
17
|
+
} catch {
|
|
18
|
+
return { status: 400, body: { error: '路径无效' } };
|
|
19
|
+
}
|
|
20
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
|
|
21
|
+
return { status: 404, body: { error: '目录不存在', path: dir } };
|
|
22
|
+
}
|
|
23
|
+
let entries;
|
|
24
|
+
try {
|
|
25
|
+
entries = fs.readdirSync(abs, { withFileTypes: true });
|
|
26
|
+
} catch (e) {
|
|
27
|
+
return { status: 500, body: { error: e.message } };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const dirs = [];
|
|
31
|
+
let skelCount = 0;
|
|
32
|
+
for (const e of entries) {
|
|
33
|
+
if (e.name.startsWith('.')) continue;
|
|
34
|
+
if (!e.isDirectory()) {
|
|
35
|
+
if (path.extname(e.name).toLowerCase() === '.skel') skelCount++;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
dirs.push({ name: e.name, path: path.join(abs, e.name) });
|
|
39
|
+
}
|
|
40
|
+
dirs.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
status: 200,
|
|
44
|
+
body: {
|
|
45
|
+
path: abs,
|
|
46
|
+
parent: path.dirname(abs) === abs ? null : path.dirname(abs),
|
|
47
|
+
dirs,
|
|
48
|
+
// 供界面提示:该目录看起来是否像 Spine 资源根
|
|
49
|
+
hint: {
|
|
50
|
+
subDirCount: dirs.length,
|
|
51
|
+
skelHere: skelCount,
|
|
52
|
+
looksLikeResourceRoot: dirs.length > 0,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|