lecodes-assets 0.2.1 → 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 (97) hide show
  1. package/README.md +203 -26
  2. package/dist/cli.js +17948 -11911
  3. package/dist/index.js +17525 -11479
  4. package/package.json +3 -1
  5. package/src/cli-kit/command.ts +100 -0
  6. package/src/cli-kit/help.ts +72 -0
  7. package/src/cli-kit/index.ts +4 -0
  8. package/src/cli-kit/parse.ts +72 -0
  9. package/src/cli-kit/run.ts +52 -0
  10. package/src/cli.ts +24 -1039
  11. package/src/commands/avatar.ts +77 -0
  12. package/src/commands/bvh.ts +26 -0
  13. package/src/commands/convert.ts +156 -0
  14. package/src/commands/doctor.ts +67 -0
  15. package/src/commands/geometry.ts +45 -0
  16. package/src/commands/index.ts +24 -0
  17. package/src/commands/marks.ts +38 -0
  18. package/src/commands/mirror.ts +35 -0
  19. package/src/commands/probe.ts +43 -0
  20. package/src/commands/retarget/donor.ts +63 -0
  21. package/src/commands/retarget/index.ts +169 -0
  22. package/src/commands/retarget/map.ts +81 -0
  23. package/src/commands/retarget/report.ts +77 -0
  24. package/src/commands/scene.ts +103 -0
  25. package/src/commands/shared.ts +121 -0
  26. package/src/commands/sky.ts +35 -0
  27. package/src/commands/terrainPack.ts +40 -0
  28. package/src/commands/unpack.ts +43 -0
  29. package/src/commands/unreal.ts +109 -0
  30. package/src/commands/unwrap.ts +56 -0
  31. package/src/doctor/cardTrim.ts +286 -0
  32. package/src/doctor/doctor.ts +94 -30
  33. package/src/doctor/merge/accessors.ts +30 -0
  34. package/src/doctor/merge/atlasLayout.ts +94 -0
  35. package/src/doctor/merge/atlasPixels.ts +114 -0
  36. package/src/doctor/merge/bindSpace.ts +59 -0
  37. package/src/doctor/merge/classify.ts +136 -0
  38. package/src/doctor/merge/geometry.ts +167 -0
  39. package/src/doctor/merge/index.ts +98 -0
  40. package/src/doctor/merge/matrix.ts +60 -0
  41. package/src/doctor/merge/plan.ts +88 -0
  42. package/src/doctor/merge/textures.ts +60 -0
  43. package/src/doctor/merge/types.ts +77 -0
  44. package/src/doctor/transcodeKtx2.ts +179 -28
  45. package/src/doctor/validateGlb.ts +2 -2
  46. package/src/doctor/vertexAo.ts +321 -0
  47. package/src/fbx/read.ts +2 -0
  48. package/src/fbx/toDocument.ts +227 -209
  49. package/src/geometry.ts +222 -0
  50. package/src/index.ts +2 -0
  51. package/src/retarget/heading.ts +238 -171
  52. package/src/retarget/humanoid.ts +572 -561
  53. package/src/retarget/retarget.ts +2375 -2272
  54. package/src/retarget/rig.ts +4 -1
  55. package/src/retarget/writeMarks.ts +134 -132
  56. package/src/sky/cmgen.ts +3 -3
  57. package/src/sky/index.ts +57 -8
  58. package/src/terrainPack.ts +61 -22
  59. package/src/unity/colliderFit.ts +287 -0
  60. package/src/unity/fbxModel.ts +194 -0
  61. package/src/unity/foliage.ts +63 -0
  62. package/src/unity/gameObjects.ts +81 -0
  63. package/src/unity/guid.ts +104 -0
  64. package/src/unity/image.ts +58 -0
  65. package/src/unity/kindGlb.ts +364 -0
  66. package/src/unity/lecodesLevel.ts +381 -0
  67. package/src/unity/levelTerrain.ts +116 -0
  68. package/src/unity/matFile.ts +179 -0
  69. package/src/unity/materialSlots.ts +53 -0
  70. package/src/unity/materialWriter.ts +306 -0
  71. package/src/unity/materials.ts +34 -429
  72. package/src/unity/math.ts +82 -0
  73. package/src/unity/prefabTree.ts +285 -0
  74. package/src/unity/prefabs.ts +10 -36
  75. package/src/unity/propertyPath.ts +43 -0
  76. package/src/unity/scene.ts +325 -304
  77. package/src/unity/serialized.ts +242 -0
  78. package/src/unity/terrain.ts +221 -0
  79. package/src/unity/triBudget.ts +77 -0
  80. package/src/unity/triplanar.ts +122 -0
  81. package/src/unity/unityMesh.ts +151 -0
  82. package/src/unity/unpack.ts +24 -5
  83. package/src/unity/yaml.ts +131 -0
  84. package/src/unreal/index.ts +6 -5
  85. package/src/unreal/materialGraph.ts +224 -0
  86. package/src/unreal/materials.ts +117 -9
  87. package/src/unreal/package.ts +30 -12
  88. package/src/unreal/resolve.ts +6 -4
  89. package/src/unreal/sound.ts +128 -0
  90. package/src/unreal/texture.ts +70 -3
  91. package/src/unreal/toDocument.ts +7 -0
  92. package/src/unreal/versions.ts +8 -0
  93. package/src/unwrap/index.ts +167 -0
  94. package/src/unwrap/lightmapUv.ts +761 -0
  95. package/src/wasm/fbxReader.js +1 -1
  96. package/src/wasm/lightmapUv.js +1 -1
  97. package/src/doctor/lightmapUv.ts +0 -452
