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.
Files changed (128) hide show
  1. package/README.md +738 -449
  2. package/dist/cli.js +19760 -8612
  3. package/dist/index.js +19334 -8167
  4. package/package.json +15 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli-kit/command.ts +100 -0
  8. package/src/cli-kit/help.ts +72 -0
  9. package/src/cli-kit/index.ts +4 -0
  10. package/src/cli-kit/parse.ts +72 -0
  11. package/src/cli-kit/run.ts +52 -0
  12. package/src/cli.ts +24 -0
  13. package/src/commands/avatar.ts +77 -0
  14. package/src/commands/bvh.ts +26 -0
  15. package/src/commands/convert.ts +156 -0
  16. package/src/commands/doctor.ts +67 -0
  17. package/src/commands/geometry.ts +45 -0
  18. package/src/commands/index.ts +24 -0
  19. package/src/commands/marks.ts +38 -0
  20. package/src/commands/mirror.ts +35 -0
  21. package/src/commands/probe.ts +43 -0
  22. package/src/commands/retarget/donor.ts +63 -0
  23. package/src/commands/retarget/index.ts +169 -0
  24. package/src/commands/retarget/map.ts +81 -0
  25. package/src/commands/retarget/report.ts +77 -0
  26. package/src/commands/scene.ts +103 -0
  27. package/src/commands/shared.ts +121 -0
  28. package/src/commands/sky.ts +35 -0
  29. package/src/commands/terrainPack.ts +40 -0
  30. package/src/commands/unpack.ts +43 -0
  31. package/src/commands/unreal.ts +109 -0
  32. package/src/commands/unwrap.ts +56 -0
  33. package/src/doctor/cardTrim.ts +286 -0
  34. package/src/doctor/doctor.ts +305 -0
  35. package/src/doctor/lod.ts +177 -0
  36. package/src/doctor/merge/accessors.ts +30 -0
  37. package/src/doctor/merge/atlasLayout.ts +94 -0
  38. package/src/doctor/merge/atlasPixels.ts +114 -0
  39. package/src/doctor/merge/bindSpace.ts +59 -0
  40. package/src/doctor/merge/classify.ts +136 -0
  41. package/src/doctor/merge/geometry.ts +167 -0
  42. package/src/doctor/merge/index.ts +98 -0
  43. package/src/doctor/merge/matrix.ts +60 -0
  44. package/src/doctor/merge/plan.ts +88 -0
  45. package/src/doctor/merge/textures.ts +60 -0
  46. package/src/doctor/merge/types.ts +77 -0
  47. package/src/doctor/normalizeScale.ts +220 -0
  48. package/src/doctor/reduceGlbBones.ts +218 -0
  49. package/src/doctor/shading.ts +95 -0
  50. package/src/doctor/transcodeKtx2.ts +275 -0
  51. package/src/doctor/transcodeTextures.ts +101 -0
  52. package/src/doctor/validateGlb.ts +264 -0
  53. package/src/doctor/vertexAo.ts +321 -0
  54. package/src/fbx/convert.ts +327 -0
  55. package/src/fbx/read.ts +111 -0
  56. package/src/fbx/toDocument.ts +227 -0
  57. package/src/filmstrip.ts +263 -0
  58. package/src/geometry.ts +222 -0
  59. package/src/index.ts +19 -0
  60. package/src/mergeSkins.ts +117 -0
  61. package/src/probe.ts +167 -0
  62. package/src/props.ts +137 -0
  63. package/src/retarget/automap.ts +276 -0
  64. package/src/retarget/ghost.ts +23 -0
  65. package/src/retarget/heading.ts +238 -0
  66. package/src/retarget/humanoid.ts +572 -0
  67. package/src/retarget/inPlace.ts +130 -0
  68. package/src/retarget/marks.ts +581 -0
  69. package/src/retarget/mirror.ts +0 -0
  70. package/src/retarget/retarget.ts +2375 -0
  71. package/src/retarget/rig.ts +545 -0
  72. package/src/retarget/unityAnim.ts +171 -0
  73. package/src/retarget/writeMarks.ts +134 -0
  74. package/src/sky/analyze.ts +136 -0
  75. package/src/sky/cmgen.ts +71 -0
  76. package/src/sky/index.ts +200 -0
  77. package/src/terrainPack.ts +208 -0
  78. package/src/textures/resolveImage.ts +202 -0
  79. package/src/unity/colliderFit.ts +287 -0
  80. package/src/unity/fbxModel.ts +194 -0
  81. package/src/unity/foliage.ts +63 -0
  82. package/src/unity/gameObjects.ts +81 -0
  83. package/src/unity/guid.ts +104 -0
  84. package/src/unity/image.ts +58 -0
  85. package/src/unity/kindGlb.ts +364 -0
  86. package/src/unity/lecodesLevel.ts +381 -0
  87. package/src/unity/levelTerrain.ts +116 -0
  88. package/src/unity/matFile.ts +179 -0
  89. package/src/unity/materialSlots.ts +53 -0
  90. package/src/unity/materialWriter.ts +306 -0
  91. package/src/unity/materials.ts +198 -0
  92. package/src/unity/math.ts +82 -0
  93. package/src/unity/png.ts +75 -0
  94. package/src/unity/prefabTree.ts +285 -0
  95. package/src/unity/prefabs.ts +54 -0
  96. package/src/unity/propertyPath.ts +43 -0
  97. package/src/unity/scene.ts +384 -0
  98. package/src/unity/serialized.ts +242 -0
  99. package/src/unity/terrain.ts +221 -0
  100. package/src/unity/triBudget.ts +77 -0
  101. package/src/unity/triplanar.ts +122 -0
  102. package/src/unity/unityMesh.ts +151 -0
  103. package/src/unity/unpack.ts +149 -0
  104. package/src/unity/yaml.ts +131 -0
  105. package/src/unreal/anim.ts +284 -0
  106. package/src/unreal/clipSource.ts +25 -0
  107. package/src/unreal/convert.ts +67 -0
  108. package/src/unreal/convertAsset.ts +81 -0
  109. package/src/unreal/index.ts +115 -0
  110. package/src/unreal/list.ts +113 -0
  111. package/src/unreal/materialGraph.ts +224 -0
  112. package/src/unreal/materials.ts +399 -0
  113. package/src/unreal/mesh.ts +205 -0
  114. package/src/unreal/oodle.ts +153 -0
  115. package/src/unreal/package.ts +677 -0
  116. package/src/unreal/probe.ts +144 -0
  117. package/src/unreal/resolve.ts +53 -0
  118. package/src/unreal/rig.ts +149 -0
  119. package/src/unreal/sequencer.ts +193 -0
  120. package/src/unreal/skeleton.ts +74 -0
  121. package/src/unreal/sound.ts +128 -0
  122. package/src/unreal/texture.ts +269 -0
  123. package/src/unreal/toDocument.ts +367 -0
  124. package/src/unreal/versions.ts +108 -0
  125. package/src/unwrap/index.ts +167 -0
  126. package/src/unwrap/lightmapUv.ts +761 -0
  127. package/src/wasm/fbxReader.js +14 -0
  128. 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
+ }