lecodes-assets 0.2.0 → 2.4.10
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/README.md +738 -449
- package/dist/cli.js +19760 -8612
- package/dist/index.js +19334 -8167
- package/package.json +15 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- package/src/cli-kit/command.ts +100 -0
- package/src/cli-kit/help.ts +72 -0
- package/src/cli-kit/index.ts +4 -0
- package/src/cli-kit/parse.ts +72 -0
- package/src/cli-kit/run.ts +52 -0
- package/src/cli.ts +24 -0
- package/src/commands/avatar.ts +77 -0
- package/src/commands/bvh.ts +26 -0
- package/src/commands/convert.ts +156 -0
- package/src/commands/doctor.ts +67 -0
- package/src/commands/geometry.ts +45 -0
- package/src/commands/index.ts +24 -0
- package/src/commands/marks.ts +38 -0
- package/src/commands/mirror.ts +35 -0
- package/src/commands/probe.ts +43 -0
- package/src/commands/retarget/donor.ts +63 -0
- package/src/commands/retarget/index.ts +169 -0
- package/src/commands/retarget/map.ts +81 -0
- package/src/commands/retarget/report.ts +77 -0
- package/src/commands/scene.ts +103 -0
- package/src/commands/shared.ts +121 -0
- package/src/commands/sky.ts +35 -0
- package/src/commands/terrainPack.ts +40 -0
- package/src/commands/unpack.ts +43 -0
- package/src/commands/unreal.ts +109 -0
- package/src/commands/unwrap.ts +56 -0
- package/src/doctor/cardTrim.ts +286 -0
- package/src/doctor/doctor.ts +305 -0
- package/src/doctor/lod.ts +177 -0
- package/src/doctor/merge/accessors.ts +30 -0
- package/src/doctor/merge/atlasLayout.ts +94 -0
- package/src/doctor/merge/atlasPixels.ts +114 -0
- package/src/doctor/merge/bindSpace.ts +59 -0
- package/src/doctor/merge/classify.ts +136 -0
- package/src/doctor/merge/geometry.ts +167 -0
- package/src/doctor/merge/index.ts +98 -0
- package/src/doctor/merge/matrix.ts +60 -0
- package/src/doctor/merge/plan.ts +88 -0
- package/src/doctor/merge/textures.ts +60 -0
- package/src/doctor/merge/types.ts +77 -0
- package/src/doctor/normalizeScale.ts +220 -0
- package/src/doctor/reduceGlbBones.ts +218 -0
- package/src/doctor/shading.ts +95 -0
- package/src/doctor/transcodeKtx2.ts +275 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/doctor/vertexAo.ts +321 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +111 -0
- package/src/fbx/toDocument.ts +227 -0
- package/src/filmstrip.ts +263 -0
- package/src/geometry.ts +222 -0
- package/src/index.ts +19 -0
- package/src/mergeSkins.ts +117 -0
- package/src/probe.ts +167 -0
- package/src/props.ts +137 -0
- package/src/retarget/automap.ts +276 -0
- package/src/retarget/ghost.ts +23 -0
- package/src/retarget/heading.ts +238 -0
- package/src/retarget/humanoid.ts +572 -0
- package/src/retarget/inPlace.ts +130 -0
- package/src/retarget/marks.ts +581 -0
- package/src/retarget/mirror.ts +0 -0
- package/src/retarget/retarget.ts +2375 -0
- package/src/retarget/rig.ts +545 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +134 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +200 -0
- package/src/terrainPack.ts +208 -0
- package/src/textures/resolveImage.ts +202 -0
- package/src/unity/colliderFit.ts +287 -0
- package/src/unity/fbxModel.ts +194 -0
- package/src/unity/foliage.ts +63 -0
- package/src/unity/gameObjects.ts +81 -0
- package/src/unity/guid.ts +104 -0
- package/src/unity/image.ts +58 -0
- package/src/unity/kindGlb.ts +364 -0
- package/src/unity/lecodesLevel.ts +381 -0
- package/src/unity/levelTerrain.ts +116 -0
- package/src/unity/matFile.ts +179 -0
- package/src/unity/materialSlots.ts +53 -0
- package/src/unity/materialWriter.ts +306 -0
- package/src/unity/materials.ts +198 -0
- package/src/unity/math.ts +82 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabTree.ts +285 -0
- package/src/unity/prefabs.ts +54 -0
- package/src/unity/propertyPath.ts +43 -0
- package/src/unity/scene.ts +384 -0
- package/src/unity/serialized.ts +242 -0
- package/src/unity/terrain.ts +221 -0
- package/src/unity/triBudget.ts +77 -0
- package/src/unity/triplanar.ts +122 -0
- package/src/unity/unityMesh.ts +151 -0
- package/src/unity/unpack.ts +149 -0
- package/src/unity/yaml.ts +131 -0
- package/src/unreal/anim.ts +284 -0
- package/src/unreal/clipSource.ts +25 -0
- package/src/unreal/convert.ts +67 -0
- package/src/unreal/convertAsset.ts +81 -0
- package/src/unreal/index.ts +115 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materialGraph.ts +224 -0
- package/src/unreal/materials.ts +399 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +677 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +53 -0
- package/src/unreal/rig.ts +149 -0
- package/src/unreal/sequencer.ts +193 -0
- package/src/unreal/skeleton.ts +74 -0
- package/src/unreal/sound.ts +128 -0
- package/src/unreal/texture.ts +269 -0
- package/src/unreal/toDocument.ts +367 -0
- package/src/unreal/versions.ts +108 -0
- package/src/unwrap/index.ts +167 -0
- package/src/unwrap/lightmapUv.ts +761 -0
- package/src/wasm/fbxReader.js +14 -0
- package/src/wasm/lightmapUv.js +14 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// A Unity MESH ASSET (`!u!43` in a text-serialised `.asset`) as a glTF mesh — the geometry a pack
|
|
2
|
+
// authored INSIDE Unity rather than in a modelling tool: NatureManufacture's tree LOD3 is a hand-made
|
|
3
|
+
// cross of quads saved as `<Tree>_Mesh_s.asset` and referenced by the prefab's last LODGroup level,
|
|
4
|
+
// so a kind built from FBX sources alone silently loses its far level and the forest pops out at
|
|
5
|
+
// 0.026 screen heights. Only what such a mesh carries is read: positions, normals, tangents, colours,
|
|
6
|
+
// up to four UV sets, 16/32-bit indices, triangles. Skins, blend shapes and compressed meshes are not
|
|
7
|
+
// (a `.asset` mesh with bones is not something a pack ships).
|
|
8
|
+
//
|
|
9
|
+
// FRAME. The importer's FBX sources hold Unity's mesh MIRRORED BACK on X (ufbx reads the file in its
|
|
10
|
+
// own right-handed frame; Unity's import negated X), and every part of a kind goes through the same
|
|
11
|
+
// mirror later. A mesh saved by Unity is already in Unity's frame, so it is negated on X here —
|
|
12
|
+
// positions, normals, tangent xyz — with the tangent sign and the triangle winding flipped, which is
|
|
13
|
+
// what a reflection does to a handedness. After that it is indistinguishable from an FBX mesh.
|
|
14
|
+
|
|
15
|
+
import { Document, type Mesh } from "@gltf-transform/core"
|
|
16
|
+
import { readFileSync } from "node:fs"
|
|
17
|
+
import { isMap, num, readUnityObjects, str, type Y, type YMap } from "./yaml"
|
|
18
|
+
|
|
19
|
+
/** Unity's VertexFormat: bytes per component and how to read one. */
|
|
20
|
+
const FORMATS: Record<number, { size: number, read: (v: DataView, o: number) => number }> = {
|
|
21
|
+
0: { size: 4, read: (v, o) => v.getFloat32(o, true) },
|
|
22
|
+
1: { size: 2, read: (v, o) => half(v.getUint16(o, true)) },
|
|
23
|
+
2: { size: 1, read: (v, o) => v.getUint8(o) / 255 },
|
|
24
|
+
3: { size: 1, read: (v, o) => Math.max(-1, v.getInt8(o) / 127) },
|
|
25
|
+
4: { size: 2, read: (v, o) => v.getUint16(o, true) / 65535 },
|
|
26
|
+
5: { size: 2, read: (v, o) => Math.max(-1, v.getInt16(o, true) / 32767) },
|
|
27
|
+
6: { size: 1, read: (v, o) => v.getUint8(o) },
|
|
28
|
+
7: { size: 1, read: (v, o) => v.getInt8(o) },
|
|
29
|
+
8: { size: 2, read: (v, o) => v.getUint16(o, true) },
|
|
30
|
+
9: { size: 2, read: (v, o) => v.getInt16(o, true) },
|
|
31
|
+
10: { size: 4, read: (v, o) => v.getUint32(o, true) },
|
|
32
|
+
11: { size: 4, read: (v, o) => v.getInt32(o, true) },
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const half = (h: number): number => {
|
|
36
|
+
const s = h >> 15 ? -1 : 1, e = (h >> 10) & 0x1f, m = h & 0x3ff
|
|
37
|
+
if (e === 0) return s * m * 2 ** -24
|
|
38
|
+
if (e === 31) return m ? NaN : s * Infinity
|
|
39
|
+
return s * (1 + m / 1024) * 2 ** (e - 15)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Unity's vertex channel order → the glTF attribute it is (blend weights / indices are not read). */
|
|
43
|
+
const CHANNELS: (string | null)[] = [ "POSITION", "NORMAL", "TANGENT", "COLOR_0", "TEXCOORD_0", "TEXCOORD_1", "TEXCOORD_2", "TEXCOORD_3", null, null, null, null, null, null ]
|
|
44
|
+
|
|
45
|
+
const hex = (s: string): Uint8Array => {
|
|
46
|
+
const out = new Uint8Array(s.length >> 1)
|
|
47
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16)
|
|
48
|
+
return out
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type UnityMeshAsset = { name: string, mesh: Mesh, doc: Document }
|
|
52
|
+
|
|
53
|
+
/** Is this text a Unity mesh asset at all? Cheap: the class tag, before any parsing. */
|
|
54
|
+
export const isUnityMeshAsset = (text: string): boolean => /^--- !u!43 /m.test(text)
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read the mesh object of a text-serialised `.asset`. Null when the file holds no readable mesh
|
|
58
|
+
* (binary, compressed, external stream data, no vertices, no triangles).
|
|
59
|
+
*/
|
|
60
|
+
export const readUnityMeshAsset = (path: string): UnityMeshAsset | null => {
|
|
61
|
+
const text = readFileSync(path, "utf8")
|
|
62
|
+
if (!isUnityMeshAsset(text)) return null
|
|
63
|
+
const obj = readUnityObjects(text).find((o) => o.classID === 43)
|
|
64
|
+
if (!obj) return null
|
|
65
|
+
const b = obj.body
|
|
66
|
+
const vd = isMap(b.m_VertexData) ? b.m_VertexData : null
|
|
67
|
+
if (!vd) return null
|
|
68
|
+
const vcount = num(vd.m_VertexCount)
|
|
69
|
+
const data = hex(str(vd._typelessdata))
|
|
70
|
+
if (!vcount || data.length === 0) return null
|
|
71
|
+
if (isMap(b.m_StreamData) && num(b.m_StreamData.size) > 0) return null
|
|
72
|
+
if (num(b.m_MeshCompression) !== 0) return null
|
|
73
|
+
|
|
74
|
+
// channels → streams: a stream's stride is the end of its last channel rounded up to 4, streams
|
|
75
|
+
// follow one another 16-aligned
|
|
76
|
+
type Ch = { stream: number, offset: number, format: number, dim: number, attr: string | null }
|
|
77
|
+
const channels: Ch[] = (Array.isArray(vd.m_Channels) ? vd.m_Channels : []).map((c: Y, i: number): Ch => {
|
|
78
|
+
const m = isMap(c) ? c : ({} as YMap)
|
|
79
|
+
return { stream: num(m.stream), offset: num(m.offset), format: num(m.format), dim: num(m.dimension) & 0xf, attr: CHANNELS[i] ?? null }
|
|
80
|
+
})
|
|
81
|
+
const streams = Math.max(0, ...channels.map((c) => c.stream)) + 1
|
|
82
|
+
const stride: number[] = [], base: number[] = []
|
|
83
|
+
let cursor = 0
|
|
84
|
+
for (let s = 0; s < streams; s++) {
|
|
85
|
+
let end = 0
|
|
86
|
+
for (const c of channels) if (c.stream === s && c.dim > 0) end = Math.max(end, c.offset + c.dim * (FORMATS[c.format]?.size ?? 4))
|
|
87
|
+
stride[s] = (end + 3) & ~3
|
|
88
|
+
base[s] = cursor
|
|
89
|
+
cursor += stride[s] * vcount
|
|
90
|
+
cursor = (cursor + 15) & ~15
|
|
91
|
+
}
|
|
92
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
|
93
|
+
|
|
94
|
+
const doc = new Document()
|
|
95
|
+
const buffer = doc.createBuffer()
|
|
96
|
+
const mesh = doc.createMesh(str(b.m_Name))
|
|
97
|
+
const attrs = new Map<string, { data: Float32Array<ArrayBuffer>, dim: number }>()
|
|
98
|
+
for (const c of channels) {
|
|
99
|
+
if (!c.attr || c.dim === 0) continue
|
|
100
|
+
const fmt = FORMATS[c.format]
|
|
101
|
+
if (!fmt) continue
|
|
102
|
+
// glTF wants TANGENT as vec4 (w = sign), UVs as vec2; colours keep their width (3 or 4)
|
|
103
|
+
const dim = c.attr === "TANGENT" ? 4 : c.attr.startsWith("TEXCOORD") ? 2 : c.dim
|
|
104
|
+
const out = new Float32Array(new ArrayBuffer(vcount * dim * 4))
|
|
105
|
+
for (let i = 0; i < vcount; i++) {
|
|
106
|
+
const at = base[c.stream] + i * stride[c.stream] + c.offset
|
|
107
|
+
for (let k = 0; k < dim; k++) out[i * dim + k] = k < c.dim ? fmt.read(view, at + k * fmt.size) : (k === 3 ? 1 : 0)
|
|
108
|
+
}
|
|
109
|
+
attrs.set(c.attr, { data: out, dim })
|
|
110
|
+
}
|
|
111
|
+
const pos = attrs.get("POSITION")
|
|
112
|
+
if (!pos || pos.dim !== 3) return null
|
|
113
|
+
|
|
114
|
+
// THE MIRROR (see the header): x negated on positions / normals / tangents, the tangent sign
|
|
115
|
+
// flipped; Unity's UV origin is bottom-left where glTF's is top-left
|
|
116
|
+
for (const key of [ "POSITION", "NORMAL", "TANGENT" ]) {
|
|
117
|
+
const a = attrs.get(key)
|
|
118
|
+
if (!a) continue
|
|
119
|
+
for (let i = 0; i < vcount; i++) {
|
|
120
|
+
a.data[i * a.dim] = -a.data[i * a.dim]
|
|
121
|
+
if (key === "TANGENT") a.data[i * 4 + 3] = -a.data[i * 4 + 3]
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const [ key, a ] of attrs) if (key.startsWith("TEXCOORD")) for (let i = 0; i < vcount; i++) a.data[i * 2 + 1] = 1 - a.data[i * 2 + 1]
|
|
125
|
+
|
|
126
|
+
const idxHex = hex(str(b.m_IndexBuffer))
|
|
127
|
+
const wide = num(b.m_IndexFormat) === 1
|
|
128
|
+
const iv = new DataView(idxHex.buffer, idxHex.byteOffset, idxHex.byteLength)
|
|
129
|
+
const subs = Array.isArray(b.m_SubMeshes) ? b.m_SubMeshes : []
|
|
130
|
+
for (const s of subs) {
|
|
131
|
+
if (!isMap(s)) continue
|
|
132
|
+
if (num(s.topology) !== 0) continue // triangles only
|
|
133
|
+
const first = num(s.firstByte), count = num(s.indexCount), baseVertex = num(s.baseVertex)
|
|
134
|
+
if (count < 3 || first + count * (wide ? 4 : 2) > idxHex.length) continue
|
|
135
|
+
const idx = new Uint32Array(new ArrayBuffer(count * 4))
|
|
136
|
+
for (let i = 0; i < count; i++) idx[i] = (wide ? iv.getUint32(first + i * 4, true) : iv.getUint16(first + i * 2, true)) + baseVertex
|
|
137
|
+
// winding: a reflection turns every triangle inside out
|
|
138
|
+
for (let i = 0; i + 2 < count; i += 3) { const t = idx[i + 1]; idx[i + 1] = idx[i + 2]; idx[i + 2] = t }
|
|
139
|
+
const prim = doc.createPrimitive().setMode(4)
|
|
140
|
+
for (const [ key, a ] of attrs) {
|
|
141
|
+
const type = a.dim === 2 ? "VEC2" : a.dim === 3 ? "VEC3" : "VEC4"
|
|
142
|
+
prim.setAttribute(key, doc.createAccessor().setType(type).setArray(a.data).setBuffer(buffer))
|
|
143
|
+
}
|
|
144
|
+
prim.setIndices(doc.createAccessor().setType("SCALAR").setArray(idx).setBuffer(buffer))
|
|
145
|
+
mesh.addPrimitive(prim)
|
|
146
|
+
}
|
|
147
|
+
if (!mesh.listPrimitives().length) return null
|
|
148
|
+
const node = doc.createNode(str(b.m_Name)).setMesh(mesh)
|
|
149
|
+
doc.createScene(str(b.m_Name)).addChild(node)
|
|
150
|
+
return { name: str(b.m_Name), mesh, doc }
|
|
151
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// `.unitypackage` reader. The file is a gzip'd tar of one folder per asset GUID holding `asset`
|
|
2
|
+
// (the bytes), `asset.meta` and `pathname` (its path under Assets/) — gzip'd or, from some publishers,
|
|
3
|
+
// a plain tar under the same extension (sniffed by the magic). Two streaming passes keep
|
|
4
|
+
// memory flat on multi-GB packs: pass 1 indexes GUID → path (written as `unity-index.txt` at the
|
|
5
|
+
// output root — `convert` resolves Unity material textures through it), pass 2 writes the files
|
|
6
|
+
// that match the requested patterns (everything by default), `.meta` sidecars included so the
|
|
7
|
+
// result is also a valid Unity project fragment.
|
|
8
|
+
|
|
9
|
+
import { closeSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync, writeSync } from "node:fs"
|
|
10
|
+
import { dirname, join } from "node:path"
|
|
11
|
+
import { createGunzip } from "node:zlib"
|
|
12
|
+
|
|
13
|
+
type Entry = { name: string, size: number }
|
|
14
|
+
|
|
15
|
+
/** gzip's magic — some publishers ship the tar UNCOMPRESSED under the same extension. */
|
|
16
|
+
const isGzip = (file: string): boolean => {
|
|
17
|
+
const fd = openSync(file, "r")
|
|
18
|
+
try { const h = Buffer.alloc(2); return readSync(fd, h, 0, 2, 0) === 2 && h[0] === 0x1f && h[1] === 0x8b } finally { closeSync(fd) }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const tarStream = (file: string, onEntry: (e: Entry, data: Uint8Array) => void): Promise<void> => new Promise((resolveP, rejectP) => {
|
|
22
|
+
const raw = createReadStream(file, { highWaterMark: 1 << 22 }).on("error", rejectP)
|
|
23
|
+
const gunzip: NodeJS.ReadableStream = isGzip(file) ? raw.pipe(createGunzip().on("error", rejectP)) : raw
|
|
24
|
+
let buf: Buffer = Buffer.alloc(0)
|
|
25
|
+
let state: "header" | "body" | "pad" = "header"
|
|
26
|
+
let entry: Entry | null = null
|
|
27
|
+
let remaining = 0, pad = 0
|
|
28
|
+
const chunks: Buffer[] = []
|
|
29
|
+
gunzip.on("data", (chunk: Buffer) => {
|
|
30
|
+
buf = buf.length ? Buffer.concat([ buf, chunk ]) : chunk
|
|
31
|
+
let off = 0
|
|
32
|
+
for (;;) {
|
|
33
|
+
if (state === "header") {
|
|
34
|
+
if (buf.length - off < 512) break
|
|
35
|
+
const h = buf.subarray(off, off + 512)
|
|
36
|
+
off += 512
|
|
37
|
+
if (h[0] === 0) continue
|
|
38
|
+
const name = h.subarray(0, 100).toString("utf8").replace(/\0.*$/s, "")
|
|
39
|
+
const size = parseInt(h.subarray(124, 136).toString("utf8").replace(/[\0 ]/g, ""), 8) || 0
|
|
40
|
+
const prefix = h.subarray(345, 500).toString("utf8").replace(/\0.*$/s, "")
|
|
41
|
+
entry = { name: prefix ? prefix + "/" + name : name, size }
|
|
42
|
+
remaining = size
|
|
43
|
+
pad = (512 - (size % 512)) % 512
|
|
44
|
+
chunks.length = 0
|
|
45
|
+
state = size > 0 ? "body" : "pad"
|
|
46
|
+
} else if (state === "body") {
|
|
47
|
+
const avail = buf.length - off
|
|
48
|
+
if (avail === 0) break
|
|
49
|
+
const take = Math.min(remaining, avail)
|
|
50
|
+
chunks.push(buf.subarray(off, off + take))
|
|
51
|
+
off += take
|
|
52
|
+
remaining -= take
|
|
53
|
+
if (remaining === 0) state = "pad"
|
|
54
|
+
} else {
|
|
55
|
+
if (buf.length - off < pad) break
|
|
56
|
+
off += pad
|
|
57
|
+
pad = 0
|
|
58
|
+
if (entry && entry.size > 0) onEntry(entry, Buffer.concat(chunks))
|
|
59
|
+
chunks.length = 0
|
|
60
|
+
entry = null
|
|
61
|
+
state = "header"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
buf = buf.subarray(off)
|
|
65
|
+
})
|
|
66
|
+
gunzip.on("end", () => resolveP())
|
|
67
|
+
gunzip.on("error", rejectP)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
export const INDEX_NAME = "unity-index.txt"
|
|
71
|
+
|
|
72
|
+
/** Pass 1: GUID → path for every asset in the package. */
|
|
73
|
+
export const indexUnityPackage = async (pkg: string, progress?: (entries: number, assets: number) => void): Promise<Map<string, string>> => {
|
|
74
|
+
const map = new Map<string, string>()
|
|
75
|
+
let n = 0
|
|
76
|
+
await tarStream(pkg, (e, data) => {
|
|
77
|
+
n++
|
|
78
|
+
if (e.name.endsWith("/pathname")) {
|
|
79
|
+
const guid = e.name.split("/")[0]
|
|
80
|
+
const p = Buffer.from(data).toString("utf8").split("\n")[0].trim()
|
|
81
|
+
if (/^[0-9a-f]{32}$/.test(guid) && p) map.set(guid, p)
|
|
82
|
+
}
|
|
83
|
+
if (n % 20000 === 0) progress?.(n, map.size)
|
|
84
|
+
})
|
|
85
|
+
return map
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const writeUnityIndex = (outDir: string, index: Map<string, string>): string => {
|
|
89
|
+
mkdirSync(outDir, { recursive: true })
|
|
90
|
+
const f = join(outDir, INDEX_NAME)
|
|
91
|
+
writeFileSync(f, [ ...index ].map(([ g, p ]) => `${g}\t${p}`).join("\n") + "\n")
|
|
92
|
+
return f
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const readUnityIndex = (file: string): Map<string, string> => {
|
|
96
|
+
const map = new Map<string, string>()
|
|
97
|
+
for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
98
|
+
const t = line.indexOf("\t")
|
|
99
|
+
if (t > 0) map.set(line.slice(0, t), line.slice(t + 1).trim())
|
|
100
|
+
}
|
|
101
|
+
return map
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Node's fs cannot write more than 2 GiB - 1 in one call, and a .unitypackage can carry a single
|
|
105
|
+
* asset over that (Chernobyl v1.0 ships a 2.46 GB one). `writeFileSync` throws ERR_OUT_OF_RANGE
|
|
106
|
+
* from deep inside the gunzip callback, which reads as a corrupt archive rather than as a limit —
|
|
107
|
+
* the extraction simply stops a few hundred files in, with the index already written and no clue
|
|
108
|
+
* which asset did it. Slice the write instead. */
|
|
109
|
+
const WRITE_CAP = 0x7fffffff
|
|
110
|
+
const writeWhole = (dest: string, data: Uint8Array): void => {
|
|
111
|
+
if (data.length <= WRITE_CAP) { writeFileSync(dest, data); return }
|
|
112
|
+
const fd = openSync(dest, "w")
|
|
113
|
+
try { for (let o = 0; o < data.length; o += WRITE_CAP) writeSync(fd, data, o, Math.min(WRITE_CAP, data.length - o)) } finally { closeSync(fd) }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type ExtractOptions = {
|
|
117
|
+
/** Regexes (case-insensitive) a path must match; none = everything. */
|
|
118
|
+
only?: RegExp[]
|
|
119
|
+
/** Also write the `.meta` sidecars (default true). */
|
|
120
|
+
meta?: boolean
|
|
121
|
+
/** Skip files that already exist with the same size (default true — re-runs are cheap). */
|
|
122
|
+
skipExisting?: boolean
|
|
123
|
+
progress?: (files: number, bytes: number) => void
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Pass 2: write the matching assets under `outDir/<pathname>`. */
|
|
127
|
+
export const extractUnityPackage = async (pkg: string, index: Map<string, string>, outDir: string, opts: ExtractOptions = {}): Promise<{ files: number, bytes: number, matched: number }> => {
|
|
128
|
+
const want = new Map<string, string>()
|
|
129
|
+
for (const [ g, p ] of index) if (!opts.only?.length || opts.only.some((r) => r.test(p))) want.set(g, p)
|
|
130
|
+
let files = 0, bytes = 0
|
|
131
|
+
await tarStream(pkg, (e, data) => {
|
|
132
|
+
const parts = e.name.split("/")
|
|
133
|
+
if (parts.length < 2) return
|
|
134
|
+
const leaf = parts[parts.length - 1]
|
|
135
|
+
const isAsset = leaf === "asset", isMeta = leaf === "asset.meta"
|
|
136
|
+
if (!isAsset && !(isMeta && (opts.meta ?? true))) return
|
|
137
|
+
const p = want.get(parts[0])
|
|
138
|
+
if (!p) return
|
|
139
|
+
const dest = join(outDir, p) + (isMeta ? ".meta" : "")
|
|
140
|
+
if ((opts.skipExisting ?? true) && existsSync(dest)) {
|
|
141
|
+
try { if (statSync(dest).size === data.length) return } catch { /* fall through */ }
|
|
142
|
+
}
|
|
143
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
144
|
+
writeWhole(dest, data)
|
|
145
|
+
if (isAsset) { files++; bytes += data.length }
|
|
146
|
+
if (files % 50 === 0) opts.progress?.(files, bytes)
|
|
147
|
+
})
|
|
148
|
+
return { files, bytes, matched: want.size }
|
|
149
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Unity's text serialisation: a YAML subset parser (what Unity writes — block maps/sequences, flow
|
|
2
|
+
// maps, scalars) and the split of a `.unity` / `.prefab` / `.mat` file into its objects
|
|
3
|
+
// (`--- !u!<classID> &<fileID>[ stripped]`), plus the small readers every consumer of those bodies
|
|
4
|
+
// needs (refs, numbers, vectors).
|
|
5
|
+
|
|
6
|
+
export type Y = string | Y[] | { [k: string]: Y }
|
|
7
|
+
export type YMap = { [k: string]: Y }
|
|
8
|
+
type Line = { indent: number, text: string }
|
|
9
|
+
|
|
10
|
+
const splitLines = (text: string): Line[] => {
|
|
11
|
+
const raw = text.split(/\r?\n/)
|
|
12
|
+
const out: Line[] = []
|
|
13
|
+
for (let i = 0; i < raw.length; i++) {
|
|
14
|
+
let l = raw[i]
|
|
15
|
+
if (!l.trim() || l.startsWith("%")) continue
|
|
16
|
+
// wrapped flow maps: `{fileID: 1, guid: abc,` ⏎ ` type: 3}`
|
|
17
|
+
let open = (l.match(/\{/g) ?? []).length - (l.match(/\}/g) ?? []).length
|
|
18
|
+
while (open > 0 && i + 1 < raw.length) {
|
|
19
|
+
const nx = raw[++i]
|
|
20
|
+
l += " " + nx.trim()
|
|
21
|
+
open += (nx.match(/\{/g) ?? []).length - (nx.match(/\}/g) ?? []).length
|
|
22
|
+
}
|
|
23
|
+
// wrapped plain / quoted scalars (long `propertyPath` or `value` strings): a deeper line that is
|
|
24
|
+
// neither a key nor a list item continues the previous one
|
|
25
|
+
const indent = l.length - l.trimStart().length
|
|
26
|
+
const prev = out[out.length - 1]
|
|
27
|
+
if (prev && indent > prev.indent && !/^- |^-$/.test(l.trim()) && l.trim().search(/:( |$)/) < 0 && !l.trim().startsWith("{") && /: \S/.test(prev.text)) {
|
|
28
|
+
prev.text += " " + l.trim()
|
|
29
|
+
continue
|
|
30
|
+
}
|
|
31
|
+
out.push({ indent, text: l.trim() })
|
|
32
|
+
}
|
|
33
|
+
return out
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const scalar = (s: string): Y => {
|
|
37
|
+
s = s.trim()
|
|
38
|
+
if (s === "" || s === "~" || s === "null") return ""
|
|
39
|
+
if (s === "[]") return []
|
|
40
|
+
if (s === "{}") return {}
|
|
41
|
+
if (s.startsWith("{") && s.endsWith("}")) {
|
|
42
|
+
const obj: YMap = {}
|
|
43
|
+
for (const part of s.slice(1, -1).split(",")) {
|
|
44
|
+
const c = part.indexOf(":")
|
|
45
|
+
if (c < 0) continue
|
|
46
|
+
obj[part.slice(0, c).trim()] = scalar(part.slice(c + 1))
|
|
47
|
+
}
|
|
48
|
+
return obj
|
|
49
|
+
}
|
|
50
|
+
if (s.startsWith("[") && s.endsWith("]")) return s.slice(1, -1).split(",").map((x) => scalar(x))
|
|
51
|
+
if ((s.startsWith("'") && s.endsWith("'")) || (s.startsWith("\"") && s.endsWith("\""))) return s.slice(1, -1).replace(/''/g, "'")
|
|
52
|
+
return s
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const parseUnityYaml = (text: string): YMap => {
|
|
56
|
+
const lines = splitLines(text)
|
|
57
|
+
let pos = 0
|
|
58
|
+
const parseValue = (minIndent: number): Y => {
|
|
59
|
+
if (pos >= lines.length || lines[pos].indent < minIndent) return ""
|
|
60
|
+
return lines[pos].text.startsWith("- ") || lines[pos].text === "-" ? parseSeq(lines[pos].indent) : parseMap(lines[pos].indent)
|
|
61
|
+
}
|
|
62
|
+
const parseMap = (indent: number): YMap => {
|
|
63
|
+
const obj: YMap = {}
|
|
64
|
+
while (pos < lines.length && lines[pos].indent === indent && !lines[pos].text.startsWith("- ")) {
|
|
65
|
+
const t = lines[pos].text
|
|
66
|
+
const c = t.search(/:( |$)/)
|
|
67
|
+
if (c < 0) { pos++; continue }
|
|
68
|
+
const key = t.slice(0, c).trim()
|
|
69
|
+
const rest = t.slice(c + 1).trim()
|
|
70
|
+
pos++
|
|
71
|
+
if (rest === "") obj[key] = pos < lines.length && lines[pos].indent > indent ? parseValue(indent + 1) : (pos < lines.length && lines[pos].indent === indent && lines[pos].text.startsWith("- ") ? parseSeq(indent) : "")
|
|
72
|
+
else obj[key] = scalar(rest)
|
|
73
|
+
}
|
|
74
|
+
return obj
|
|
75
|
+
}
|
|
76
|
+
const parseSeq = (indent: number): Y[] => {
|
|
77
|
+
const arr: Y[] = []
|
|
78
|
+
while (pos < lines.length && lines[pos].indent === indent && (lines[pos].text.startsWith("- ") || lines[pos].text === "-")) {
|
|
79
|
+
const rest = lines[pos].text.slice(1).trim()
|
|
80
|
+
if (rest === "") { pos++; arr.push(parseValue(indent + 1)); continue }
|
|
81
|
+
// a flow map / flow list / plain scalar item (`- {fileID: 0, guid: …}`) is one value, not a key
|
|
82
|
+
if (rest.startsWith("{") || rest.startsWith("[") || rest.search(/:( |$)/) < 0) { pos++; arr.push(scalar(rest)); continue }
|
|
83
|
+
// the item's first key shares the dash line; the rest of its keys sit two deeper
|
|
84
|
+
lines[pos] = { indent: indent + 2, text: rest }
|
|
85
|
+
arr.push(parseValue(indent + 2))
|
|
86
|
+
}
|
|
87
|
+
return arr
|
|
88
|
+
}
|
|
89
|
+
return parseMap(lines[0]?.indent ?? 0)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---- objects --------------------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
export type UnityObject = { classID: number, id: string, stripped: boolean, cls: string, body: YMap }
|
|
95
|
+
|
|
96
|
+
/** Split a text-serialised Unity file into its objects, each body parsed. */
|
|
97
|
+
export const readUnityObjects = (text: string): UnityObject[] => {
|
|
98
|
+
const re = /^--- !u!(\d+) &(-?\d+)( stripped)?\s*$/gm
|
|
99
|
+
const starts: { idx: number, classID: number, id: string, stripped: boolean, end: number }[] = []
|
|
100
|
+
for (let m = re.exec(text); m; m = re.exec(text)) starts.push({ idx: m.index, classID: +m[1], id: m[2], stripped: !!m[3], end: m.index + m[0].length })
|
|
101
|
+
const out: UnityObject[] = []
|
|
102
|
+
for (let i = 0; i < starts.length; i++) {
|
|
103
|
+
const s = starts[i]
|
|
104
|
+
const body = parseUnityYaml(text.slice(s.end, i + 1 < starts.length ? starts[i + 1].idx : text.length))
|
|
105
|
+
const cls = Object.keys(body)[0] ?? ""
|
|
106
|
+
out.push({ classID: s.classID, id: s.id, stripped: s.stripped, cls, body: (body[cls] as YMap) ?? {} })
|
|
107
|
+
}
|
|
108
|
+
return out
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- value readers --------------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
export type Ref = { fileID: string, guid?: string }
|
|
114
|
+
export const isMap = (v: Y | undefined): v is YMap => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
115
|
+
export const ref = (v: Y | undefined): Ref => (isMap(v) ? { fileID: String(v.fileID ?? "0"), guid: typeof v.guid === "string" && v.guid ? v.guid : undefined } : { fileID: "0" })
|
|
116
|
+
export const num = (v: Y | undefined, d = 0): number => { const n = typeof v === "string" ? Number(v) : NaN; return Number.isFinite(n) ? n : d }
|
|
117
|
+
export const str = (v: Y | undefined): string => (typeof v === "string" ? v : "")
|
|
118
|
+
export type Vec3 = [number, number, number]
|
|
119
|
+
export type Quat = [number, number, number, number]
|
|
120
|
+
export const vec3 = (v: Y | undefined, d: Vec3 = [ 0, 0, 0 ]): Vec3 => {
|
|
121
|
+
const o = isMap(v) ? v : {}
|
|
122
|
+
return [ num(o.x, d[0]), num(o.y, d[1]), num(o.z, d[2]) ]
|
|
123
|
+
}
|
|
124
|
+
export const quat = (v: Y | undefined): Quat => {
|
|
125
|
+
const o = isMap(v) ? v : {}
|
|
126
|
+
return [ num(o.x, 0), num(o.y, 0), num(o.z, 0), num(o.w, 1) ]
|
|
127
|
+
}
|
|
128
|
+
export const color = (v: Y | undefined): [number, number, number, number] => {
|
|
129
|
+
const o = isMap(v) ? v : {}
|
|
130
|
+
return [ num(o.r, 1), num(o.g, 1), num(o.b, 1), num(o.a, 1) ]
|
|
131
|
+
}
|