@@ -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
+ }
@@ -1,19 +1,26 @@
1
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
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
3
4
  // memory flat on multi-GB packs: pass 1 indexes GUID → path (written as `unity-index.txt` at the
4
5
  // output root — `convert` resolves Unity material textures through it), pass 2 writes the files
5
6
  // that match the requested patterns (everything by default), `.meta` sidecars included so the
6
7
  // result is also a valid Unity project fragment.
7
8
 
8
- import { createReadStream, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
9
+ import { closeSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync, writeSync } from "node:fs"
9
10
  import { dirname, join } from "node:path"
10
11
  import { createGunzip } from "node:zlib"
11
12
 
12
13
  type Entry = { name: string, size: number }
13
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
+
14
21
  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)
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
17
24
  let buf: Buffer = Buffer.alloc(0)
18
25
  let state: "header" | "body" | "pad" = "header"
19
26
  let entry: Entry | null = null
@@ -94,6 +101,18 @@ export const readUnityIndex = (file: string): Map<string, string> => {
94
101
  return map
95
102
  }
96
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
+
97
116
  export type ExtractOptions = {
98
117
  /** Regexes (case-insensitive) a path must match; none = everything. */
99
118
  only?: RegExp[]
@@ -122,7 +141,7 @@ export const extractUnityPackage = async (pkg: string, index: Map<string, string
122
141
  try { if (statSync(dest).size === data.length) return } catch { /* fall through */ }
123
142
  }
124
143
  mkdirSync(dirname(dest), { recursive: true })
125
- writeFileSync(dest, data)
144
+ writeWhole(dest, data)
126
145
  if (isAsset) { files++; bytes += data.length }
127
146
  if (files % 50 === 0) opts.progress?.(files, bytes)
128
147
  })
@@ -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
+ }
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { readFileSync } from "node:fs"
6
6
  import { basename, dirname } from "node:path"
7
- import { readPackage, engineLabel, classOf, importPackagePath, type UePackage } from "./package"
7
+ import { readPackage, engineLabel, classOf, importPackagePath, type UePackage, type ReadPackageOptions } from "./package"
8
8
  import { readSkeleton, type UeSkeleton } from "./skeleton"
9
9
  import { readAnimSequence, dataModelKind, packageKind, type UeAnimSequence, type DataModelKind } from "./anim"
10
10
  import { readSkeletalMesh, type UeSkeletalMesh } from "./mesh"
