@oddessentials/agent-guild 1.29.0 → 1.30.0
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/bin/native/win32-arm64/agent-guild-report.exe +0 -0
- package/bin/native/win32-x64/agent-guild-report.exe +0 -0
- package/bin/windows/arm64/agent-guild-start.exe +0 -0
- package/bin/windows/x64/agent-guild-start.exe +0 -0
- package/docs/api.md +6 -2
- package/node_modules/@jsquash/jpeg/CHANGELOG.md +54 -0
- package/node_modules/@jsquash/jpeg/LICENSE +201 -0
- package/node_modules/@jsquash/jpeg/README.md +105 -0
- package/node_modules/@jsquash/jpeg/codec/LICENSE.codec.md +132 -0
- package/node_modules/@jsquash/jpeg/codec/dec/mozjpeg_dec.d.ts +7 -0
- package/node_modules/@jsquash/jpeg/codec/dec/mozjpeg_dec.js +15 -0
- package/node_modules/@jsquash/jpeg/codec/dec/mozjpeg_dec.wasm +0 -0
- package/node_modules/@jsquash/jpeg/codec/enc/mozjpeg_enc.d.ts +37 -0
- package/node_modules/@jsquash/jpeg/codec/enc/mozjpeg_enc.js +15 -0
- package/node_modules/@jsquash/jpeg/codec/enc/mozjpeg_enc.wasm +0 -0
- package/node_modules/@jsquash/jpeg/codec/pre.js +24 -0
- package/node_modules/@jsquash/jpeg/decode.d.ts +16 -0
- package/node_modules/@jsquash/jpeg/decode.js +36 -0
- package/node_modules/@jsquash/jpeg/emscripten-types.d.ts +137 -0
- package/node_modules/@jsquash/jpeg/encode.d.ts +21 -0
- package/node_modules/@jsquash/jpeg/encode.js +35 -0
- package/node_modules/@jsquash/jpeg/index.d.ts +3 -0
- package/node_modules/@jsquash/jpeg/index.js +2 -0
- package/node_modules/@jsquash/jpeg/meta.d.ts +24 -0
- package/node_modules/@jsquash/jpeg/meta.js +25 -0
- package/node_modules/@jsquash/jpeg/package.json +32 -0
- package/node_modules/@jsquash/jpeg/utils.d.ts +17 -0
- package/node_modules/@jsquash/jpeg/utils.js +31 -0
- package/node_modules/@jsquash/png/CHANGELOG.md +69 -0
- package/node_modules/@jsquash/png/LICENSE +201 -0
- package/node_modules/@jsquash/png/README.md +114 -0
- package/node_modules/@jsquash/png/codec/LICENSE.codec.md +29 -0
- package/node_modules/@jsquash/png/codec/README.md +5 -0
- package/node_modules/@jsquash/png/codec/pkg/README.md +5 -0
- package/node_modules/@jsquash/png/codec/pkg/squoosh_png.d.ts +71 -0
- package/node_modules/@jsquash/png/codec/pkg/squoosh_png.js +314 -0
- package/node_modules/@jsquash/png/codec/pkg/squoosh_png_bg.wasm +0 -0
- package/node_modules/@jsquash/png/codec/pkg/squoosh_png_bg.wasm.d.ts +13 -0
- package/node_modules/@jsquash/png/codec/pre.js +24 -0
- package/node_modules/@jsquash/png/decode.d.ts +29 -0
- package/node_modules/@jsquash/png/decode.js +35 -0
- package/node_modules/@jsquash/png/encode.d.ts +31 -0
- package/node_modules/@jsquash/png/encode.js +40 -0
- package/node_modules/@jsquash/png/index.d.ts +3 -0
- package/node_modules/@jsquash/png/index.js +2 -0
- package/node_modules/@jsquash/png/meta.d.ts +16 -0
- package/node_modules/@jsquash/png/meta.js +15 -0
- package/node_modules/@jsquash/png/package.json +34 -0
- package/node_modules/@jsquash/webp/CHANGELOG.md +48 -0
- package/node_modules/@jsquash/webp/LICENSE +201 -0
- package/node_modules/@jsquash/webp/README.md +100 -0
- package/node_modules/@jsquash/webp/codec/LICENSE.codec.md +29 -0
- package/node_modules/@jsquash/webp/codec/Makefile.animated.backup +88 -0
- package/node_modules/@jsquash/webp/codec/README.md +5 -0
- package/node_modules/@jsquash/webp/codec/dec/README.md +18 -0
- package/node_modules/@jsquash/webp/codec/dec/webp_dec.d.ts +7 -0
- package/node_modules/@jsquash/webp/codec/dec/webp_dec.js +16 -0
- package/node_modules/@jsquash/webp/codec/dec/webp_dec.wasm +0 -0
- package/node_modules/@jsquash/webp/codec/enc/README.md +22 -0
- package/node_modules/@jsquash/webp/codec/enc/webp_enc.d.ts +42 -0
- package/node_modules/@jsquash/webp/codec/enc/webp_enc.js +16 -0
- package/node_modules/@jsquash/webp/codec/enc/webp_enc.wasm +0 -0
- package/node_modules/@jsquash/webp/codec/enc/webp_enc_simd.d.ts +1 -0
- package/node_modules/@jsquash/webp/codec/enc/webp_enc_simd.js +16 -0
- package/node_modules/@jsquash/webp/codec/enc/webp_enc_simd.wasm +0 -0
- package/node_modules/@jsquash/webp/codec/pre.js +24 -0
- package/node_modules/@jsquash/webp/decode.d.ts +15 -0
- package/node_modules/@jsquash/webp/decode.js +34 -0
- package/node_modules/@jsquash/webp/emscripten-types.d.ts +137 -0
- package/node_modules/@jsquash/webp/encode.d.ts +23 -0
- package/node_modules/@jsquash/webp/encode.js +43 -0
- package/node_modules/@jsquash/webp/index.d.ts +3 -0
- package/node_modules/@jsquash/webp/index.js +2 -0
- package/node_modules/@jsquash/webp/meta.d.ts +19 -0
- package/node_modules/@jsquash/webp/meta.js +33 -0
- package/node_modules/@jsquash/webp/package.json +35 -0
- package/node_modules/@jsquash/webp/utils.d.ts +17 -0
- package/node_modules/@jsquash/webp/utils.js +31 -0
- package/node_modules/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-x64/conpty.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
- package/node_modules/wasm-feature-detect/LICENSE +202 -0
- package/node_modules/wasm-feature-detect/README.md +109 -0
- package/node_modules/wasm-feature-detect/README.md.ejs +89 -0
- package/node_modules/wasm-feature-detect/dist/cjs/index.cjs +1 -0
- package/node_modules/wasm-feature-detect/dist/esm/index.js +1 -0
- package/node_modules/wasm-feature-detect/dist/index.d.cts +24 -0
- package/node_modules/wasm-feature-detect/dist/index.d.ts +24 -0
- package/node_modules/wasm-feature-detect/dist/umd/index.js +1 -0
- package/node_modules/wasm-feature-detect/package.json +53 -0
- package/package.json +4 -1
- package/src/manager/config.mjs +1 -0
- package/src/manager/folder-browser.mjs +31 -2
- package/src/manager/main.mjs +2 -0
- package/src/manager/server.mjs +19 -5
- package/src/manager/steam-workshop.mjs +64 -0
- package/src/manager/yard-piece-check.mjs +424 -0
- package/src/manager/yard-pieces.mjs +188 -0
- package/web/app.js +54 -16
- package/web/index.html +14 -1
- package/web/skins/guild/skin.css +2 -13
- package/web/styles.css +14 -0
- package/web/yard/art.json +1 -1
- package/web/yard/model.mjs +44 -2
- package/web/yard/renderer.js +136 -27
- package/web/yard/view.js +123 -11
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
|
|
4
|
+
import { SKELETON, CLIPS, yardExtras, playableClip, unitQuaternion } from '../../web/yard/model.mjs';
|
|
5
|
+
|
|
6
|
+
export const LIMITS = {
|
|
7
|
+
character: { faces: 210000, side: 4096, bytes: 128 * 1024 * 1024 },
|
|
8
|
+
companion: { faces: 63000, side: 2048, bytes: 48 * 1024 * 1024 },
|
|
9
|
+
};
|
|
10
|
+
const COUNTS = { nodes: 256, meshes: 16, materials: 16, textures: 16, samplers: 16, images: 8, animations: 16, accessors: 2048, bufferViews: 2048, skins: 2, buffers: 1 };
|
|
11
|
+
const REQUIRED_OK = new Set(['EXT_texture_webp', 'KHR_texture_transform', 'KHR_materials_emissive_strength', 'KHR_mesh_quantization']);
|
|
12
|
+
const ELEMENTS = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 };
|
|
13
|
+
const COMPONENT_BYTES = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 };
|
|
14
|
+
const MATERIAL_TEXTURES = {
|
|
15
|
+
KHR_materials_clearcoat: ['clearcoatTexture', 'clearcoatRoughnessTexture', 'clearcoatNormalTexture'],
|
|
16
|
+
KHR_materials_diffuse_roughness: ['diffuseRoughnessTexture'],
|
|
17
|
+
KHR_materials_sheen: ['sheenColorTexture', 'sheenRoughnessTexture'],
|
|
18
|
+
KHR_materials_specular: ['specularTexture', 'specularColorTexture'],
|
|
19
|
+
KHR_materials_transmission: ['transmissionTexture'],
|
|
20
|
+
KHR_materials_iridescence: ['iridescenceTexture', 'iridescenceThicknessTexture'],
|
|
21
|
+
KHR_materials_anisotropy: ['anisotropyTexture'],
|
|
22
|
+
KHR_materials_volume: ['thicknessTexture'],
|
|
23
|
+
EXT_materials_bump: ['bumpTexture'],
|
|
24
|
+
};
|
|
25
|
+
const IN_PLACE = 0.1;
|
|
26
|
+
const MAX_PARTS = 16;
|
|
27
|
+
const FLOAT = [5126, false];
|
|
28
|
+
const SIGNED = { byte: [5120, false], normalByte: [5120, true], short: [5122, false], normalShort: [5122, true] };
|
|
29
|
+
const UNSIGNED = { byte: [5121, false], normalByte: [5121, true], short: [5123, false], normalShort: [5123, true], int: [5125, false] };
|
|
30
|
+
const ATTRIBUTE_FORMS = {
|
|
31
|
+
POSITION: [['VEC3'], [FLOAT]],
|
|
32
|
+
NORMAL: [['VEC3'], [FLOAT]],
|
|
33
|
+
TANGENT: [['VEC4'], [FLOAT]],
|
|
34
|
+
TEXCOORD: [['VEC2'], [FLOAT, UNSIGNED.normalByte, UNSIGNED.normalShort]],
|
|
35
|
+
COLOR: [['VEC3', 'VEC4'], [FLOAT, UNSIGNED.normalByte, UNSIGNED.normalShort]],
|
|
36
|
+
JOINTS: [['VEC4'], [UNSIGNED.byte, UNSIGNED.short]],
|
|
37
|
+
WEIGHTS: [['VEC4'], [FLOAT, UNSIGNED.normalByte, UNSIGNED.normalShort]],
|
|
38
|
+
};
|
|
39
|
+
const QUANTIZED_FORMS = {
|
|
40
|
+
POSITION: [SIGNED.byte, SIGNED.normalByte, UNSIGNED.byte, UNSIGNED.normalByte, SIGNED.short, SIGNED.normalShort, UNSIGNED.short, UNSIGNED.normalShort],
|
|
41
|
+
NORMAL: [SIGNED.normalByte, SIGNED.normalShort],
|
|
42
|
+
TANGENT: [SIGNED.normalByte, SIGNED.normalShort],
|
|
43
|
+
TEXCOORD: [SIGNED.byte, SIGNED.normalByte, UNSIGNED.byte, SIGNED.short, SIGNED.normalShort, UNSIGNED.short],
|
|
44
|
+
};
|
|
45
|
+
const TRACKS = { translation: 'VEC3', rotation: 'VEC4', scale: 'VEC3' };
|
|
46
|
+
const METADATA = ['name', 'extras'];
|
|
47
|
+
const PROPERTIES = {
|
|
48
|
+
accessor: ['bufferView', 'byteOffset', 'componentType', 'normalized', 'count', 'type', 'max', 'min', 'sparse', ...METADATA],
|
|
49
|
+
bufferView: ['buffer', 'byteOffset', 'byteLength', 'byteStride', 'target', ...METADATA],
|
|
50
|
+
skin: ['inverseBindMatrices', 'skeleton', 'joints', ...METADATA],
|
|
51
|
+
node: ['children', 'skin', 'matrix', 'mesh', 'rotation', 'scale', 'translation', 'extensions', ...METADATA],
|
|
52
|
+
mesh: ['primitives', ...METADATA],
|
|
53
|
+
primitive: ['attributes', 'indices', 'material', 'mode', 'targets', ...METADATA],
|
|
54
|
+
attributes: /^(POSITION|NORMAL|TANGENT|(TEXCOORD|COLOR|JOINTS|WEIGHTS)_\d+)$/,
|
|
55
|
+
animation: ['channels', 'samplers', ...METADATA],
|
|
56
|
+
sampler: ['input', 'interpolation', 'output', ...METADATA],
|
|
57
|
+
channel: ['sampler', 'target', ...METADATA],
|
|
58
|
+
target: ['node', 'path', ...METADATA],
|
|
59
|
+
};
|
|
60
|
+
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
|
61
|
+
const MAX_JSON = 1024 * 1024;
|
|
62
|
+
const TIMEOUT = 30000;
|
|
63
|
+
|
|
64
|
+
const require = createRequire(import.meta.url);
|
|
65
|
+
const DECODERS = {
|
|
66
|
+
'image/png': ['@jsquash/png/decode.js', '@jsquash/png/codec/pkg/squoosh_png_bg.wasm'],
|
|
67
|
+
'image/jpeg': ['@jsquash/jpeg/decode.js', '@jsquash/jpeg/codec/dec/mozjpeg_dec.wasm'],
|
|
68
|
+
'image/webp': ['@jsquash/webp/decode.js', '@jsquash/webp/codec/dec/webp_dec.wasm'],
|
|
69
|
+
};
|
|
70
|
+
const decoders = new Map();
|
|
71
|
+
|
|
72
|
+
function decoder(type) {
|
|
73
|
+
if (!DECODERS[type]) return Promise.reject(new Error('unsupported image'));
|
|
74
|
+
if (!decoders.has(type)) {
|
|
75
|
+
decoders.set(type, (async () => {
|
|
76
|
+
globalThis.ImageData ??= class ImageData { constructor(data, width, height) { Object.assign(this, { data, width, height }); } };
|
|
77
|
+
const [entry, wasm] = DECODERS[type];
|
|
78
|
+
const codec = await import(entry);
|
|
79
|
+
await codec.init(await WebAssembly.compile(fs.readFileSync(require.resolve(wasm))));
|
|
80
|
+
return codec.default;
|
|
81
|
+
})());
|
|
82
|
+
}
|
|
83
|
+
return decoders.get(type);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const invalid = (message) => Object.assign(new Error(message), { status: 400, code: 'piece_invalid' });
|
|
87
|
+
|
|
88
|
+
export function imageSize(b) {
|
|
89
|
+
if (b.length >= 24 && b.readUInt32BE(0) === 0x89504e47 && b.toString('latin1', 12, 16) === 'IHDR') return [b.readUInt32BE(16), b.readUInt32BE(20)];
|
|
90
|
+
if (b.length >= 30 && b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') {
|
|
91
|
+
const chunk = b.toString('latin1', 12, 16);
|
|
92
|
+
if (chunk === 'VP8 ') return [b.readUInt16LE(26) & 0x3fff, b.readUInt16LE(28) & 0x3fff];
|
|
93
|
+
if (chunk === 'VP8L') { const v = b.readUInt32LE(21); return [(v & 0x3fff) + 1, ((v >>> 14) & 0x3fff) + 1]; }
|
|
94
|
+
if (chunk === 'VP8X') return [b.readUIntLE(24, 3) + 1, b.readUIntLE(27, 3) + 1];
|
|
95
|
+
}
|
|
96
|
+
if (b.length >= 4 && b[0] === 0xff && b[1] === 0xd8) {
|
|
97
|
+
for (let i = 2; i + 9 < b.length;) {
|
|
98
|
+
if (b[i] !== 0xff) return null;
|
|
99
|
+
const marker = b[i + 1];
|
|
100
|
+
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { i += 2; continue; }
|
|
101
|
+
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) return [b.readUInt16BE(i + 7), b.readUInt16BE(i + 5)];
|
|
102
|
+
i += 2 + b.readUInt16BE(i + 2);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function readGlb(b, name) {
|
|
109
|
+
if (b.length < 20 || b.toString('latin1', 0, 4) !== 'glTF' || b.readUInt32LE(4) !== 2 || b.readUInt32LE(8) !== b.length) throw invalid(`${name} isn’t a binary glTF model.`);
|
|
110
|
+
const size = b.readUInt32LE(12);
|
|
111
|
+
if (b.readUInt32LE(16) !== 0x4e4f534a || size > MAX_JSON || 20 + size > b.length) throw invalid(`${name} isn’t a binary glTF model.`);
|
|
112
|
+
try { return JSON.parse(b.toString('utf8', 20, 20 + size)); } catch { throw invalid(`${name} isn’t a binary glTF model.`); }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function binLength(b, name) {
|
|
116
|
+
const at = 20 + b.readUInt32LE(12);
|
|
117
|
+
if (at + 8 > b.length) return 0;
|
|
118
|
+
const size = b.readUInt32LE(at);
|
|
119
|
+
if (b.readUInt32LE(at + 4) !== 0x004e4942 || at + 8 + size > b.length) throw invalid(`${name} isn’t a binary glTF model.`);
|
|
120
|
+
return size;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function checkJson(json, name, limit, bin) {
|
|
124
|
+
if (!json || typeof json !== 'object' || Array.isArray(json)) throw invalid(`${name} isn’t a binary glTF model.`);
|
|
125
|
+
for (const [key, most] of Object.entries(COUNTS)) {
|
|
126
|
+
const list = json[key] ?? [];
|
|
127
|
+
if (!Array.isArray(list) || list.length > most) throw invalid(`${name} has more ${key} than a piece may.`);
|
|
128
|
+
}
|
|
129
|
+
for (const extension of json.extensionsRequired ?? []) if (!REQUIRED_OK.has(extension)) throw invalid(`${name} needs ${extension}, which the Yard can’t draw.`);
|
|
130
|
+
if ((json.buffers ?? []).some((buffer) => buffer.uri !== undefined)) throw invalid(`${name} refers to files outside itself.`);
|
|
131
|
+
for (const image of json.images ?? []) {
|
|
132
|
+
if (image.uri !== undefined || image.bufferView === undefined) throw invalid(`${name} refers to files outside itself.`);
|
|
133
|
+
if (!IMAGE_TYPES.has(image.mimeType)) throw invalid(`${name} has an image the Yard can’t draw.`);
|
|
134
|
+
}
|
|
135
|
+
if ((json.accessors ?? []).some((accessor) => accessor?.sparse)) throw invalid(`${name} uses sparse data, which a piece may not.`);
|
|
136
|
+
for (const material of json.materials ?? []) {
|
|
137
|
+
const pbr = material?.pbrMetallicRoughness;
|
|
138
|
+
const extensions = Object.entries(MATERIAL_TEXTURES).flatMap(([extension, slots]) => slots.map((slot) => material?.extensions?.[extension]?.[slot]));
|
|
139
|
+
for (const info of [pbr?.baseColorTexture, pbr?.metallicRoughnessTexture, material?.normalTexture, material?.occlusionTexture, material?.emissiveTexture, ...extensions]) {
|
|
140
|
+
if (!info) continue;
|
|
141
|
+
for (const channel of [info.texCoord ?? 0, info.extensions?.KHR_texture_transform?.texCoord ?? 0]) {
|
|
142
|
+
if (!(Number.isInteger(channel) && channel >= 0 && channel <= 3)) throw invalid(`${name} uses texture channel ${channel}, which the Yard can’t draw.`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const allowed = (kind, object) => {
|
|
147
|
+
if (!object || typeof object !== 'object' || Array.isArray(object)) throw invalid(`${name} has a ${kind} that can’t be read.`);
|
|
148
|
+
const extra = Object.keys(object).find((key) => !(PROPERTIES[kind] instanceof RegExp ? PROPERTIES[kind].test(key) : PROPERTIES[kind].includes(key)));
|
|
149
|
+
if (extra !== undefined) throw invalid(`${name} uses a glTF property the Yard doesn’t read (${kind}.${extra}).`);
|
|
150
|
+
};
|
|
151
|
+
for (const accessor of json.accessors ?? []) allowed('accessor', accessor);
|
|
152
|
+
for (const view of json.bufferViews ?? []) allowed('bufferView', view);
|
|
153
|
+
for (const skin of json.skins ?? []) allowed('skin', skin);
|
|
154
|
+
for (const node of json.nodes ?? []) allowed('node', node);
|
|
155
|
+
for (const mesh of json.meshes ?? []) {
|
|
156
|
+
allowed('mesh', mesh);
|
|
157
|
+
if (!Array.isArray(mesh.primitives)) throw invalid(`${name} has a mesh that can’t be read.`);
|
|
158
|
+
for (const primitive of mesh.primitives) {
|
|
159
|
+
allowed('primitive', primitive);
|
|
160
|
+
allowed('attributes', primitive.attributes);
|
|
161
|
+
if (primitive.attributes.POSITION === undefined) throw invalid(`${name} has a mesh without positions.`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
for (const animation of json.animations ?? []) {
|
|
165
|
+
allowed('animation', animation);
|
|
166
|
+
for (const sampler of Array.isArray(animation.samplers) ? animation.samplers : []) allowed('sampler', sampler);
|
|
167
|
+
for (const channel of Array.isArray(animation.channels) ? animation.channels : []) { allowed('channel', channel); allowed('target', channel.target); }
|
|
168
|
+
}
|
|
169
|
+
const missing = () => invalid(`${name} refers to data it doesn’t contain.`);
|
|
170
|
+
const multiplied = () => invalid(`${name} reuses its data in a way that would multiply its size.`);
|
|
171
|
+
const index = (value, list) => Number.isInteger(value) && value >= 0 && value < (Array.isArray(list) ? list.length : 0);
|
|
172
|
+
const length = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
173
|
+
let viewBytes = 0;
|
|
174
|
+
for (const view of json.bufferViews ?? []) {
|
|
175
|
+
const offset = view?.byteOffset ?? 0;
|
|
176
|
+
if (view?.buffer !== 0 || !length(view.byteLength) || !length(offset) || offset + view.byteLength > bin) throw missing();
|
|
177
|
+
viewBytes += view.byteLength;
|
|
178
|
+
}
|
|
179
|
+
if (viewBytes > bin) throw multiplied();
|
|
180
|
+
let accessorBytes = 0;
|
|
181
|
+
for (const accessor of json.accessors ?? []) {
|
|
182
|
+
const width = (ELEMENTS[accessor?.type] ?? 0) * (COMPONENT_BYTES[accessor?.componentType] ?? 0);
|
|
183
|
+
if (!width || !length(accessor.count) || !index(accessor.bufferView, json.bufferViews) || !length(accessor.byteOffset ?? 0)) throw missing();
|
|
184
|
+
const stride = json.bufferViews[accessor.bufferView].byteStride;
|
|
185
|
+
if (stride !== undefined && stride !== width) throw invalid(`${name} interleaves its data, which a piece may not.`);
|
|
186
|
+
accessorBytes += accessor.count * width;
|
|
187
|
+
}
|
|
188
|
+
if (accessorBytes > bin) throw multiplied();
|
|
189
|
+
const quantized = (json.extensionsUsed ?? []).includes('KHR_mesh_quantization');
|
|
190
|
+
const drawable = (at, types, forms) => types.includes(json.accessors[at].type) && forms.some(([type, normalized]) => json.accessors[at].componentType === type && Boolean(json.accessors[at].normalized) === normalized);
|
|
191
|
+
for (const mesh of json.meshes ?? []) for (const primitive of mesh.primitives) {
|
|
192
|
+
for (const [attribute, at] of Object.entries(primitive.attributes)) {
|
|
193
|
+
const semantic = attribute.replace(/_\d+$/, ''), [types, forms] = ATTRIBUTE_FORMS[semantic];
|
|
194
|
+
if (index(at, json.accessors) && !drawable(at, types, [...forms, ...(quantized ? QUANTIZED_FORMS[semantic] ?? [] : [])])) throw invalid(`${name} has a ${attribute} attribute of a type the Yard can’t draw.`);
|
|
195
|
+
}
|
|
196
|
+
if (index(primitive.indices, json.accessors) && !drawable(primitive.indices, ['SCALAR'], [UNSIGNED.byte, UNSIGNED.short, UNSIGNED.int])) throw invalid(`${name} has triangle indices of a type the Yard can’t draw.`);
|
|
197
|
+
const vertices = json.accessors[primitive.attributes.POSITION]?.count;
|
|
198
|
+
if (!Object.values(primitive.attributes).every((at) => index(at, json.accessors) && json.accessors[at].count === vertices)) throw invalid(`${name} has a mesh whose attributes don’t share one vertex count.`);
|
|
199
|
+
if (primitive.indices !== undefined && (!index(primitive.indices, json.accessors) || json.accessors[primitive.indices].count % 3 !== 0)) throw invalid(`${name} has a mesh whose triangles can’t be read.`);
|
|
200
|
+
}
|
|
201
|
+
if ((json.meshes ?? []).some((mesh) => (mesh?.primitives ?? []).some((primitive) => primitive?.targets !== undefined))) throw invalid(`${name} has shape keys, which the Yard doesn’t use.`);
|
|
202
|
+
let channels = 0;
|
|
203
|
+
for (const animation of json.animations ?? []) {
|
|
204
|
+
if (!Array.isArray(animation?.channels) || !Array.isArray(animation.samplers)) throw invalid(`${name} has an animation that can’t be read.`);
|
|
205
|
+
if (animation.channels.length > 256) throw invalid(`${name} has an animation with more than 256 channels.`);
|
|
206
|
+
channels += animation.channels.length;
|
|
207
|
+
for (const channel of animation.channels) {
|
|
208
|
+
const sampler = index(channel?.sampler, animation.samplers) ? animation.samplers[channel.sampler] : null;
|
|
209
|
+
if (!sampler || ![sampler.input, sampler.output].every((at) => index(at, json.accessors))) throw invalid(`${name} has an animation that can’t be read.`);
|
|
210
|
+
if (![sampler.input, sampler.output].every((at) => json.accessors[at].componentType === 5126 && !json.accessors[at].normalized)) throw invalid(`${name} has animation data that isn’t plain 32-bit floats.`);
|
|
211
|
+
const input = json.accessors[sampler.input], output = json.accessors[sampler.output], path = TRACKS[channel.target.path];
|
|
212
|
+
if (!['LINEAR', 'STEP'].includes(sampler.interpolation ?? 'LINEAR')) throw invalid(`${name} uses ${sampler.interpolation} animation, which the Yard doesn’t play.`);
|
|
213
|
+
if (!path || !index(channel.target.node, json.nodes)) throw invalid(`${name} animates something the Yard doesn’t move.`);
|
|
214
|
+
if (input.type !== 'SCALAR' || output.type !== path || input.count < 1 || output.count !== input.count) throw invalid(`${name} has animation keyframes that don’t match their times.`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (channels > 2048) throw invalid(`${name} has more than 2,048 animation channels.`);
|
|
218
|
+
const nodes = json.nodes ?? [];
|
|
219
|
+
if (!Array.isArray(json.scenes) || json.scenes.length !== 1 || (json.scene ?? 0) !== 0 || !Array.isArray(json.scenes[0]?.nodes ?? [])) throw invalid(`${name} needs exactly one scene.`);
|
|
220
|
+
const parent = new Array(nodes.length).fill(null);
|
|
221
|
+
const place = (child, from) => {
|
|
222
|
+
if (!index(child, nodes)) throw invalid(`${name} refers to a node it doesn’t have.`);
|
|
223
|
+
if (parent[child] !== null) throw invalid(`${name} has a node used in two places.`);
|
|
224
|
+
parent[child] = from;
|
|
225
|
+
};
|
|
226
|
+
for (const root of json.scenes[0].nodes ?? []) place(root, -1);
|
|
227
|
+
nodes.forEach((node, at) => {
|
|
228
|
+
if (!node || typeof node !== 'object' || !Array.isArray(node.children ?? [])) throw invalid(`${name} has a node that can’t be read.`);
|
|
229
|
+
for (const child of node.children ?? []) place(child, at);
|
|
230
|
+
if (node.rotation !== undefined && !(Array.isArray(node.rotation) && node.rotation.length === 4 && unitQuaternion(...node.rotation))) throw invalid(`${name} has a node rotation that isn’t a unit quaternion (${node.name ?? at}).`);
|
|
231
|
+
});
|
|
232
|
+
const shown = new Set();
|
|
233
|
+
for (let at = 0; at < nodes.length; at++) {
|
|
234
|
+
let up = at;
|
|
235
|
+
for (let steps = 0; up !== null && up >= 0; steps++) {
|
|
236
|
+
if (steps > nodes.length) throw invalid(`${name} has nodes that loop.`);
|
|
237
|
+
up = parent[up];
|
|
238
|
+
}
|
|
239
|
+
if (up === -1) shown.add(at);
|
|
240
|
+
}
|
|
241
|
+
for (const skin of json.skins ?? []) {
|
|
242
|
+
if (!Array.isArray(skin?.joints) || skin.joints.length > 128 || !skin.joints.every((joint) => index(joint, nodes))) throw invalid(`${name} needs at most 128 joints per skin.`);
|
|
243
|
+
if (skin.inverseBindMatrices !== undefined && (!index(skin.inverseBindMatrices, json.accessors) || !drawable(skin.inverseBindMatrices, ['MAT4'], [FLOAT]))) throw invalid(`${name} has inverse bind matrices of a type the Yard can’t use.`);
|
|
244
|
+
for (const joint of skin.joints) shown.add(joint);
|
|
245
|
+
}
|
|
246
|
+
for (const node of nodes) {
|
|
247
|
+
if ((node.mesh !== undefined && !index(node.mesh, json.meshes)) || (node.skin !== undefined && !index(node.skin, json.skins))) throw invalid(`${name} refers to a part it doesn’t have.`);
|
|
248
|
+
}
|
|
249
|
+
const extensions = [...(json.extensionsUsed ?? []), ...Object.keys(json.extensions ?? {}), ...nodes.flatMap((node) => Object.keys(node?.extensions ?? {}))];
|
|
250
|
+
if (extensions.includes('KHR_lights_punctual')) throw invalid(`${name} uses lights, which a piece may not.`);
|
|
251
|
+
if (extensions.includes('EXT_mesh_gpu_instancing')) throw invalid(`${name} uses instancing, which a piece may not.`);
|
|
252
|
+
const meshFaces = (json.meshes ?? []).map((mesh) => (mesh?.primitives ?? []).reduce((sum, primitive) => {
|
|
253
|
+
if ((primitive.mode ?? 4) !== 4) throw invalid(`${name} has geometry that isn’t triangles.`);
|
|
254
|
+
return sum + Math.floor((json.accessors?.[primitive.indices ?? primitive.attributes?.POSITION]?.count ?? 0) / 3);
|
|
255
|
+
}, 0));
|
|
256
|
+
let faces = 0, parts = 0;
|
|
257
|
+
for (const [at, node] of nodes.entries()) {
|
|
258
|
+
if (node.mesh === undefined || !shown.has(at)) continue;
|
|
259
|
+
faces += meshFaces[node.mesh] ?? 0;
|
|
260
|
+
parts += json.meshes?.[node.mesh]?.primitives?.length ?? 0;
|
|
261
|
+
if (parts > MAX_PARTS) throw invalid(`${name} draws more than ${MAX_PARTS} mesh parts.`);
|
|
262
|
+
if (node.skin !== undefined && (json.meshes?.[node.mesh]?.primitives ?? []).some((primitive) => primitive.attributes?.JOINTS_0 === undefined || primitive.attributes?.WEIGHTS_0 === undefined)) {
|
|
263
|
+
throw invalid(`${name} has a rigged mesh without weights.`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (faces > limit.faces) throw invalid(`${name} has more than ${limit.faces.toLocaleString('en-US')} faces.`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const IMAGE_ENDS = {
|
|
270
|
+
'image/png': (data) => data.length - data.lastIndexOf('IEND', data.length - 1, 'latin1') - 8,
|
|
271
|
+
'image/jpeg': (data) => { const end = data.lastIndexOf(Buffer.from([0xff, 0xd9])); return end < 0 ? Infinity : data.length - end - 2; },
|
|
272
|
+
'image/webp': (data) => (data.length >= 8 ? data.length - data.readUInt32LE(4) - 8 : Infinity),
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
function checkImages(json, b, name) {
|
|
276
|
+
const start = 20 + b.readUInt32LE(12) + 8;
|
|
277
|
+
for (const image of json.images ?? []) {
|
|
278
|
+
const view = json.bufferViews?.[image.bufferView];
|
|
279
|
+
const from = start + (view?.byteOffset ?? 0), to = from + (view?.byteLength ?? 0);
|
|
280
|
+
if (!view || to > b.length) throw invalid(`${name} has an image that is incomplete or damaged.`);
|
|
281
|
+
const spare = IMAGE_ENDS[image.mimeType](b.subarray(from, to));
|
|
282
|
+
if (!(spare >= 0 && spare <= 3)) throw invalid(`${name} has an image that is incomplete or damaged.`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function inspect(file, name, kind) {
|
|
287
|
+
globalThis.self ??= globalThis;
|
|
288
|
+
for (const method of ['log', 'info', 'warn', 'error']) console[method] = () => {};
|
|
289
|
+
const T = await import('../../web/yard/vendor/engine.js');
|
|
290
|
+
const limit = LIMITS[kind];
|
|
291
|
+
const b = fs.readFileSync(file);
|
|
292
|
+
const json = readGlb(b, name);
|
|
293
|
+
checkJson(json, name, limit, binLength(b, name));
|
|
294
|
+
checkImages(json, b, name);
|
|
295
|
+
const problems = [];
|
|
296
|
+
let pixels = 0, decoding = Promise.resolve();
|
|
297
|
+
globalThis.createImageBitmap = (blob) => {
|
|
298
|
+
const turn = decoding.then(async () => {
|
|
299
|
+
const bytes = Buffer.from(await blob.arrayBuffer()), size = imageSize(bytes);
|
|
300
|
+
if (!size || size.some((side) => side <= 0 || side > limit.side)) {
|
|
301
|
+
problems.push(size ? `${name} has a texture larger than ${limit.side} pixels.` : `${name} has an image that can’t be read.`);
|
|
302
|
+
throw new Error('refused');
|
|
303
|
+
}
|
|
304
|
+
pixels += size[0] * size[1];
|
|
305
|
+
if (pixels > 4 * limit.side * limit.side) {
|
|
306
|
+
problems.push(`${name}’s textures need more memory than a piece may.`);
|
|
307
|
+
throw new Error('refused');
|
|
308
|
+
}
|
|
309
|
+
let image;
|
|
310
|
+
try { image = await (await decoder(blob.type))(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length)); } catch { image = null; }
|
|
311
|
+
if (image?.width !== size[0] || image?.height !== size[1]) {
|
|
312
|
+
problems.push(`${name} has an image that is incomplete or damaged.`);
|
|
313
|
+
throw new Error('refused');
|
|
314
|
+
}
|
|
315
|
+
return { width: size[0], height: size[1], close() {} };
|
|
316
|
+
});
|
|
317
|
+
decoding = turn.catch(() => {});
|
|
318
|
+
return turn;
|
|
319
|
+
};
|
|
320
|
+
const ab = b.buffer.slice(b.byteOffset, b.byteOffset + b.length);
|
|
321
|
+
let gltf;
|
|
322
|
+
try { gltf = await new T.GLTFLoader().parseAsync(ab, ''); } catch { throw invalid(`${name} is damaged and can’t be drawn.`); }
|
|
323
|
+
if (problems.length) throw invalid(problems[0]);
|
|
324
|
+
const bones = new Set(), skinned = [];
|
|
325
|
+
let yard = null;
|
|
326
|
+
gltf.scene.traverse((node) => {
|
|
327
|
+
if (node.isBone) bones.add(node.name);
|
|
328
|
+
if (node.isSkinnedMesh) skinned.push(node);
|
|
329
|
+
if (node.userData?.yard) yard = node.userData.yard;
|
|
330
|
+
});
|
|
331
|
+
if (!skinned.length) throw invalid(`${name} has no rigged mesh.`);
|
|
332
|
+
gltf.scene.updateMatrixWorld(true);
|
|
333
|
+
gltf.scene.traverse((node) => {
|
|
334
|
+
const vertices = node.geometry?.attributes.position?.count, triangles = node.isMesh ? node.geometry.index?.array : null;
|
|
335
|
+
if (triangles && !triangles.every((at) => at < vertices)) throw invalid(`${name} has a triangle that refers to a vertex it doesn’t have.`);
|
|
336
|
+
});
|
|
337
|
+
const picked = (mesh) => {
|
|
338
|
+
const order = mesh.geometry.index?.array, corners = order ? order.length : mesh.geometry.attributes.position.count;
|
|
339
|
+
const points = [new T.Vector3(), new T.Vector3(), new T.Vector3()], normal = new T.Vector3(), edge = new T.Vector3(), scale = new T.Vector3();
|
|
340
|
+
for (const material of [mesh.material].flat()) material.side = T.DoubleSide;
|
|
341
|
+
mesh.getWorldScale(scale);
|
|
342
|
+
const reach = mesh.boundingSphere.radius * Math.max(Math.abs(scale.x), Math.abs(scale.y), Math.abs(scale.z)) * 2 + 1;
|
|
343
|
+
for (let at = 0; at + 2 < corners; at += 3) {
|
|
344
|
+
points.forEach((point, k) => mesh.localToWorld(mesh.getVertexPosition(order ? order[at + k] : at + k, point)));
|
|
345
|
+
normal.subVectors(points[2], points[1]).cross(edge.subVectors(points[0], points[1]));
|
|
346
|
+
if (!(normal.lengthSq() > 1e-12)) continue;
|
|
347
|
+
normal.normalize();
|
|
348
|
+
const centre = points[0].clone().add(points[1]).add(points[2]).divideScalar(3);
|
|
349
|
+
try { return new T.Raycaster(centre.clone().addScaledVector(normal, reach), normal.clone().negate()).intersectObject(mesh).length > 0; } catch { return false; }
|
|
350
|
+
}
|
|
351
|
+
return false;
|
|
352
|
+
};
|
|
353
|
+
let low = Infinity, high = -Infinity;
|
|
354
|
+
for (const mesh of skinned) {
|
|
355
|
+
const position = mesh.geometry.attributes.position, joints = mesh.geometry.attributes.skinIndex, weights = mesh.geometry.attributes.skinWeight;
|
|
356
|
+
if (!position || !joints || !weights) throw invalid(`${name} has a rigged mesh without positions or weights.`);
|
|
357
|
+
for (let i = 0; i < joints.count; i++) for (let k = 0; k < joints.itemSize; k++) {
|
|
358
|
+
if (weights.getComponent(i, k) > 0 && !(joints.getComponent(i, k) < mesh.skeleton.bones.length)) throw invalid(`${name} weights a vertex to a joint it doesn’t have.`);
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
mesh.skeleton.update();
|
|
362
|
+
mesh.computeBoundingBox();
|
|
363
|
+
mesh.computeBoundingSphere();
|
|
364
|
+
} catch { throw invalid(`${name} has a rigged mesh the Yard can’t draw.`); }
|
|
365
|
+
if (!Number.isFinite(mesh.boundingSphere?.radius)) throw invalid(`${name} has a rigged mesh the Yard can’t draw.`);
|
|
366
|
+
const { min, max } = mesh.boundingBox, corner = new T.Vector3();
|
|
367
|
+
for (const x of [min.x, max.x]) for (const y of [min.y, max.y]) for (const z of [min.z, max.z]) {
|
|
368
|
+
corner.set(x, y, z).applyMatrix4(mesh.matrixWorld);
|
|
369
|
+
low = Math.min(low, corner.y);
|
|
370
|
+
high = Math.max(high, corner.y);
|
|
371
|
+
}
|
|
372
|
+
if (!picked(mesh)) throw invalid(`${name} has a rigged mesh the Yard can’t pick.`);
|
|
373
|
+
}
|
|
374
|
+
const missingBone = SKELETON[kind === 'character' ? 'character' : 'helper'].find((bone) => !bones.has(bone));
|
|
375
|
+
if (missingBone) throw invalid(`${name} has no ${missingBone} bone.`);
|
|
376
|
+
const clips = new Set(gltf.animations.map((clip) => clip.name));
|
|
377
|
+
const wanted = kind === 'character' ? Object.values(CLIPS.character).flat() : CLIPS.helper;
|
|
378
|
+
const missingClip = wanted.find((clip) => !clips.has(clip));
|
|
379
|
+
if (missingClip) throw invalid(`${name} has no ${missingClip} animation.`);
|
|
380
|
+
for (const clip of gltf.animations) {
|
|
381
|
+
if (!Number.isFinite(clip.duration) || clip.duration <= 0 || clip.duration > 60) throw invalid(`${name} has an animation ${clip.name} that must last more than 0 and at most 60 seconds.`);
|
|
382
|
+
}
|
|
383
|
+
for (const clip of gltf.animations) if (!playableClip(clip)) throw invalid(`${name} has an animation with values the Yard can’t play (${clip.name}).`);
|
|
384
|
+
const height = high - low;
|
|
385
|
+
const anchors = ['Root', 'Hips'].map((bone) => gltf.scene.getObjectByName(bone));
|
|
386
|
+
const rest = anchors.map((bone) => bone.getWorldPosition(new T.Vector3()));
|
|
387
|
+
const mixer = new T.AnimationMixer(gltf.scene), at = new T.Vector3();
|
|
388
|
+
for (const clip of gltf.animations) for (let step = 0; step <= 16; step++) {
|
|
389
|
+
mixer.stopAllAction();
|
|
390
|
+
mixer.clipAction(clip).reset().play();
|
|
391
|
+
mixer.setTime(clip.duration * step / 16);
|
|
392
|
+
gltf.scene.updateMatrixWorld(true);
|
|
393
|
+
anchors.forEach((bone, i) => {
|
|
394
|
+
bone.getWorldPosition(at);
|
|
395
|
+
if (!(Math.hypot(at.x - rest[i].x, at.z - rest[i].z) <= IN_PLACE * height)) throw invalid(`${name} has a clip that moves the character instead of playing in place (${clip.name}).`);
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
const { problem } = yardExtras(yard, kind);
|
|
399
|
+
if (problem) throw invalid(`${name} ${problem}`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function checkModels(files, kind, { timeout = TIMEOUT } = {}) {
|
|
403
|
+
return new Promise((resolve, reject) => {
|
|
404
|
+
const worker = new Worker(new URL(import.meta.url), { workerData: { files, kind }, resourceLimits: { maxOldGenerationSizeMb: 1024, maxYoungGenerationSizeMb: 64 } });
|
|
405
|
+
let settled = false;
|
|
406
|
+
const finish = (err) => {
|
|
407
|
+
if (settled) return;
|
|
408
|
+
settled = true;
|
|
409
|
+
clearTimeout(timer);
|
|
410
|
+
worker.terminate();
|
|
411
|
+
if (err) reject(err); else resolve();
|
|
412
|
+
};
|
|
413
|
+
const timer = setTimeout(() => finish(invalid('This piece took too long to check.')), timeout);
|
|
414
|
+
worker.once('message', (result) => finish(result.error ? invalid(result.error) : null));
|
|
415
|
+
worker.once('error', (err) => finish(invalid(err.code === 'ERR_WORKER_OUT_OF_MEMORY' ? 'This piece needs more memory than a piece may.' : 'This piece couldn’t be checked.')));
|
|
416
|
+
worker.once('exit', () => finish(invalid('This piece couldn’t be checked.')));
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (!isMainThread && workerData?.files) {
|
|
421
|
+
(async () => {
|
|
422
|
+
for (const { path: file, name } of workerData.files) await inspect(file, name, workerData.kind);
|
|
423
|
+
})().then(() => parentPort.postMessage({}), (err) => parentPort.postMessage({ error: err.status ? err.message : 'This piece is damaged and can’t be drawn.' }));
|
|
424
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import fsp from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { EventEmitter } from 'node:events';
|
|
6
|
+
import { WORLDS, PROVIDER_ORDER } from '../../web/yard/model.mjs';
|
|
7
|
+
import { renameRetrying } from './yard-art.mjs';
|
|
8
|
+
import { checkModels, LIMITS } from './yard-piece-check.mjs';
|
|
9
|
+
|
|
10
|
+
const MANIFEST = 'piece.json';
|
|
11
|
+
const INSTALL = 'install.json';
|
|
12
|
+
const MAX_MANIFEST = 16 * 1024;
|
|
13
|
+
export const SLOTS = [...PROVIDER_ORDER, 'companion'];
|
|
14
|
+
const KIND = (slot) => (slot === 'companion' ? 'companion' : 'character');
|
|
15
|
+
export const MODELS = { character: ['character.glb'], companion: ['companion_0.glb', 'companion_1.glb', 'companion_2.glb', 'companion_3.glb'] };
|
|
16
|
+
|
|
17
|
+
const sha256 = (data) => crypto.createHash('sha256').update(data).digest('hex');
|
|
18
|
+
const failure = (status, code, message) => Object.assign(new Error(message), { status, code });
|
|
19
|
+
const invalid = (message) => failure(400, 'piece_invalid', message);
|
|
20
|
+
|
|
21
|
+
function checkManifest(meta, kind) {
|
|
22
|
+
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) throw invalid('This piece’s details can’t be read.');
|
|
23
|
+
if (meta.format !== 1) throw Number.isInteger(meta.format) && meta.format > 1 ? Object.assign(invalid('This piece needs a newer Agent Guild.'), { newer: true }) : invalid('This piece’s format is missing.');
|
|
24
|
+
if (meta.kind !== 'character' && meta.kind !== 'companion') throw invalid('This isn’t a character or companion piece.');
|
|
25
|
+
if (meta.kind !== kind) throw invalid(kind === 'character' ? 'This is a companion piece, not a character.' : 'This is a character piece, not a companion.');
|
|
26
|
+
if (meta.title !== undefined && (typeof meta.title !== 'string' || meta.title.length > 128 || /\p{Cc}/u.test(meta.title))) throw invalid('This piece’s title is too long or unreadable.');
|
|
27
|
+
return typeof meta.title === 'string' ? meta.title.trim() : '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readManifest(bytes, kind = null) {
|
|
31
|
+
let meta;
|
|
32
|
+
try { meta = JSON.parse(bytes.toString('utf8')); } catch { throw invalid('This piece’s details can’t be read.'); }
|
|
33
|
+
return { kind: meta?.kind, title: checkManifest(meta, kind ?? meta?.kind) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function readSource(folder, name, most) {
|
|
37
|
+
const file = path.join(folder, name);
|
|
38
|
+
let stat;
|
|
39
|
+
try { stat = await fsp.lstat(file); } catch { throw invalid(`${name} is missing.`); }
|
|
40
|
+
if (!stat.isFile()) throw invalid(`${name} isn’t a plain file.`);
|
|
41
|
+
if (stat.size > most) throw invalid(`${name} is larger than a piece may be.`);
|
|
42
|
+
try { return await fsp.readFile(file); } catch { throw invalid(`${name} can’t be read.`); }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class YardPieces extends EventEmitter {
|
|
46
|
+
constructor({ dir = null, remove = (folder) => fsp.rm(folder, { recursive: true, force: true, maxRetries: 5 }), check = checkModels } = {}) {
|
|
47
|
+
super();
|
|
48
|
+
this.dir = dir;
|
|
49
|
+
this.pieces = {};
|
|
50
|
+
this.busy = false;
|
|
51
|
+
this.remove = remove;
|
|
52
|
+
this.check = check;
|
|
53
|
+
this.swept = Promise.resolve();
|
|
54
|
+
if (!dir) return;
|
|
55
|
+
for (const skin of Object.keys(WORLDS)) for (const slot of SLOTS) this.read(skin, slot);
|
|
56
|
+
this.swept = this.sweep();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
snapshot() { return this.pieces; }
|
|
60
|
+
|
|
61
|
+
discard(folder) { return this.remove(folder).catch(() => {}); }
|
|
62
|
+
|
|
63
|
+
async sweep() {
|
|
64
|
+
const names = await fsp.readdir(this.dir).catch(() => []);
|
|
65
|
+
await Promise.all(names.filter((name) => name.startsWith('.')).map(async (name) => {
|
|
66
|
+
const [, skin, slot, , kind] = name.split('.');
|
|
67
|
+
const target = Object.hasOwn(WORLDS, skin) && SLOTS.includes(slot) ? path.join(this.dir, skin, slot) : null;
|
|
68
|
+
if (kind === 'old' && target && !fs.existsSync(target)) {
|
|
69
|
+
try {
|
|
70
|
+
await renameRetrying(path.join(this.dir, name), target);
|
|
71
|
+
this.read(skin, slot);
|
|
72
|
+
this.emit('updated', this.snapshot());
|
|
73
|
+
return;
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
76
|
+
let folder = path.join(this.dir, name);
|
|
77
|
+
if (kind === 'old' && target && fs.existsSync(target)) {
|
|
78
|
+
const removed = this.aside(skin, slot, 'removed');
|
|
79
|
+
try { await renameRetrying(folder, removed); folder = removed; } catch { return; }
|
|
80
|
+
}
|
|
81
|
+
await this.discard(folder);
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
aside(skin, slot, kind) { return path.join(this.dir, `.${skin}.${slot}.${crypto.randomUUID()}.${kind}`); }
|
|
86
|
+
|
|
87
|
+
file(skin, slot, name) {
|
|
88
|
+
return this.dir && Object.hasOwn(WORLDS, skin) && SLOTS.includes(slot) && MODELS[KIND(slot)].includes(name) && this.pieces[skin]?.[slot]?.revision
|
|
89
|
+
? path.join(this.dir, skin, slot, name) : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
set(skin, slot, value) {
|
|
93
|
+
if (value) (this.pieces[skin] ??= {})[slot] = value;
|
|
94
|
+
else if (this.pieces[skin]) {
|
|
95
|
+
delete this.pieces[skin][slot];
|
|
96
|
+
if (!Object.keys(this.pieces[skin]).length) delete this.pieces[skin];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
read(skin, slot) {
|
|
101
|
+
const folder = path.join(this.dir, skin, slot);
|
|
102
|
+
if (!fs.existsSync(folder)) return this.set(skin, slot, null);
|
|
103
|
+
let title = null;
|
|
104
|
+
try {
|
|
105
|
+
const kind = KIND(slot);
|
|
106
|
+
title = readManifest(fs.readFileSync(path.join(folder, MANIFEST)), kind).title;
|
|
107
|
+
const bytes = fs.readFileSync(path.join(folder, INSTALL));
|
|
108
|
+
const install = JSON.parse(bytes.toString('utf8'));
|
|
109
|
+
title = install.title;
|
|
110
|
+
for (const name of [MANIFEST, ...MODELS[kind]]) {
|
|
111
|
+
const details = install.files?.[name];
|
|
112
|
+
let content;
|
|
113
|
+
try { content = fs.readFileSync(path.join(folder, name)); } catch { content = null; }
|
|
114
|
+
if (!details || !content || content.length !== details.size || sha256(content) !== details.sha256) throw invalid(`${title} is damaged: ${name} is missing or changed.`);
|
|
115
|
+
}
|
|
116
|
+
this.set(skin, slot, { title, revision: sha256(bytes).slice(0, 16) });
|
|
117
|
+
} catch (err) {
|
|
118
|
+
this.set(skin, slot, { title: title || null, problem: err.status ? err.message : `${title || 'This piece'} is damaged.` });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async exclusive(skin, slot, work) {
|
|
123
|
+
if (!Object.hasOwn(WORLDS, skin)) throw failure(404, 'unknown_yard', 'There is no such Yard.');
|
|
124
|
+
if (!SLOTS.includes(slot)) throw failure(404, 'unknown_slot', 'There is no such character or companion.');
|
|
125
|
+
if (!this.dir) throw failure(409, 'pieces_unsupported', 'This manager has no data folder to keep pieces in.');
|
|
126
|
+
if (this.busy) throw failure(409, 'piece_busy', 'Another piece is being changed. Try again when it finishes.');
|
|
127
|
+
this.busy = true;
|
|
128
|
+
let before;
|
|
129
|
+
try {
|
|
130
|
+
await this.swept;
|
|
131
|
+
before = JSON.stringify(this.pieces[skin]?.[slot] ?? null);
|
|
132
|
+
await work();
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (err.status) throw err;
|
|
135
|
+
throw failure(500, 'piece_unwritable', `Agent Guild cannot write to its data folder: ${err.message}`);
|
|
136
|
+
} finally {
|
|
137
|
+
this.read(skin, slot);
|
|
138
|
+
this.busy = false;
|
|
139
|
+
if (JSON.stringify(this.pieces[skin]?.[slot] ?? null) !== before) this.emit('updated', this.snapshot());
|
|
140
|
+
}
|
|
141
|
+
return this.snapshot();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
replace(skin, slot, folder) {
|
|
145
|
+
return this.exclusive(skin, slot, async () => {
|
|
146
|
+
const kind = KIND(slot);
|
|
147
|
+
if (typeof folder !== 'string' || !path.isAbsolute(folder) || folder.includes('\0')) throw invalid('Choose a piece folder.');
|
|
148
|
+
try {
|
|
149
|
+
if (!(await fsp.stat(folder)).isDirectory()) throw invalid('Choose a piece folder.');
|
|
150
|
+
} catch (err) {
|
|
151
|
+
throw err.status ? err : invalid('This folder can’t be read.');
|
|
152
|
+
}
|
|
153
|
+
const manifest = await readSource(folder, MANIFEST, MAX_MANIFEST);
|
|
154
|
+
const title = readManifest(manifest, kind).title || path.basename(folder);
|
|
155
|
+
const files = new Map([[MANIFEST, manifest]]);
|
|
156
|
+
for (const name of MODELS[kind]) files.set(name, await readSource(folder, name, LIMITS[kind].bytes));
|
|
157
|
+
const target = path.join(this.dir, skin, slot), staging = this.aside(skin, slot, 'staging'), previous = this.aside(skin, slot, 'old');
|
|
158
|
+
await fsp.mkdir(path.join(this.dir, skin), { recursive: true });
|
|
159
|
+
try {
|
|
160
|
+
await fsp.mkdir(staging);
|
|
161
|
+
for (const [name, bytes] of files) await fsp.writeFile(path.join(staging, name), bytes);
|
|
162
|
+
await this.check(MODELS[kind].map((name) => ({ path: path.join(staging, name), name })), kind);
|
|
163
|
+
const install = { id: crypto.randomUUID(), title, files: Object.fromEntries([...files].map(([name, bytes]) => [name, { size: bytes.length, sha256: sha256(bytes) }])) };
|
|
164
|
+
await fsp.writeFile(path.join(staging, INSTALL), JSON.stringify(install));
|
|
165
|
+
const had = fs.existsSync(target);
|
|
166
|
+
if (had) await renameRetrying(target, previous);
|
|
167
|
+
try { await renameRetrying(staging, target); } catch (err) {
|
|
168
|
+
if (had) await renameRetrying(previous, target);
|
|
169
|
+
throw err;
|
|
170
|
+
}
|
|
171
|
+
if (had) await renameRetrying(previous, this.aside(skin, slot, 'removed')).catch(() => {});
|
|
172
|
+
} finally { await this.discard(staging); }
|
|
173
|
+
await this.sweep();
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
restore(skin, slot) {
|
|
178
|
+
return this.exclusive(skin, slot, async () => {
|
|
179
|
+
const target = path.join(this.dir, skin, slot);
|
|
180
|
+
const names = await fsp.readdir(this.dir).catch(err => { if (err.code === 'ENOENT') return []; throw err; });
|
|
181
|
+
for (const name of names) if (name.startsWith(`.${skin}.${slot}.`) && name.endsWith('.old')) {
|
|
182
|
+
await renameRetrying(path.join(this.dir, name), this.aside(skin, slot, 'removed'));
|
|
183
|
+
}
|
|
184
|
+
if (fs.existsSync(target)) await renameRetrying(target, this.aside(skin, slot, 'removed'));
|
|
185
|
+
await this.sweep();
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|