lecodes-assets 0.2.0 → 0.2.1
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 +561 -449
- package/dist/cli.js +5632 -521
- package/dist/index.js +5647 -526
- package/package.json +13 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- package/src/cli.ts +1039 -0
- package/src/doctor/doctor.ts +241 -0
- package/src/doctor/lightmapUv.ts +452 -0
- package/src/doctor/lod.ts +177 -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 +124 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +109 -0
- package/src/fbx/toDocument.ts +209 -0
- package/src/filmstrip.ts +263 -0
- package/src/index.ts +17 -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 +171 -0
- package/src/retarget/humanoid.ts +561 -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 +2272 -0
- package/src/retarget/rig.ts +542 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +132 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +151 -0
- package/src/terrainPack.ts +169 -0
- package/src/textures/resolveImage.ts +202 -0
- package/src/unity/materials.ts +593 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabs.ts +80 -0
- package/src/unity/scene.ts +363 -0
- package/src/unity/unpack.ts +130 -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 +114 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materials.ts +291 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +659 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +51 -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/texture.ts +202 -0
- package/src/unreal/toDocument.ts +360 -0
- package/src/unreal/versions.ts +100 -0
- package/src/wasm/fbxReader.js +14 -0
- package/src/wasm/lightmapUv.js +14 -0
package/src/unity/png.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Minimal PNG decoder — 8-bit (and 16-bit, truncated) grey / grey+alpha / RGB / RGBA / palette,
|
|
2
|
+
// non-interlaced, all five scanline filters. Enough for every texture a game asset pack ships, so
|
|
3
|
+
// ORM packing does not need `sharp` (an optional native dependency). Interlaced or <8-bit images
|
|
4
|
+
// return null and the caller falls back to sharp / a warning.
|
|
5
|
+
|
|
6
|
+
import { inflateSync } from "node:zlib"
|
|
7
|
+
import type { Rgba } from "../textures/resolveImage"
|
|
8
|
+
|
|
9
|
+
const u32 = (b: Uint8Array, o: number): number => ((b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]) >>> 0
|
|
10
|
+
|
|
11
|
+
export const decodePng = (b: Uint8Array): Rgba | null => {
|
|
12
|
+
if (b.length < 33 || b[0] !== 0x89 || b[1] !== 0x50 || b[2] !== 0x4e || b[3] !== 0x47) return null
|
|
13
|
+
let o = 8
|
|
14
|
+
let width = 0, height = 0, depth = 0, color = 0, interlace = 0
|
|
15
|
+
const idat: Uint8Array[] = []
|
|
16
|
+
let palette: Uint8Array | null = null
|
|
17
|
+
let trns: Uint8Array | null = null
|
|
18
|
+
while (o + 8 <= b.length) {
|
|
19
|
+
const len = u32(b, o)
|
|
20
|
+
const type = String.fromCharCode(b[o + 4], b[o + 5], b[o + 6], b[o + 7])
|
|
21
|
+
const data = b.subarray(o + 8, o + 8 + len)
|
|
22
|
+
if (type === "IHDR") { width = u32(data, 0); height = u32(data, 4); depth = data[8]; color = data[9]; interlace = data[12] }
|
|
23
|
+
else if (type === "PLTE") palette = data
|
|
24
|
+
else if (type === "tRNS") trns = data
|
|
25
|
+
else if (type === "IDAT") idat.push(data)
|
|
26
|
+
else if (type === "IEND") break
|
|
27
|
+
o += 12 + len
|
|
28
|
+
}
|
|
29
|
+
if (!width || !height || interlace !== 0 || (depth !== 8 && depth !== 16)) return null
|
|
30
|
+
const channels = color === 0 ? 1 : color === 2 ? 3 : color === 3 ? 1 : color === 4 ? 2 : color === 6 ? 4 : 0
|
|
31
|
+
if (!channels) return null
|
|
32
|
+
const bps = depth / 8
|
|
33
|
+
const bpp = channels * bps // bytes per pixel (filter unit)
|
|
34
|
+
const stride = width * bpp
|
|
35
|
+
let raw: Uint8Array
|
|
36
|
+
try {
|
|
37
|
+
const total = idat.reduce((n, c) => n + c.length, 0)
|
|
38
|
+
const joined = new Uint8Array(total)
|
|
39
|
+
let p = 0
|
|
40
|
+
for (const c of idat) { joined.set(c, p); p += c.length }
|
|
41
|
+
raw = new Uint8Array(inflateSync(joined))
|
|
42
|
+
} catch { return null }
|
|
43
|
+
if (raw.length < (stride + 1) * height) return null
|
|
44
|
+
const out = new Uint8Array(width * height * 4)
|
|
45
|
+
const prev = new Uint8Array(stride)
|
|
46
|
+
const cur = new Uint8Array(stride)
|
|
47
|
+
for (let y = 0; y < height; y++) {
|
|
48
|
+
const f = raw[y * (stride + 1)]
|
|
49
|
+
const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1))
|
|
50
|
+
for (let x = 0; x < stride; x++) {
|
|
51
|
+
const a = x >= bpp ? cur[x - bpp] : 0
|
|
52
|
+
const bb = prev[x]
|
|
53
|
+
const c = x >= bpp ? prev[x - bpp] : 0
|
|
54
|
+
let v = line[x]
|
|
55
|
+
if (f === 1) v += a
|
|
56
|
+
else if (f === 2) v += bb
|
|
57
|
+
else if (f === 3) v += (a + bb) >> 1
|
|
58
|
+
else if (f === 4) { const p = a + bb - c; const pa = Math.abs(p - a), pb = Math.abs(p - bb), pc = Math.abs(p - c); v += pa <= pb && pa <= pc ? a : pb <= pc ? bb : c }
|
|
59
|
+
cur[x] = v & 0xff
|
|
60
|
+
}
|
|
61
|
+
for (let x = 0; x < width; x++) {
|
|
62
|
+
const s = x * bpp, d = (y * width + x) * 4
|
|
63
|
+
if (color === 3) {
|
|
64
|
+
const i = cur[s]
|
|
65
|
+
out[d] = palette ? palette[i * 3] : i; out[d + 1] = palette ? palette[i * 3 + 1] : i; out[d + 2] = palette ? palette[i * 3 + 2] : i
|
|
66
|
+
out[d + 3] = trns && i < trns.length ? trns[i] : 255
|
|
67
|
+
} else if (channels === 1) { out[d] = out[d + 1] = out[d + 2] = cur[s]; out[d + 3] = 255 }
|
|
68
|
+
else if (channels === 2) { out[d] = out[d + 1] = out[d + 2] = cur[s]; out[d + 3] = cur[s + bps] }
|
|
69
|
+
else if (channels === 3) { out[d] = cur[s]; out[d + 1] = cur[s + bps]; out[d + 2] = cur[s + 2 * bps]; out[d + 3] = 255 }
|
|
70
|
+
else { out[d] = cur[s]; out[d + 1] = cur[s + bps]; out[d + 2] = cur[s + 2 * bps]; out[d + 3] = cur[s + 3 * bps] }
|
|
71
|
+
}
|
|
72
|
+
prev.set(cur)
|
|
73
|
+
}
|
|
74
|
+
return { width, height, data: out }
|
|
75
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Prefab material overrides.
|
|
2
|
+
//
|
|
3
|
+
// Asset-store packs often leave the FBX's materials as placeholders ("17 - Default", "Material #3150")
|
|
4
|
+
// and assign the real material on the PREFAB's MeshRenderer — the FBX alone converts untextured.
|
|
5
|
+
// This reads the text-serialised `.prefab` files around an FBX and reports, per GameObject that
|
|
6
|
+
// renders a mesh, which FBX mesh it shows and which material GUIDs its renderer carries, so the
|
|
7
|
+
// converter can re-material the matching glTF node (`applyUnityMaterials`).
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs"
|
|
10
|
+
import { join } from "node:path"
|
|
11
|
+
import { parseUnityYaml } from "./scene"
|
|
12
|
+
|
|
13
|
+
export type PrefabOverride = {
|
|
14
|
+
/** The .prefab file. */
|
|
15
|
+
prefab: string
|
|
16
|
+
/** GameObject name — the FBX node / glTF node it renders (packs keep them identical). */
|
|
17
|
+
node: string
|
|
18
|
+
/** GUID of the mesh asset (the FBX) the MeshFilter references, when it is an external asset. */
|
|
19
|
+
meshGuid: string | undefined
|
|
20
|
+
/** Material GUIDs by primitive (sub-mesh) index; an empty slot = keep the FBX's. */
|
|
21
|
+
materials: (string | undefined)[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type Y = string | Y[] | { [k: string]: Y }
|
|
25
|
+
const isMap = (v: Y | undefined): v is { [k: string]: Y } => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
26
|
+
const guidOf = (v: Y | undefined): string | undefined => (isMap(v) && typeof v.guid === "string" ? v.guid : undefined)
|
|
27
|
+
const idOf = (v: Y | undefined): string => (isMap(v) ? String(v.fileID ?? "0") : "0")
|
|
28
|
+
|
|
29
|
+
/** Material overrides declared by one text-serialised prefab (empty for binary / non-prefab files). */
|
|
30
|
+
export const readPrefabOverrides = (file: string): PrefabOverride[] => {
|
|
31
|
+
const text = readFileSync(file, "utf8")
|
|
32
|
+
if (!text.startsWith("%YAML")) return []
|
|
33
|
+
const names = new Map<string, string>() // GameObject id → name
|
|
34
|
+
const meshes = new Map<string, string | undefined>() // GameObject id → mesh guid
|
|
35
|
+
const renderers = new Map<string, (string | undefined)[]>() // GameObject id → material guids
|
|
36
|
+
const re = /^--- !u!(\d+) &(-?\d+)( stripped)?\s*$/gm
|
|
37
|
+
const starts: { idx: number, end: number }[] = []
|
|
38
|
+
for (let m = re.exec(text); m; m = re.exec(text)) starts.push({ idx: m.index, end: m.index + m[0].length })
|
|
39
|
+
for (let i = 0; i < starts.length; i++) {
|
|
40
|
+
const chunk = text.slice(starts[i].end, i + 1 < starts.length ? starts[i + 1].idx : text.length)
|
|
41
|
+
const doc = parseUnityYaml(chunk)
|
|
42
|
+
const cls = Object.keys(doc)[0] ?? ""
|
|
43
|
+
const body = doc[cls]
|
|
44
|
+
if (!isMap(body)) continue
|
|
45
|
+
const id = /&(-?\d+)/.exec(text.slice(starts[i].idx, starts[i].end))?.[1] ?? ""
|
|
46
|
+
if (cls === "GameObject" && typeof body.m_Name === "string") names.set(id, body.m_Name)
|
|
47
|
+
else if (cls === "MeshFilter") meshes.set(idOf(body.m_GameObject), guidOf(body.m_Mesh))
|
|
48
|
+
else if (cls === "MeshRenderer" || cls === "SkinnedMeshRenderer") {
|
|
49
|
+
const mats = Array.isArray(body.m_Materials) ? body.m_Materials.map((m) => guidOf(m)) : []
|
|
50
|
+
renderers.set(idOf(body.m_GameObject), mats)
|
|
51
|
+
if (cls === "SkinnedMeshRenderer") meshes.set(idOf(body.m_GameObject), guidOf(body.m_Mesh))
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const out: PrefabOverride[] = []
|
|
55
|
+
for (const [ go, materials ] of renderers) {
|
|
56
|
+
const node = names.get(go)
|
|
57
|
+
if (!node || materials.every((m) => !m)) continue
|
|
58
|
+
out.push({ prefab: file, node, meshGuid: meshes.get(go), materials })
|
|
59
|
+
}
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Every `.prefab` under `dirs` (recursive to `depth`), read for overrides. */
|
|
64
|
+
export const collectPrefabOverrides = (dirs: string[], depth = 3): PrefabOverride[] => {
|
|
65
|
+
const files: string[] = []
|
|
66
|
+
const walk = (dir: string, d: number): void => {
|
|
67
|
+
if (d < 0 || !existsSync(dir)) return
|
|
68
|
+
let entries
|
|
69
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
70
|
+
for (const e of entries) {
|
|
71
|
+
const p = join(dir, e.name)
|
|
72
|
+
if (e.isDirectory()) { if (!e.name.startsWith(".")) walk(p, d - 1) }
|
|
73
|
+
else if (/\.prefab$/i.test(e.name)) files.push(p)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (const d of dirs) walk(d, depth)
|
|
77
|
+
const out: PrefabOverride[] = []
|
|
78
|
+
for (const f of [ ...new Set(files) ]) out.push(...readPrefabOverrides(f))
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// Unity scene (`.unity`, text-serialised YAML) → a level description: every placed prefab instance
|
|
2
|
+
// and mesh object with its world transform, already in LeCodes' frame, plus a table of the prefabs
|
|
3
|
+
// used (and a best guess at the FBX + node each one renders). The vendor's demo map becomes a JSON
|
|
4
|
+
// a `level.ts` can walk: one `Model.load` per prefab, one `clone()` per instance.
|
|
5
|
+
//
|
|
6
|
+
// What is read: `PrefabInstance` (source prefab GUID, the m_LocalPosition/Rotation/Scale + m_Name +
|
|
7
|
+
// m_IsActive overrides, the transform it is parented to), `Transform` / `GameObject` (the scene's
|
|
8
|
+
// own hierarchy: groups like "Static/Roads", plain mesh objects via `MeshFilter`). Parents that are
|
|
9
|
+
// themselves inside a prefab instance ("stripped" transforms) resolve to that instance's transform —
|
|
10
|
+
// the prefab's internal offsets live in the prefab file, which the big packs ship binary-serialised,
|
|
11
|
+
// so a child parented deep inside a prefab lands at the prefab's root (counted in `approximate`).
|
|
12
|
+
//
|
|
13
|
+
// Frame: Unity is left-handed (+Z forward); glTF/LeCodes right-handed. Unity's FBX importer mirrors
|
|
14
|
+
// X, so undoing it is: position (−x, y, z), quaternion (x, −y, −z, w), scale unchanged — the
|
|
15
|
+
// UnityGLTF convention. `handedness: "z"` mirrors Z instead (for content authored in Unity itself),
|
|
16
|
+
// `"none"` keeps Unity's numbers.
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
19
|
+
import { basename, dirname, resolve } from "node:path"
|
|
20
|
+
import { findUnityIndex } from "./materials"
|
|
21
|
+
|
|
22
|
+
// ---- a YAML subset parser (what Unity writes: block maps/sequences, flow maps, scalars) ----------
|
|
23
|
+
|
|
24
|
+
export type Y = string | Y[] | { [k: string]: Y }
|
|
25
|
+
type Line = { indent: number, text: string }
|
|
26
|
+
|
|
27
|
+
const splitLines = (text: string): Line[] => {
|
|
28
|
+
const raw = text.split(/\r?\n/)
|
|
29
|
+
const out: Line[] = []
|
|
30
|
+
for (let i = 0; i < raw.length; i++) {
|
|
31
|
+
let l = raw[i]
|
|
32
|
+
if (!l.trim() || l.startsWith("%")) continue
|
|
33
|
+
// wrapped flow maps: `{fileID: 1, guid: abc,` ⏎ ` type: 3}`
|
|
34
|
+
let open = (l.match(/\{/g) ?? []).length - (l.match(/\}/g) ?? []).length
|
|
35
|
+
while (open > 0 && i + 1 < raw.length) {
|
|
36
|
+
const nx = raw[++i]
|
|
37
|
+
l += " " + nx.trim()
|
|
38
|
+
open += (nx.match(/\{/g) ?? []).length - (nx.match(/\}/g) ?? []).length
|
|
39
|
+
}
|
|
40
|
+
const indent = l.length - l.trimStart().length
|
|
41
|
+
out.push({ indent, text: l.trim() })
|
|
42
|
+
}
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const scalar = (s: string): Y => {
|
|
47
|
+
s = s.trim()
|
|
48
|
+
if (s === "" || s === "~" || s === "null") return ""
|
|
49
|
+
if (s === "[]") return []
|
|
50
|
+
if (s === "{}") return {}
|
|
51
|
+
if (s.startsWith("{") && s.endsWith("}")) {
|
|
52
|
+
const obj: { [k: string]: Y } = {}
|
|
53
|
+
for (const part of s.slice(1, -1).split(",")) {
|
|
54
|
+
const c = part.indexOf(":")
|
|
55
|
+
if (c < 0) continue
|
|
56
|
+
obj[part.slice(0, c).trim()] = scalar(part.slice(c + 1))
|
|
57
|
+
}
|
|
58
|
+
return obj
|
|
59
|
+
}
|
|
60
|
+
if (s.startsWith("[") && s.endsWith("]")) return s.slice(1, -1).split(",").map((x) => scalar(x))
|
|
61
|
+
if ((s.startsWith("'") && s.endsWith("'")) || (s.startsWith("\"") && s.endsWith("\""))) return s.slice(1, -1).replace(/''/g, "'")
|
|
62
|
+
return s
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const parseUnityYaml = (text: string): { [k: string]: Y } => {
|
|
66
|
+
const lines = splitLines(text)
|
|
67
|
+
let pos = 0
|
|
68
|
+
const parseValue = (minIndent: number): Y => {
|
|
69
|
+
if (pos >= lines.length || lines[pos].indent < minIndent) return ""
|
|
70
|
+
return lines[pos].text.startsWith("- ") || lines[pos].text === "-" ? parseSeq(lines[pos].indent) : parseMap(lines[pos].indent)
|
|
71
|
+
}
|
|
72
|
+
const parseMap = (indent: number): { [k: string]: Y } => {
|
|
73
|
+
const obj: { [k: string]: Y } = {}
|
|
74
|
+
while (pos < lines.length && lines[pos].indent === indent && !lines[pos].text.startsWith("- ")) {
|
|
75
|
+
const t = lines[pos].text
|
|
76
|
+
const c = t.search(/:( |$)/)
|
|
77
|
+
if (c < 0) { pos++; continue }
|
|
78
|
+
const key = t.slice(0, c).trim()
|
|
79
|
+
const rest = t.slice(c + 1).trim()
|
|
80
|
+
pos++
|
|
81
|
+
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) : "")
|
|
82
|
+
else obj[key] = scalar(rest)
|
|
83
|
+
}
|
|
84
|
+
return obj
|
|
85
|
+
}
|
|
86
|
+
const parseSeq = (indent: number): Y[] => {
|
|
87
|
+
const arr: Y[] = []
|
|
88
|
+
while (pos < lines.length && lines[pos].indent === indent && (lines[pos].text.startsWith("- ") || lines[pos].text === "-")) {
|
|
89
|
+
const rest = lines[pos].text.slice(1).trim()
|
|
90
|
+
if (rest === "") { pos++; arr.push(parseValue(indent + 1)); continue }
|
|
91
|
+
// a flow map / flow list / plain scalar item (`- {fileID: 0, guid: …}`) is one value, not a key
|
|
92
|
+
if (rest.startsWith("{") || rest.startsWith("[") || rest.search(/:( |$)/) < 0) { pos++; arr.push(scalar(rest)); continue }
|
|
93
|
+
// the item's first key shares the dash line; the rest of its keys sit two deeper
|
|
94
|
+
lines[pos] = { indent: indent + 2, text: rest }
|
|
95
|
+
arr.push(parseValue(indent + 2))
|
|
96
|
+
}
|
|
97
|
+
return arr
|
|
98
|
+
}
|
|
99
|
+
return parseMap(lines[0]?.indent ?? 0)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---- the scene -----------------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
type Ref = { fileID: string, guid?: string }
|
|
105
|
+
const ref = (v: Y | undefined): Ref => (typeof v === "object" && !Array.isArray(v) && v ? { fileID: String(v.fileID ?? "0"), guid: typeof v.guid === "string" ? v.guid : undefined } : { fileID: "0" })
|
|
106
|
+
const num = (v: Y | undefined, d = 0): number => { const n = typeof v === "string" ? Number(v) : NaN; return Number.isFinite(n) ? n : d }
|
|
107
|
+
const str = (v: Y | undefined): string => (typeof v === "string" ? v : "")
|
|
108
|
+
const vec3 = (v: Y | undefined, d: [number, number, number]): [number, number, number] => {
|
|
109
|
+
const o = typeof v === "object" && !Array.isArray(v) && v ? v : {}
|
|
110
|
+
return [ num(o.x, d[0]), num(o.y, d[1]), num(o.z, d[2]) ]
|
|
111
|
+
}
|
|
112
|
+
const quat = (v: Y | undefined): [number, number, number, number] => {
|
|
113
|
+
const o = typeof v === "object" && !Array.isArray(v) && v ? v : {}
|
|
114
|
+
return [ num(o.x, 0), num(o.y, 0), num(o.z, 0), num(o.w, 1) ]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
type Vec3 = [number, number, number]
|
|
118
|
+
type Quat = [number, number, number, number]
|
|
119
|
+
type TRS = { t: Vec3, r: Quat, s: Vec3 }
|
|
120
|
+
const ID: TRS = { t: [ 0, 0, 0 ], r: [ 0, 0, 0, 1 ], s: [ 1, 1, 1 ] }
|
|
121
|
+
const qmul = (a: Quat, b: Quat): Quat => [
|
|
122
|
+
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
|
|
123
|
+
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
|
|
124
|
+
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
|
|
125
|
+
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
|
|
126
|
+
]
|
|
127
|
+
const qnorm = (q: Quat): Quat => { const l = Math.hypot(q[0], q[1], q[2], q[3]) || 1; return [ q[0] / l, q[1] / l, q[2] / l, q[3] / l ] }
|
|
128
|
+
const qrot = (q: Quat, v: Vec3): Vec3 => {
|
|
129
|
+
const [ x, y, z, w ] = q
|
|
130
|
+
const ix = w * v[0] + y * v[2] - z * v[1], iy = w * v[1] + z * v[0] - x * v[2], iz = w * v[2] + x * v[1] - y * v[0], iw = -x * v[0] - y * v[1] - z * v[2]
|
|
131
|
+
return [ ix * w + iw * -x + iy * -z - iz * -y, iy * w + iw * -y + iz * -x - ix * -z, iz * w + iw * -z + ix * -y - iy * -x ]
|
|
132
|
+
}
|
|
133
|
+
const compose = (p: TRS, l: TRS): TRS => {
|
|
134
|
+
const st: Vec3 = [ p.s[0] * l.t[0], p.s[1] * l.t[1], p.s[2] * l.t[2] ]
|
|
135
|
+
const rt = qrot(p.r, st)
|
|
136
|
+
return { t: [ p.t[0] + rt[0], p.t[1] + rt[1], p.t[2] + rt[2] ], r: qnorm(qmul(p.r, l.r)), s: [ p.s[0] * l.s[0], p.s[1] * l.s[1], p.s[2] * l.s[2] ] }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export type Handedness = "x" | "z" | "none"
|
|
140
|
+
const toLeCodes = (w: TRS, h: Handedness): TRS => {
|
|
141
|
+
if (h === "x") return { t: [ -w.t[0], w.t[1], w.t[2] ], r: [ w.r[0], -w.r[1], -w.r[2], w.r[3] ], s: w.s }
|
|
142
|
+
if (h === "z") return { t: [ w.t[0], w.t[1], -w.t[2] ], r: [ -w.r[0], -w.r[1], w.r[2], w.r[3] ], s: w.s }
|
|
143
|
+
return w
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type SceneInstance = {
|
|
147
|
+
name: string
|
|
148
|
+
/** prefab GUID (key into `prefabs`), or null for a plain mesh object */
|
|
149
|
+
prefab: string | null
|
|
150
|
+
/** `Static/Roads` — the scene's grouping GameObjects above the instance */
|
|
151
|
+
group: string
|
|
152
|
+
position: Vec3
|
|
153
|
+
rotation: Quat
|
|
154
|
+
scale: Vec3
|
|
155
|
+
/** true when a parent sat inside a binary prefab — placed at that prefab's root, not its inner child */
|
|
156
|
+
approximate?: boolean
|
|
157
|
+
}
|
|
158
|
+
export type ScenePrefab = { path: string | null, name: string, fbx: string | null, node: string | null, count: number }
|
|
159
|
+
export type SceneImport = {
|
|
160
|
+
source: string
|
|
161
|
+
frame: string
|
|
162
|
+
prefabs: Record<string, ScenePrefab>
|
|
163
|
+
instances: SceneInstance[]
|
|
164
|
+
stats: { prefabInstances: number, meshObjects: number, inactive: number, approximate: number, unresolvedPrefabs: number }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export type ImportSceneOptions = {
|
|
168
|
+
/** `unity-index.txt` for GUID → path (default: found walking up from the scene file) */
|
|
169
|
+
indexFile?: string | null
|
|
170
|
+
handedness?: Handedness
|
|
171
|
+
/** keep only instances whose prefab path / name / group matches */
|
|
172
|
+
filter?: RegExp
|
|
173
|
+
/** Unity-space X/Z rectangle [x0, z0, x1, z1] — instances outside are dropped */
|
|
174
|
+
region?: [number, number, number, number]
|
|
175
|
+
/** only instances under a group whose path contains this */
|
|
176
|
+
group?: string
|
|
177
|
+
includeInactive?: boolean
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const importUnityScene = (scenePath: string, opts: ImportSceneOptions = {}): SceneImport => {
|
|
181
|
+
const text = readFileSync(scenePath, "utf8")
|
|
182
|
+
if (!text.startsWith("%YAML")) throw new Error(`${basename(scenePath)} is not text-serialised (Unity: Edit ▸ Project Settings ▸ Editor ▸ Asset Serialization = Force Text)`)
|
|
183
|
+
const handedness = opts.handedness ?? "x"
|
|
184
|
+
const indexFile = opts.indexFile === undefined ? findUnityIndex(dirname(resolve(scenePath))) : opts.indexFile
|
|
185
|
+
const guidPath = new Map<string, string>()
|
|
186
|
+
if (indexFile && existsSync(indexFile)) {
|
|
187
|
+
for (const line of readFileSync(indexFile, "utf8").split(/\r?\n/)) { const t = line.indexOf("\t"); if (t > 0) guidPath.set(line.slice(0, t), line.slice(t + 1).trim()) }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// split into objects
|
|
191
|
+
type Obj = { type: number, id: string, stripped: boolean, cls: string, body: { [k: string]: Y } }
|
|
192
|
+
const objs = new Map<string, Obj>()
|
|
193
|
+
const re = /^--- !u!(\d+) &(-?\d+)( stripped)?\s*$/gm
|
|
194
|
+
const starts: { idx: number, type: number, id: string, stripped: boolean, end: number }[] = []
|
|
195
|
+
for (let m = re.exec(text); m; m = re.exec(text)) starts.push({ idx: m.index, type: +m[1], id: m[2], stripped: !!m[3], end: m.index + m[0].length })
|
|
196
|
+
for (let i = 0; i < starts.length; i++) {
|
|
197
|
+
const s = starts[i]
|
|
198
|
+
const chunk = text.slice(s.end, i + 1 < starts.length ? starts[i + 1].idx : text.length)
|
|
199
|
+
const body = parseUnityYaml(chunk)
|
|
200
|
+
const cls = Object.keys(body)[0] ?? ""
|
|
201
|
+
objs.set(s.id, { type: s.type, id: s.id, stripped: s.stripped, cls, body: (body[cls] as { [k: string]: Y }) ?? {} })
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// transforms + game objects
|
|
205
|
+
type T = { local: TRS, father: string, gameObject: string, prefabInstance: string | null }
|
|
206
|
+
const transforms = new Map<string, T>()
|
|
207
|
+
const gameObjects = new Map<string, { name: string, active: boolean, components: string[] }>()
|
|
208
|
+
const meshFilters = new Map<string, { mesh: Ref }>() // by game object id
|
|
209
|
+
type PI = { parent: string, local: TRS, guid: string | undefined, name: string | null, active: boolean }
|
|
210
|
+
const prefabInstances = new Map<string, PI>()
|
|
211
|
+
for (const o of objs.values()) {
|
|
212
|
+
if (o.cls === "Transform" || o.cls === "RectTransform") {
|
|
213
|
+
transforms.set(o.id, {
|
|
214
|
+
local: { t: vec3(o.body.m_LocalPosition, [ 0, 0, 0 ]), r: quat(o.body.m_LocalRotation), s: vec3(o.body.m_LocalScale, [ 1, 1, 1 ]) },
|
|
215
|
+
father: ref(o.body.m_Father).fileID, gameObject: ref(o.body.m_GameObject).fileID,
|
|
216
|
+
prefabInstance: o.stripped ? ref(o.body.m_PrefabInstance).fileID : null,
|
|
217
|
+
})
|
|
218
|
+
} else if (o.cls === "GameObject") {
|
|
219
|
+
const comps = Array.isArray(o.body.m_Component) ? o.body.m_Component.map((c) => ref(typeof c === "object" && !Array.isArray(c) ? c.component : undefined).fileID) : []
|
|
220
|
+
gameObjects.set(o.id, { name: str(o.body.m_Name), active: num(o.body.m_IsActive, 1) !== 0, components: comps })
|
|
221
|
+
} else if (o.cls === "MeshFilter") {
|
|
222
|
+
meshFilters.set(ref(o.body.m_GameObject).fileID, { mesh: ref(o.body.m_Mesh) })
|
|
223
|
+
} else if (o.cls === "PrefabInstance") {
|
|
224
|
+
const mod = (typeof o.body.m_Modification === "object" && !Array.isArray(o.body.m_Modification) ? o.body.m_Modification : {}) as { [k: string]: Y }
|
|
225
|
+
const local: TRS = { t: [ 0, 0, 0 ], r: [ 0, 0, 0, 1 ], s: [ 1, 1, 1 ] }
|
|
226
|
+
let name: string | null = null, active = true
|
|
227
|
+
const mods = Array.isArray(mod.m_Modifications) ? mod.m_Modifications : []
|
|
228
|
+
// the modifications target the prefab's ROOT transform (the first fileID seen for TRS keys);
|
|
229
|
+
// overrides on inner children are ignored — they need the prefab's contents
|
|
230
|
+
let rootTarget: string | null = null
|
|
231
|
+
for (const m of mods) {
|
|
232
|
+
if (typeof m !== "object" || Array.isArray(m)) continue
|
|
233
|
+
const path = str(m.propertyPath), value = str(m.value), target = ref(m.target).fileID
|
|
234
|
+
const mt = /^m_Local(Position|Rotation|Scale)\.([xyzw])$/.exec(path)
|
|
235
|
+
if (mt) {
|
|
236
|
+
if (rootTarget === null) rootTarget = target
|
|
237
|
+
if (target !== rootTarget) continue
|
|
238
|
+
const k = "xyzw".indexOf(mt[2])
|
|
239
|
+
if (mt[1] === "Position") local.t[k] = num(value, local.t[k])
|
|
240
|
+
else if (mt[1] === "Rotation") local.r[k] = num(value, local.r[k])
|
|
241
|
+
else local.s[k] = num(value, local.s[k])
|
|
242
|
+
} else if (path === "m_Name" && name === null) name = value
|
|
243
|
+
else if (path === "m_IsActive" && value === "0") active = false
|
|
244
|
+
}
|
|
245
|
+
local.r = qnorm(local.r)
|
|
246
|
+
prefabInstances.set(o.id, { parent: ref(mod.m_TransformParent).fileID, local, guid: ref(o.body.m_SourcePrefab).guid, name, active })
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// world transforms, memoised; stripped transforms resolve to their prefab instance
|
|
251
|
+
const worldT = new Map<string, { w: TRS, approx: boolean, path: string[] }>()
|
|
252
|
+
const worldPI = new Map<string, { w: TRS, approx: boolean, path: string[] }>()
|
|
253
|
+
const ROOT = { w: ID, approx: false, path: [] as string[] }
|
|
254
|
+
const resolveT = (id: string, depth = 0): { w: TRS, approx: boolean, path: string[] } => {
|
|
255
|
+
if (id === "0" || depth > 64) return ROOT
|
|
256
|
+
const hit = worldT.get(id); if (hit) return hit
|
|
257
|
+
const t = transforms.get(id)
|
|
258
|
+
let out = ROOT
|
|
259
|
+
if (t) {
|
|
260
|
+
if (t.prefabInstance) { const p = resolvePI(t.prefabInstance, depth + 1); out = { w: p.w, approx: true, path: p.path } }
|
|
261
|
+
else {
|
|
262
|
+
const par = resolveT(t.father, depth + 1)
|
|
263
|
+
const go = gameObjects.get(t.gameObject)
|
|
264
|
+
out = { w: compose(par.w, t.local), approx: par.approx, path: go?.name ? [ ...par.path, go.name ] : par.path }
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
worldT.set(id, out)
|
|
268
|
+
return out
|
|
269
|
+
}
|
|
270
|
+
const resolvePI = (id: string, depth = 0): { w: TRS, approx: boolean, path: string[] } => {
|
|
271
|
+
const hit = worldPI.get(id); if (hit) return hit
|
|
272
|
+
const p = prefabInstances.get(id)
|
|
273
|
+
let out = ROOT
|
|
274
|
+
if (p) {
|
|
275
|
+
const par = resolveT(p.parent, depth + 1)
|
|
276
|
+
const nm = p.name ?? prefabName(p.guid)
|
|
277
|
+
out = { w: compose(par.w, p.local), approx: par.approx, path: [ ...par.path, nm ] }
|
|
278
|
+
}
|
|
279
|
+
worldPI.set(id, out)
|
|
280
|
+
return out
|
|
281
|
+
}
|
|
282
|
+
const prefabName = (guid: string | undefined): string => {
|
|
283
|
+
const p = guid ? guidPath.get(guid) : undefined
|
|
284
|
+
return p ? basename(p).replace(/\.prefab$/i, "") : (guid ?? "prefab")
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// FBX guess per prefab: an FBX from the index under the prefab's folder (`Source/` first), whose
|
|
288
|
+
// stem is a prefix of the prefab name when there are several
|
|
289
|
+
const fbxByDir = new Map<string, string[]>()
|
|
290
|
+
for (const p of guidPath.values()) if (/\.fbx$/i.test(p)) { const d = dirname(p); fbxByDir.set(d, [ ...(fbxByDir.get(d) ?? []), p ]) }
|
|
291
|
+
const guessFbx = (prefabPath: string, name: string): string | null => {
|
|
292
|
+
const d = dirname(prefabPath)
|
|
293
|
+
const cands = [ ...(fbxByDir.get(d + "/Source") ?? []), ...(fbxByDir.get(d) ?? []), ...(fbxByDir.get(dirname(d) + "/Source") ?? []) ]
|
|
294
|
+
if (cands.length === 0) return null
|
|
295
|
+
const lname = name.toLowerCase()
|
|
296
|
+
const stem = (f: string): string => basename(f).replace(/\.fbx$/i, "").toLowerCase()
|
|
297
|
+
return cands.find((f) => lname.startsWith(stem(f))) ?? cands.find((f) => lname.includes(stem(f)) || stem(f).includes(lname)) ?? (cands.length === 1 ? cands[0] : null)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const prefabs: Record<string, ScenePrefab> = {}
|
|
301
|
+
const instances: SceneInstance[] = []
|
|
302
|
+
const stats = { prefabInstances: 0, meshObjects: 0, inactive: 0, approximate: 0, unresolvedPrefabs: 0 }
|
|
303
|
+
const inRegion = (w: TRS): boolean => !opts.region || (w.t[0] >= opts.region[0] && w.t[0] <= opts.region[2] && w.t[2] >= opts.region[1] && w.t[2] <= opts.region[3])
|
|
304
|
+
const push = (name: string, prefab: string | null, res: { w: TRS, approx: boolean, path: string[] }, prefabPath: string | null): void => {
|
|
305
|
+
const group = res.path.slice(0, -1).join("/")
|
|
306
|
+
if (opts.group && !res.path.join("/").includes(opts.group)) return
|
|
307
|
+
if (opts.filter && !(opts.filter.test(name) || opts.filter.test(group) || (prefabPath !== null && opts.filter.test(prefabPath)))) return
|
|
308
|
+
if (!inRegion(res.w)) return
|
|
309
|
+
const w = toLeCodes(res.w, handedness)
|
|
310
|
+
const inst: SceneInstance = { name, prefab, group, position: w.t.map(r6) as Vec3, rotation: w.r.map(r6) as Quat, scale: w.s.map(r6) as Vec3 }
|
|
311
|
+
if (res.approx) { inst.approximate = true; stats.approximate++ }
|
|
312
|
+
instances.push(inst)
|
|
313
|
+
}
|
|
314
|
+
for (const [ id, p ] of prefabInstances) {
|
|
315
|
+
stats.prefabInstances++
|
|
316
|
+
if (!p.active && !opts.includeInactive) { stats.inactive++; continue }
|
|
317
|
+
const guid = p.guid ?? "?"
|
|
318
|
+
const path = guidPath.get(guid) ?? null
|
|
319
|
+
if (!prefabs[guid]) {
|
|
320
|
+
const name = prefabName(p.guid)
|
|
321
|
+
if (!path) stats.unresolvedPrefabs++
|
|
322
|
+
const fbx = path ? guessFbx(path, name) : null
|
|
323
|
+
prefabs[guid] = { path, name, fbx, node: fbx ? name : null, count: 0 }
|
|
324
|
+
}
|
|
325
|
+
prefabs[guid].count++
|
|
326
|
+
push(p.name ?? prefabs[guid].name, guid, resolvePI(id), path)
|
|
327
|
+
}
|
|
328
|
+
for (const [ goId, mf ] of meshFilters) {
|
|
329
|
+
const go = gameObjects.get(goId)
|
|
330
|
+
if (!go) continue
|
|
331
|
+
const tId = go.components.find((c) => transforms.has(c))
|
|
332
|
+
if (!tId) continue
|
|
333
|
+
if (!go.active && !opts.includeInactive) { stats.inactive++; continue }
|
|
334
|
+
if (mf.mesh.guid === "0000000000000000e000000000000000") continue // Unity built-in primitives
|
|
335
|
+
stats.meshObjects++
|
|
336
|
+
const key = mf.mesh.guid ? `mesh:${mf.mesh.guid}` : `mesh:${goId}`
|
|
337
|
+
const fbx = mf.mesh.guid ? guidPath.get(mf.mesh.guid) ?? null : null
|
|
338
|
+
if (!prefabs[key]) prefabs[key] = { path: null, name: go.name, fbx, node: go.name.replace(/\s*\(\d+\)$/, ""), count: 0 }
|
|
339
|
+
prefabs[key].count++
|
|
340
|
+
push(go.name, key, resolveT(tId), fbx)
|
|
341
|
+
}
|
|
342
|
+
// counts = surviving instances (filters / region applied); prefabs nothing uses are dropped
|
|
343
|
+
for (const p of Object.values(prefabs)) p.count = 0
|
|
344
|
+
for (const i of instances) if (i.prefab && prefabs[i.prefab]) prefabs[i.prefab].count++
|
|
345
|
+
for (const k of Object.keys(prefabs)) if (!prefabs[k].count) delete prefabs[k]
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
source: basename(scenePath),
|
|
349
|
+
frame: handedness === "none" ? "unity (left-handed, untouched)" : `lecodes: Unity mirrored on ${handedness.toUpperCase()} (position −${handedness}, quaternion ${handedness === "x" ? "(x, −y, −z, w)" : "(−x, −y, z, w)"}), metres, Y up`,
|
|
350
|
+
prefabs, instances, stats,
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const r6 = (v: number): number => Math.round(v * 1e6) / 1e6
|
|
355
|
+
|
|
356
|
+
/** The prefab table as printable lines, most used first. */
|
|
357
|
+
export const describeSceneImport = (r: SceneImport, max = 20): string[] => {
|
|
358
|
+
const rows = Object.entries(r.prefabs).sort((a, b) => b[1].count - a[1].count)
|
|
359
|
+
const lines = [ `${r.instances.length} instances of ${rows.length} prefab(s)${r.stats.inactive ? ` · ${r.stats.inactive} inactive skipped` : ""}${r.stats.approximate ? ` · ${r.stats.approximate} placed at a binary prefab's root (inner offset unknown)` : ""}${r.stats.unresolvedPrefabs ? ` · ${r.stats.unresolvedPrefabs} prefab GUID(s) not in the index` : ""}` ]
|
|
360
|
+
for (const [ , p ] of rows.slice(0, max)) lines.push(` ${String(p.count).padStart(5)} × ${p.name.padEnd(36)} ${p.fbx ? `${basename(p.fbx)} → --keep ${p.node}` : p.path ? "(no FBX next to the prefab)" : "(unresolved)"}`)
|
|
361
|
+
if (rows.length > max) lines.push(` … ${rows.length - max} more`)
|
|
362
|
+
return lines
|
|
363
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
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/). Two streaming passes keep
|
|
3
|
+
// memory flat on multi-GB packs: pass 1 indexes GUID → path (written as `unity-index.txt` at the
|
|
4
|
+
// output root — `convert` resolves Unity material textures through it), pass 2 writes the files
|
|
5
|
+
// that match the requested patterns (everything by default), `.meta` sidecars included so the
|
|
6
|
+
// result is also a valid Unity project fragment.
|
|
7
|
+
|
|
8
|
+
import { createReadStream, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
|
9
|
+
import { dirname, join } from "node:path"
|
|
10
|
+
import { createGunzip } from "node:zlib"
|
|
11
|
+
|
|
12
|
+
type Entry = { name: string, size: number }
|
|
13
|
+
|
|
14
|
+
const tarStream = (file: string, onEntry: (e: Entry, data: Uint8Array) => void): Promise<void> => new Promise((resolveP, rejectP) => {
|
|
15
|
+
const gunzip = createGunzip()
|
|
16
|
+
createReadStream(file, { highWaterMark: 1 << 22 }).on("error", rejectP).pipe(gunzip)
|
|
17
|
+
let buf: Buffer = Buffer.alloc(0)
|
|
18
|
+
let state: "header" | "body" | "pad" = "header"
|
|
19
|
+
let entry: Entry | null = null
|
|
20
|
+
let remaining = 0, pad = 0
|
|
21
|
+
const chunks: Buffer[] = []
|
|
22
|
+
gunzip.on("data", (chunk: Buffer) => {
|
|
23
|
+
buf = buf.length ? Buffer.concat([ buf, chunk ]) : chunk
|
|
24
|
+
let off = 0
|
|
25
|
+
for (;;) {
|
|
26
|
+
if (state === "header") {
|
|
27
|
+
if (buf.length - off < 512) break
|
|
28
|
+
const h = buf.subarray(off, off + 512)
|
|
29
|
+
off += 512
|
|
30
|
+
if (h[0] === 0) continue
|
|
31
|
+
const name = h.subarray(0, 100).toString("utf8").replace(/\0.*$/s, "")
|
|
32
|
+
const size = parseInt(h.subarray(124, 136).toString("utf8").replace(/[\0 ]/g, ""), 8) || 0
|
|
33
|
+
const prefix = h.subarray(345, 500).toString("utf8").replace(/\0.*$/s, "")
|
|
34
|
+
entry = { name: prefix ? prefix + "/" + name : name, size }
|
|
35
|
+
remaining = size
|
|
36
|
+
pad = (512 - (size % 512)) % 512
|
|
37
|
+
chunks.length = 0
|
|
38
|
+
state = size > 0 ? "body" : "pad"
|
|
39
|
+
} else if (state === "body") {
|
|
40
|
+
const avail = buf.length - off
|
|
41
|
+
if (avail === 0) break
|
|
42
|
+
const take = Math.min(remaining, avail)
|
|
43
|
+
chunks.push(buf.subarray(off, off + take))
|
|
44
|
+
off += take
|
|
45
|
+
remaining -= take
|
|
46
|
+
if (remaining === 0) state = "pad"
|
|
47
|
+
} else {
|
|
48
|
+
if (buf.length - off < pad) break
|
|
49
|
+
off += pad
|
|
50
|
+
pad = 0
|
|
51
|
+
if (entry && entry.size > 0) onEntry(entry, Buffer.concat(chunks))
|
|
52
|
+
chunks.length = 0
|
|
53
|
+
entry = null
|
|
54
|
+
state = "header"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
buf = buf.subarray(off)
|
|
58
|
+
})
|
|
59
|
+
gunzip.on("end", () => resolveP())
|
|
60
|
+
gunzip.on("error", rejectP)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
export const INDEX_NAME = "unity-index.txt"
|
|
64
|
+
|
|
65
|
+
/** Pass 1: GUID → path for every asset in the package. */
|
|
66
|
+
export const indexUnityPackage = async (pkg: string, progress?: (entries: number, assets: number) => void): Promise<Map<string, string>> => {
|
|
67
|
+
const map = new Map<string, string>()
|
|
68
|
+
let n = 0
|
|
69
|
+
await tarStream(pkg, (e, data) => {
|
|
70
|
+
n++
|
|
71
|
+
if (e.name.endsWith("/pathname")) {
|
|
72
|
+
const guid = e.name.split("/")[0]
|
|
73
|
+
const p = Buffer.from(data).toString("utf8").split("\n")[0].trim()
|
|
74
|
+
if (/^[0-9a-f]{32}$/.test(guid) && p) map.set(guid, p)
|
|
75
|
+
}
|
|
76
|
+
if (n % 20000 === 0) progress?.(n, map.size)
|
|
77
|
+
})
|
|
78
|
+
return map
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const writeUnityIndex = (outDir: string, index: Map<string, string>): string => {
|
|
82
|
+
mkdirSync(outDir, { recursive: true })
|
|
83
|
+
const f = join(outDir, INDEX_NAME)
|
|
84
|
+
writeFileSync(f, [ ...index ].map(([ g, p ]) => `${g}\t${p}`).join("\n") + "\n")
|
|
85
|
+
return f
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const readUnityIndex = (file: string): Map<string, string> => {
|
|
89
|
+
const map = new Map<string, string>()
|
|
90
|
+
for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
91
|
+
const t = line.indexOf("\t")
|
|
92
|
+
if (t > 0) map.set(line.slice(0, t), line.slice(t + 1).trim())
|
|
93
|
+
}
|
|
94
|
+
return map
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type ExtractOptions = {
|
|
98
|
+
/** Regexes (case-insensitive) a path must match; none = everything. */
|
|
99
|
+
only?: RegExp[]
|
|
100
|
+
/** Also write the `.meta` sidecars (default true). */
|
|
101
|
+
meta?: boolean
|
|
102
|
+
/** Skip files that already exist with the same size (default true — re-runs are cheap). */
|
|
103
|
+
skipExisting?: boolean
|
|
104
|
+
progress?: (files: number, bytes: number) => void
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Pass 2: write the matching assets under `outDir/<pathname>`. */
|
|
108
|
+
export const extractUnityPackage = async (pkg: string, index: Map<string, string>, outDir: string, opts: ExtractOptions = {}): Promise<{ files: number, bytes: number, matched: number }> => {
|
|
109
|
+
const want = new Map<string, string>()
|
|
110
|
+
for (const [ g, p ] of index) if (!opts.only?.length || opts.only.some((r) => r.test(p))) want.set(g, p)
|
|
111
|
+
let files = 0, bytes = 0
|
|
112
|
+
await tarStream(pkg, (e, data) => {
|
|
113
|
+
const parts = e.name.split("/")
|
|
114
|
+
if (parts.length < 2) return
|
|
115
|
+
const leaf = parts[parts.length - 1]
|
|
116
|
+
const isAsset = leaf === "asset", isMeta = leaf === "asset.meta"
|
|
117
|
+
if (!isAsset && !(isMeta && (opts.meta ?? true))) return
|
|
118
|
+
const p = want.get(parts[0])
|
|
119
|
+
if (!p) return
|
|
120
|
+
const dest = join(outDir, p) + (isMeta ? ".meta" : "")
|
|
121
|
+
if ((opts.skipExisting ?? true) && existsSync(dest)) {
|
|
122
|
+
try { if (statSync(dest).size === data.length) return } catch { /* fall through */ }
|
|
123
|
+
}
|
|
124
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
125
|
+
writeFileSync(dest, data)
|
|
126
|
+
if (isAsset) { files++; bytes += data.length }
|
|
127
|
+
if (files % 50 === 0) opts.progress?.(files, bytes)
|
|
128
|
+
})
|
|
129
|
+
return { files, bytes, matched: want.size }
|
|
130
|
+
}
|