@@ -38,11 +38,12 @@ export type LoadedUnreal = {
38
38
  const packageCache = new Map<string, UePackage>()
39
39
  const skeletonCache = new Map<string, UeSkeleton>()
40
40
 
41
- export const readPackageFile = (path: string): UePackage => {
42
- const hit = packageCache.get(path)
41
+ export const readPackageFile = (path: string, opts: ReadPackageOptions = {}): UePackage => {
42
+ const key = opts.ue4 ? `${path}\0ue4` : path
43
+ const hit = packageCache.get(key)
43
44
  if (hit) return hit
44
- const pkg = readPackage(new Uint8Array(readFileSync(path)), basename(path))
45
- packageCache.set(path, pkg)
45
+ const pkg = readPackage(new Uint8Array(readFileSync(path)), basename(path), opts)
46
+ packageCache.set(key, pkg)
46
47
  return pkg
47
48
  }
48
49
 
@@ -0,0 +1,224 @@
1
+ // A Material's EXPRESSION GRAPH — the nodes the artist wired in the material editor, as data.
2
+ //
3
+ // `materials.ts` places textures by what their names suggest, because what a parameter DOES is a
4
+ // property of the graph. This reads the graph itself: every `MaterialExpression*` export with its
5
+ // scalar settings (a Constant's `R`, a ScalarParameter's name and default, a TextureSample's asset)
6
+ // and its inputs as edges (`{ node, output, mask }`), plus the material's own outputs (BaseColor,
7
+ // Opacity, Normal, …) as edges into it. A tool can then EVALUATE the graph — e.g. bake what a decal
8
+ // material computes into a texture a renderer without that shader can use.
9
+ //
10
+ // An input (`FExpressionInput` and its Color/Scalar/Vector subclasses) is a StructProperty whose
11
+ // payload is native, not a tag list: the expression (package index), the output index, the input's
12
+ // name, the mask flag and the four channel masks, then the subclass's own constant. Only the common
13
+ // head is read. Output indices follow each class's own output list — a TextureSample's are RGB, R,
14
+ // G, B, A, RGBA — and `mask` spells the channels the edge takes ("rgb", "a", "" = all).
15
+ //
16
+ // Verified on UE 4.25 packages (the "Bullet Impact VFX" pack's decal materials, read with
17
+ // `{ ue4: true }`). UE5 writes the same input head, but its editor-only data moved to
18
+ // `MaterialEditorOnlyData` — a UE5 Material's outputs are read from that export when present.
19
+
20
+ import { classOf, readFName, readObjectProperties, readProperties, propInt, resolveObject, type PropertyTag, type UePackage, type BinaryReader } from "./package"
21
+
22
+ export type MaterialEdge = {
23
+ /** The expression the edge comes from (its object name, e.g. `MaterialExpressionMultiply_21`). */
24
+ node: string
25
+ /** Which of that expression's outputs. */
26
+ output: number
27
+ /** The channels taken: `"rgb"`, `"a"`, `"r"` … or `""` for the output as it is. */
28
+ mask: string
29
+ }
30
+
31
+ export type MaterialExpression = {
32
+ /** `Multiply`, `LinearInterpolate`, `TextureSample`, … (the class without `MaterialExpression`). */
33
+ kind: string
34
+ /** Connected inputs by their property name (`A`, `B`, `Alpha`, `Input`, `Coordinates`, …). */
35
+ inputs: Record<string, MaterialEdge>
36
+ /** Everything scalar the expression carries: numbers, names, enums, booleans, colours, and
37
+ * object references as the referenced asset's path (`Texture` → `/Game/…/T_x`). */
38
+ values: Record<string, number | string | boolean | number[]>
39
+ }
40
+
41
+ export type MaterialGraph = {
42
+ name: string
43
+ /** `MD_Surface`, `MD_DeferredDecal`, …; `BLEND_Opaque`, `BLEND_Translucent`, …; `MSM_DefaultLit`, …. */
44
+ domain: string
45
+ blendMode: string
46
+ shadingModel: string
47
+ /** The material's own pins (`BaseColor`, `Opacity`, `Normal`, `Metallic`, `Roughness`, `EmissiveColor`, …). */
48
+ outputs: Record<string, MaterialEdge>
49
+ /** Every other scalar setting on the material (two-sided, tangent-space normal, …). */
50
+ settings: Record<string, number | string | boolean | number[]>
51
+ expressions: Record<string, MaterialExpression>
52
+ }
53
+
54
+ /** Struct types that are an expression input (`FExpressionInput` and its subclasses). */
55
+ const isInputStruct = (s: string | undefined): boolean => !!s && /(^ExpressionInput$|MaterialInput$)/.test(s)
56
+
57
+ const MASK = [ "r", "g", "b", "a" ]
58
+
59
+ /** The native head of an `FExpressionInput`: expression, output index, input name, mask, r/g/b/a. */
60
+ const readEdge = (pkg: UePackage, r: BinaryReader, tag: PropertyTag): MaterialEdge | null => {
61
+ if (isTaggedEdge(pkg, r, tag)) return readTaggedEdge(pkg, r, tag)
62
+ if (tag.size < 36) return null
63
+ r.offset = tag.data
64
+ const expr = r.i32(), output = r.i32()
65
+ readFName(r, pkg.names) // the input's display name
66
+ const masked = r.i32() !== 0
67
+ const channels = [ r.i32(), r.i32(), r.i32(), r.i32() ]
68
+ return edgeTo(pkg, expr, output, masked, channels)
69
+ }
70
+
71
+ const EDGE_FIELDS = new Set([ "Expression", "OutputIndex", "InputName", "Mask", "MaskR", "MaskG", "MaskB", "MaskA", "ExpressionName", "None" ])
72
+
73
+ /**
74
+ * Old UE4 (seen on 4.10, file version 482) writes an input as an ordinary TAG LIST — `Expression`
75
+ * (ObjectProperty), `OutputIndex`, `Mask`, `MaskR`… each only when it is not the default — instead of
76
+ * the native head. Told apart by its first two names: a field of the struct, then a property type.
77
+ */
78
+ const isTaggedEdge = (pkg: UePackage, r: BinaryReader, tag: PropertyTag): boolean => {
79
+ if (tag.size < 8 || pkg.summary.ue5 !== 0) return false
80
+ r.offset = tag.data
81
+ const n0 = r.i32()
82
+ if (n0 < 0 || n0 >= pkg.names.length || !EDGE_FIELDS.has(pkg.names[n0])) return false
83
+ if (pkg.names[n0] === "None") return tag.size === 8
84
+ r.i32()
85
+ const n2 = r.i32()
86
+ return n2 >= 0 && n2 < pkg.names.length && pkg.names[n2].endsWith("Property")
87
+ }
88
+
89
+ const readTaggedEdge = (pkg: UePackage, r: BinaryReader, tag: PropertyTag): MaterialEdge | null => {
90
+ r.offset = tag.data
91
+ const p = readProperties(r, pkg)
92
+ const int = (n: string): number => propInt(r, p.byName.get(n)) ?? 0
93
+ return edgeTo(pkg, int("Expression"), int("OutputIndex"), int("Mask") !== 0, [ int("MaskR"), int("MaskG"), int("MaskB"), int("MaskA") ])
94
+ }
95
+
96
+ const edgeTo = (pkg: UePackage, expr: number, output: number, masked: boolean, channels: number[]): MaterialEdge | null => {
97
+ if (expr === 0) return null
98
+ const target = resolveObject(pkg, expr)
99
+ if (!target) return null
100
+ return { node: target.name, output, mask: masked ? channels.map((c, i) => (c ? MASK[i] : "")).join("") : "" }
101
+ }
102
+
103
+ /** A tag's value when it is a plain setting; undefined for what this reader does not model. */
104
+ const readValue = (pkg: UePackage, r: BinaryReader, t: PropertyTag): number | string | boolean | number[] | undefined => {
105
+ r.offset = t.data
106
+ switch (t.type) {
107
+ case "FloatProperty": return r.f32()
108
+ case "DoubleProperty": return r.f64()
109
+ case "IntProperty": return r.i32()
110
+ case "BoolProperty": return t.bool ?? false
111
+ case "NameProperty": return readFName(r, pkg.names)
112
+ case "StrProperty": return r.fstring()
113
+ case "EnumProperty": return readFName(r, pkg.names)
114
+ case "ByteProperty": return t.enumName && t.enumName !== "None" ? readFName(r, pkg.names) : r.u8()
115
+ case "ObjectProperty": {
116
+ const o = resolveObject(pkg, r.i32())
117
+ if (!o) return undefined
118
+ return o.kind === "import" && o.packagePath ? o.packagePath : o.name
119
+ }
120
+ case "StructProperty":
121
+ if (t.struct === "LinearColor") return [ r.f32(), r.f32(), r.f32(), r.f32() ]
122
+ if (t.struct === "Color") { const b = r.raw(4); return [ b[2] / 255, b[1] / 255, b[0] / 255, b[3] / 255 ] } // BGRA in memory
123
+ return undefined
124
+ default: return undefined
125
+ }
126
+ }
127
+
128
+ // Bookkeeping every expression carries that says nothing about what it computes.
129
+ const NOISE = /^(MaterialExpressionEditor[XY]|MaterialExpressionGuid|ExpressionGUID|Material|Function|Desc|bCollapsed|bRealtimePreview|bHidePreviewWindow|bShowOutputNameOnPin|bNeedToUpdatePreview|Outputs|SortPriority|Group)$/
130
+
131
+ /** What an edge evaluates to when everything upstream of it is a number — `null` as soon as anything
132
+ * is not (a texture, a vertex colour, UVs, an expression this does not model). Output 0 is the whole
133
+ * value, output k the k-th channel (a VectorParameter's RGB / R / G / B / A); the edge's mask then
134
+ * takes its channels. Properties left at their class default are not serialised, so the defaults
135
+ * are UE's: a Constant's R 0, a Multiply's ConstB 1, a Lerp's ConstAlpha 0.5. */
136
+ export const evaluateConstant = (graph: MaterialGraph, edge: MaterialEdge | undefined, depth = 0): number[] | null => {
137
+ if (!edge || depth > 64) return null
138
+ const ex = graph.expressions[edge.node]
139
+ if (!ex) return null
140
+ const num = (k: string, d = 0): number => (typeof ex.values[k] === "number" ? ex.values[k] as number : d)
141
+ const vec = (k: string, d: number[]): number[] => (Array.isArray(ex.values[k]) ? [ ...(ex.values[k] as number[]) ] : d)
142
+ const arg = (pin: string, constKey: string, d: number): number[] | null =>
143
+ ex.inputs[pin] ? evaluateConstant(graph, ex.inputs[pin], depth + 1) : [ num(constKey, d) ]
144
+ const zip = (a: number[], b: number[], f: (x: number, y: number) => number): number[] => {
145
+ const n = Math.max(a.length, b.length)
146
+ return Array.from({ length: n }, (_, i) => f(a.length === 1 ? a[0] : a[i] ?? 0, b.length === 1 ? b[0] : b[i] ?? 0))
147
+ }
148
+ let v: number[] | null
149
+ switch (ex.kind) {
150
+ case "Constant": v = [ num("R") ]; break
151
+ case "Constant2Vector": v = [ num("R"), num("G") ]; break
152
+ case "Constant3Vector": v = vec("Constant", [ 0, 0, 0, 0 ]).slice(0, 3); break
153
+ case "Constant4Vector": v = vec("Constant", [ 0, 0, 0, 0 ]); break
154
+ case "ScalarParameter": case "StaticBoolParameter": v = [ num("DefaultValue") ]; break
155
+ case "VectorParameter": v = vec("DefaultValue", [ 0, 0, 0, 1 ]); break
156
+ case "Multiply": case "Add": case "Subtract": case "Divide": case "Max": case "Min": {
157
+ const a = arg("A", "ConstA", ex.kind === "Multiply" || ex.kind === "Divide" ? 1 : 0)
158
+ const b = arg("B", "ConstB", ex.kind === "Multiply" || ex.kind === "Add" || ex.kind === "Divide" ? 1 : 0)
159
+ if (!a || !b) return null
160
+ const op = { Multiply: (x: number, y: number) => x * y, Add: (x: number, y: number) => x + y, Subtract: (x: number, y: number) => x - y,
161
+ Divide: (x: number, y: number) => (y ? x / y : 0), Max: Math.max, Min: Math.min }[ex.kind]
162
+ v = zip(a, b, op)
163
+ break
164
+ }
165
+ case "OneMinus": { const a = arg("Input", "", 0); v = a && a.map((x) => 1 - x); break }
166
+ case "LinearInterpolate": {
167
+ const a = arg("A", "ConstA", 0), b = arg("B", "ConstB", 1), t = arg("Alpha", "ConstAlpha", 0.5)
168
+ if (!a || !b || !t) return null
169
+ v = zip(zip(a, b, (x, y) => y - x), t, (d, s) => d * s).map((d, i) => (a.length === 1 ? a[0] : a[i] ?? 0) + d)
170
+ break
171
+ }
172
+ default: return null
173
+ }
174
+ if (!v) return null
175
+ if (edge.output > 0) v = [ v[edge.output - 1] ?? 0 ]
176
+ if (edge.mask) v = [ ...edge.mask ].map((c) => v![MASK.indexOf(c)] ?? v![0] ?? 0)
177
+ return v
178
+ }
179
+
180
+ /** The expression graph of the package's Material (throws when it holds none). */
181
+ export const readMaterialGraph = (pkg: UePackage): MaterialGraph => {
182
+ const exports = pkg.exports.map((e, i) => ({ e, i, cls: classOf(pkg, e) }))
183
+ const mat = exports.find((x) => x.cls === "Material")
184
+ if (!mat) throw new Error(`${pkg.label}: no Material export (${[ ...new Set(exports.map((x) => x.cls)) ].join(", ")})`)
185
+ const graph: MaterialGraph = { name: mat.e.objectName, domain: "MD_Surface", blendMode: "BLEND_Opaque", shadingModel: "MSM_DefaultLit", outputs: {}, settings: {}, expressions: {} }
186
+
187
+ // the material's own pins: on the Material in UE4, on its MaterialEditorOnlyData in UE5
188
+ const pinsFrom = [ mat, ...exports.filter((x) => x.cls === "MaterialEditorOnlyData") ]
189
+ for (const src of pinsFrom) {
190
+ const { reader: r, props } = readObjectProperties(pkg, src.e)
191
+ for (const t of props.list) {
192
+ if (t.type === "StructProperty" && isInputStruct(t.struct)) {
193
+ const edge = readEdge(pkg, r, t)
194
+ if (edge) graph.outputs[t.name] = edge
195
+ continue
196
+ }
197
+ if (src !== mat || NOISE.test(t.name)) continue
198
+ const v = readValue(pkg, r, t)
199
+ if (v === undefined) continue
200
+ if (t.name === "MaterialDomain") graph.domain = String(v)
201
+ else if (t.name === "BlendMode") graph.blendMode = String(v)
202
+ else if (t.name === "ShadingModel") graph.shadingModel = String(v)
203
+ else graph.settings[t.name] = v
204
+ }
205
+ }
206
+
207
+ for (const x of exports) {
208
+ if (!x.cls.startsWith("MaterialExpression") || x.cls === "MaterialExpressionComment") continue
209
+ const { reader: r, props } = readObjectProperties(pkg, x.e)
210
+ const ex: MaterialExpression = { kind: x.cls.slice("MaterialExpression".length), inputs: {}, values: {} }
211
+ for (const t of props.list) {
212
+ if (NOISE.test(t.name)) continue
213
+ if (t.type === "StructProperty" && isInputStruct(t.struct)) {
214
+ const edge = readEdge(pkg, r, t)
215
+ if (edge) ex.inputs[t.name] = edge
216
+ continue
217
+ }
218
+ const v = readValue(pkg, r, t)
219
+ if (v !== undefined) ex.values[t.name] = v
220
+ }
221
+ graph.expressions[x.e.objectName] = ex
222
+ }
223
+ return graph
224
+ }