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
@@ -16,10 +16,11 @@
16
16
  import { basename, dirname } from "node:path"
17
17
  import type { Document, Material, Texture } from "@gltf-transform/core"
18
18
  import type { ConvertWarning } from "../fbx/toDocument"
19
- import { findExport, readArrayHeader, readFName, readProperties, readObjectProperties, resolveObject, classOf, type UePackage } from "./package"
19
+ import { BinaryReader, findExport, readArrayHeader, readFName, readProperties, readObjectProperties, resolveObject, classOf, type UePackage } from "./package"
20
20
  import { readPackageFile } from "./index"
21
21
  import { resolveObjectFile } from "./resolve"
22
22
  import { extractTexture, readTexture2D, type UeTexture } from "./texture"
23
+ import { evaluateConstant, readMaterialGraph, type MaterialGraph } from "./materialGraph"
23
24
  import { findOodle } from "./oodle"
24
25
 
25
26
  export type TextureRole = "base" | "normal" | "orm" | "other"
@@ -44,6 +45,7 @@ const SCORES: Record<Exclude<TextureRole, "other">, { re: RegExp, on: "param" |
44
45
  base: [
45
46
  { re: /^(base ?colou?r|base ?tex(ture)?|albedo|diffuse|colou?r ?map)( map)?$/i, on: "param", score: 100 },
46
47
  { re: /_(d|bc|albedo|basecolor|diff)$/i, on: "asset", score: 90 },
48
+ { re: /(base ?colou?r|albedo)$/i, on: "asset", score: 85 },
47
49
  { re: /base ?colou?r|albedo|diffuse/i, on: "param", score: 80 },
48
50
  ],
49
51
  normal: [
@@ -114,6 +116,60 @@ export const readMaterialTextures = (pkg: UePackage): UeTextureParam[] => {
114
116
  return out
115
117
  }
116
118
 
119
+ /** One entry of a `SkeletalMesh`'s own material array: which asset the SLOT actually uses. */
120
+ export type UeSkeletalMaterial = { slot: string, imported: string, path: string, name: string }
121
+
122
+ /**
123
+ * The mesh's OWN per-slot material assignment (`TArray<FSkeletalMaterial>` from
124
+ * `USkeletalMesh::Serialize`, right after the tagged properties). This is the only exact answer:
125
+ * a slot's NAME is what the FBX import left behind (`MI_Pants_ColorA`), while the material the
126
+ * artist later put in that slot can be a different asset entirely (`MI_Pants_CamoA`) — matching by
127
+ * name alone leaves those slots grey.
128
+ *
129
+ * The array's offset is not fixed (the header between the properties and it has changed across 5.x),
130
+ * so the start is FOUND rather than assumed: a candidate offset must parse into a whole array whose
131
+ * every entry resolves to a Material object and whose flags are booleans. Nothing is returned unless
132
+ * that holds, and the caller falls back to matching by name.
133
+ */
134
+ export const readSkeletalMaterials = (pkg: UePackage): UeSkeletalMaterial[] => {
135
+ const found = findExport(pkg, "SkeletalMesh")
136
+ if (!found) return []
137
+ let start: number
138
+ let data: Uint8Array
139
+ try {
140
+ const { reader } = readObjectProperties(pkg, found.export)
141
+ start = reader.offset
142
+ data = reader.data
143
+ } catch { return [] }
144
+ const end = found.export.serialOffset + found.export.serialSize
145
+ for (let off = start; off < start + 128; off++) {
146
+ const r = new BinaryReader(data, off, pkg.label)
147
+ try {
148
+ const count = r.i32()
149
+ if (count < 1 || count > 256) continue
150
+ const rows: UeSkeletalMaterial[] = []
151
+ for (let i = 0; i < count; i++) {
152
+ // a slot may hold NO material (index 0 — UE draws its default there); that is still a row
153
+ const index = r.i32()
154
+ const ref = index === 0 ? { packagePath: "", name: "", className: "Material" } : resolveObject(pkg, index)
155
+ if (!ref || !/Material/.test(ref.className)) throw new Error("not a material")
156
+ const slot = readFName(r, pkg.names)
157
+ const hasImported = r.i32()
158
+ if (hasImported !== 0 && hasImported !== 1) throw new Error("not a bool")
159
+ const imported = hasImported ? readFName(r, pkg.names) : ""
160
+ // FMeshUVChannelInfo: bInitialized, bOverrideDensities, LocalUVDensities[4]
161
+ const init = r.i32(), override = r.i32()
162
+ if (init > 1 || init < 0 || override > 1 || override < 0) throw new Error("not a UV channel info")
163
+ r.skip(16)
164
+ rows.push({ slot, imported, path: ref.packagePath, name: ref.name })
165
+ }
166
+ if (r.offset > end) continue
167
+ return rows
168
+ } catch { /* not the array — keep scanning */ }
169
+ }
170
+ return []
171
+ }
172
+
117
173
  const normalize = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]/g, "")
118
174
 
119
175
  /**
@@ -122,6 +178,24 @@ const normalize = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]/g, "
122
178
  * agree and nothing else did) in order, which is how the assets line up in practice.
123
179
  */
124
180
  export const resolveSlotMaterials = (pkg: UePackage, slots: string[]): (string | null)[] => {
181
+ const out: (string | null)[] = slots.map<string | null>(() => null)
182
+
183
+ // exact first: the mesh's own slot → material array, matched by slot name (a mesh can carry more
184
+ // entries than the import data has slots — an unused section — so index order is not enough)
185
+ const own = readSkeletalMaterials(pkg)
186
+ if (own.length) {
187
+ const byName = new Map<string, string>()
188
+ // an EMPTY slot (no material assigned) is left for the name match below rather than taken as ""
189
+ for (const m of own) {
190
+ if (m.path) for (const key of [ m.imported, m.slot ]) if (key) byName.set(normalize(key), m.path)
191
+ }
192
+ slots.forEach((s, i) => { out[i] = byName.get(normalize(s)) ?? null })
193
+ const matched = out.filter((v) => v !== null).length
194
+ // no name agreed at all (an import whose slots were renamed) — the array IS the slot order
195
+ if (!matched && own.length === slots.length) own.forEach((m, i) => { out[i] = m.path })
196
+ if (out.every((v) => v !== null)) return out
197
+ }
198
+
125
199
  const refs: { name: string, path: string }[] = []
126
200
  for (let i = 0; i < pkg.imports.length; i++) {
127
201
  const imp = pkg.imports[i]
@@ -129,10 +203,9 @@ export const resolveSlotMaterials = (pkg: UePackage, slots: string[]): (string |
129
203
  const path = resolveObject(pkg, -i - 1)?.packagePath ?? ""
130
204
  if (path && !refs.some((r) => r.path === path)) refs.push({ name: imp.objectName, path })
131
205
  }
132
- const out: (string | null)[] = slots.map<string | null>(() => null)
133
- const left = [ ...refs ]
206
+ const left = refs.filter((r) => !out.includes(r.path))
134
207
  const take = (i: number, r: { name: string, path: string }): void => { out[i] = r.path; left.splice(left.indexOf(r), 1) }
135
- slots.forEach((s, i) => { const hit = left.find((r) => r.name.toLowerCase() === s.toLowerCase()); if (hit) take(i, hit) })
208
+ slots.forEach((s, i) => { if (out[i]) return; const hit = left.find((r) => r.name.toLowerCase() === s.toLowerCase()); if (hit) take(i, hit) })
136
209
  slots.forEach((s, i) => { if (!out[i]) { const hit = left.find((r) => normalize(r.name) === normalize(s)); if (hit) take(i, hit) } })
137
210
  slots.forEach((s, i) => {
138
211
  if (out[i] || !left.length) return
@@ -167,7 +240,7 @@ export type AppliedTextures = {
167
240
 
168
241
  /** Decode → fix channels → re-encode. Uses `sharp` when it is installed, else the package's own
169
242
  * PNG codec (correct, just slower and without the resize). */
170
- export const fixChannels = async (data: Uint8Array, mime: string, opts: { swapRb: boolean, flipGreen: boolean, maxSize: number }): Promise<{ data: Uint8Array, mimeType: string }> => {
243
+ export const fixChannels = async (data: Uint8Array, mime: string, opts: { swapRb: boolean, flipGreen: boolean, maxSize: number, png?: boolean }): Promise<{ data: Uint8Array, mimeType: string }> => {
171
244
  const { swapRb, flipGreen, maxSize } = opts
172
245
  if (!swapRb && !flipGreen && !maxSize) return { data, mimeType: mime }
173
246
  try {
@@ -183,11 +256,15 @@ export const fixChannels = async (data: Uint8Array, mime: string, opts: { swapRb
183
256
  }
184
257
  let usesAlpha = false
185
258
  if (ch === 4) for (let i = 3; i < raw.length; i += 4) if (raw[i] !== 255) { usesAlpha = true; break }
259
+ // An opaque image goes to JPEG (what a GLB wants to embed) unless the caller asked for a
260
+ // lossless file — `unreal tex`, where the image is a SOURCE for further work (a normal map
261
+ // headed for an atlas has no business picking up JPEG blocks on the way).
262
+ const lossless = usesAlpha || opts.png === true
186
263
  const out = sharp(raw, { raw: { width: info.width, height: info.height, channels: ch } })
187
- const encoded = usesAlpha
188
- ? await out.png({ compressionLevel: 9 }).toBuffer()
264
+ const encoded = lossless
265
+ ? await (usesAlpha ? out : out.removeAlpha()).png({ compressionLevel: 9 }).toBuffer()
189
266
  : await out.removeAlpha().jpeg({ quality: 92, chromaSubsampling: "4:4:4" }).toBuffer()
190
- return { data: new Uint8Array(encoded), mimeType: usesAlpha ? "image/png" : "image/jpeg" }
267
+ return { data: new Uint8Array(encoded), mimeType: lossless ? "image/png" : "image/jpeg" }
191
268
  } catch {
192
269
  // no sharp: the channel fix still has to happen, so do it on the package's own PNG codec
193
270
  const { decodePng } = await import("../unity/png")
@@ -202,6 +279,17 @@ export const fixChannels = async (data: Uint8Array, mime: string, opts: { swapRb
202
279
  }
203
280
  }
204
281
 
282
+ /** The constant-valued pins of a plain Material (null for an instance or an unreadable package). */
283
+ const constantPins = (file: string): { base?: number[], metallic?: number, roughness?: number, emissive?: number[] } | null => {
284
+ let graph: MaterialGraph
285
+ try { graph = readMaterialGraph(readPackageFile(file)) } catch { return null }
286
+ const pin = (name: string): number[] | undefined => evaluateConstant(graph, graph.outputs[name]) ?? undefined
287
+ // an UNCONNECTED pin is the engine's default (Metallic 0, Roughness 0.5), not "unknown" — but a
288
+ // pin wired to something this cannot evaluate (a texture) stays undecided
289
+ const or = (name: string, dflt: number): number | undefined => (graph.outputs[name] ? pin(name)?.[0] : dflt)
290
+ return { base: pin("BaseColor"), metallic: or("Metallic", 0), roughness: or("Roughness", 0.5), emissive: pin("EmissiveColor") }
291
+ }
292
+
205
293
  /**
206
294
  * Put the pack's textures on a document's materials. The document must already carry one material
207
295
  * per mesh slot, named after the slot (that is what `unrealToDocument` builds).
@@ -263,12 +351,19 @@ export const applyUnrealTextures = async (doc: Document, meshPath: string, slots
263
351
  return { ...p, file, texture, role: classifyTexture(p.param, p.asset, texture?.compressionSettings) }
264
352
  })
265
353
 
354
+ // a material whose ONLY texture says nothing about its role (`Param`, `Texture` — this pack's
355
+ // eyes and eyelashes) is a base colour: there is nothing else it could be, and the alternative
356
+ // is a grey slot. A name that reads as a mask or a map glTF has no slot for is still left out.
357
+ const lone = resolved.filter((p) => p.file && !NOT_A_MAP.test(p.param) && !NOT_A_MAP.test(p.asset))
358
+ const onlyBase = lone.length === 1 && lone[0].role === "other" ? lone[0] : null
359
+
266
360
  for (const role of [ "base", "normal", "orm" ] as const) {
267
361
  const pick = resolved
268
362
  .filter((p) => p.file)
269
363
  .map((p) => ({ p, score: scoreTexture(role, p.param, p.asset, p.texture?.compressionSettings) }))
270
364
  .filter((c) => c.score > 0)
271
365
  .sort((a, b) => b.score - a.score)[0]?.p
366
+ ?? (role === "base" ? onlyBase : null)
272
367
  if (!pick) continue
273
368
  const tex = await loadTexture(pick.path, role, pick.texture, pick.file!)
274
369
  if (!tex) continue
@@ -283,7 +378,20 @@ export const applyUnrealTextures = async (doc: Document, meshPath: string, slots
283
378
  }
284
379
  row.roles.push(role)
285
380
  }
286
- row.skipped = resolved.filter((p) => p.role === "other").map((p) => `${p.param || p.asset}`)
381
+ // A pin no texture reached gets what the graph computes when it is all NUMBERS — a pack whose
382
+ // materials carry no textures at all (KINEMATION's FPS Animation Pack weapons: `M_Finish` is a
383
+ // VectorParameter colour, a Constant metallic and a Constant roughness) otherwise came out as 14
384
+ // identical grey slots. Only a plain Material has a graph; an instance keeps its defaults.
385
+ const pins = constantPins(matFile)
386
+ if (pins) {
387
+ if (pins.base && !row.roles.includes("base")) { material.setBaseColorFactor([ pins.base[0], pins.base[1] ?? pins.base[0], pins.base[2] ?? pins.base[0], 1 ]); row.roles.push("base=const") }
388
+ if (!row.roles.includes("orm")) {
389
+ if (pins.metallic !== undefined) { material.setMetallicFactor(Math.min(1, Math.max(0, pins.metallic))); row.roles.push("metallic=const") }
390
+ if (pins.roughness !== undefined) { material.setRoughnessFactor(Math.min(1, Math.max(0, pins.roughness))); row.roles.push("roughness=const") }
391
+ }
392
+ if (pins.emissive && pins.emissive.some((c) => c > 0)) { material.setEmissiveFactor([ pins.emissive[0], pins.emissive[1] ?? pins.emissive[0], pins.emissive[2] ?? pins.emissive[0] ].map((c) => Math.min(1, c)) as [ number, number, number ]); row.roles.push("emissive=const") }
393
+ }
394
+ row.skipped = resolved.filter((p) => p.role === "other" && p !== onlyBase).map((p) => `${p.param || p.asset}`)
287
395
  }
288
396
  if (report.oodleMissing) warn({ level: "info", message: `textures skipped — no Oodle decoder (${findOodle() ? "found the library but could not call it" : "set LECODES_OODLE"})` })
289
397
  void classOf
@@ -7,13 +7,16 @@
7
7
  //
8
8
  // Only the uncooked editor form is handled, for UE 5.0 … 5.7 (`FileVersionUE5` 1004–1018, see
9
9
  // `versions.ts`). UE4 packages carry float FTransforms and a different export layout and are refused
10
- // with a message rather than guessed at, as is anything newer than the last verified version.
10
+ // with a message rather than guessed at, as is anything newer than the last verified version — unless
11
+ // the caller opts in (`{ ue4: true }`): the summary, the tables and the tag list are version-gated
12
+ // field by field and read UE 4.25+ files as they are, so a texture's source image or a sound's WAV
13
+ // comes out of a UE4 pack the same way. Meshes and clips stay UE5-only.
11
14
  //
12
15
  // See docs/unreal-assets-plan.md §3 — every layout here was verified byte-for-byte against real
13
16
  // files from packs saved by 5.0, 5.3, 5.4, 5.5, 5.6 and 5.7.
14
17
 
15
18
  import { inflateSync } from "node:zlib"
16
- import { UE4, UE5, LEGACY, UE5_VERSION_MIN, UE5_VERSION_MAX, releaseOfVersion } from "./versions"
19
+ import { UE4, UE5, LEGACY, UE5_VERSION_MIN, UE5_VERSION_MAX, UE4_VERSION_MIN, releaseOfVersion } from "./versions"
17
20
 
18
21
  export { UE5_VERSION_MIN, UE5_VERSION_MAX } from "./versions"
19
22
 
@@ -190,9 +193,21 @@ export const readSummary = (bytes: Uint8Array, label: string): UeSummary => {
190
193
  export const engineLabel = (s: UeSummary): string =>
191
194
  s.savedBy && s.savedBy.major ? `UE ${s.savedBy.major}.${s.savedBy.minor}${s.savedBy.patch ? `.${s.savedBy.patch}` : ""}` : s.ue5 ? `UE ${releaseOfVersion(s.ue5)} (version ${s.ue5})` : `UE? (ue4 ${s.ue4}, ue5 ${s.ue5})`
192
195
 
193
- const checkVersion = (s: UeSummary, label: string): void => {
196
+ export type ReadPackageOptions = {
197
+ /**
198
+ * Accept a UE4 package (legacy −7, `FileVersionUE4` ≥ `UE4_VERSION_MIN`). The tables and the tag
199
+ * list read the same way there; what differs is class payloads — float transforms, mesh and clip
200
+ * layouts — so only readers that do not depend on those opt in (texture sources, sounds).
201
+ */
202
+ ue4?: boolean
203
+ }
204
+
205
+ const checkVersion = (s: UeSummary, label: string, opts: ReadPackageOptions): void => {
194
206
  if (s.legacy > LEGACY.UE5 || s.ue5 === 0) {
195
- throw new Error(`${label}: a UE4 package (legacy ${s.legacy}) — only UE 5.0–5.7 uncooked assets are supported. Export the asset to FBX from the editor instead.`)
207
+ if (!opts.ue4) throw new Error(`${label}: a UE4 package (legacy ${s.legacy}) — only UE 5.0–5.7 uncooked assets are supported. Export the asset to FBX from the editor instead.`)
208
+ if (s.ue4 < UE4_VERSION_MIN) throw new Error(`${label}: a UE4 package of version ${s.ue4} — older than any this reader has been verified on (${UE4_VERSION_MIN}, UE 4.25)`)
209
+ if ((s.packageFlags & PKG_FILTER_EDITOR_ONLY) !== 0) throw new Error(`${label}: a COOKED package (editor data stripped) — this reader needs the uncooked editor asset from a pack's Content folder`)
210
+ return
196
211
  }
197
212
  if (s.ue5 < UE5_VERSION_MIN) throw new Error(`${label}: package version ${s.ue5} predates UE 5.0 — not supported`)
198
213
  if (s.ue5 > UE5_VERSION_MAX) {
@@ -298,9 +313,9 @@ export type UePackage = {
298
313
  exports: UeExport[]
299
314
  }
300
315
 
301
- export const readPackage = (bytes: Uint8Array, label = "package"): UePackage => {
316
+ export const readPackage = (bytes: Uint8Array, label = "package", opts: ReadPackageOptions = {}): UePackage => {
302
317
  const summary = readSummary(bytes, label)
303
- checkVersion(summary, label)
318
+ checkVersion(summary, label, opts)
304
319
  const names = readNames(bytes, summary, label)
305
320
  const imports = readImports(bytes, summary, names, label)
306
321
  const exports = readExports(bytes, summary, names, label)
@@ -450,9 +465,10 @@ const skipTagExtension = (r: BinaryReader): void => {
450
465
  * type TREE, size, one flags byte that says which of array index / guid / extensions follow and
451
466
  * carries the bool value itself.
452
467
  */
453
- export const readPropertyTag = (r: BinaryReader, pkg: { names: string[], summary: { ue5: number } }): PropertyTag | null => {
468
+ export const readPropertyTag = (r: BinaryReader, pkg: { names: string[], summary: { ue4?: number, ue5: number } }): PropertyTag | null => {
454
469
  const { names } = pkg
455
470
  const ue5 = pkg.summary.ue5
471
+ const ue4 = pkg.summary.ue4 ?? UE4.FROZEN
456
472
  const name = readFName(r, names)
457
473
  if (name === "None") return null
458
474
  let tag: PropertyTag
@@ -477,7 +493,7 @@ export const readPropertyTag = (r: BinaryReader, pkg: { names: string[], summary
477
493
  else if (type === "ByteProperty" || type === "EnumProperty") extras.enumName = readFName(r, names)
478
494
  else if (type === "ArrayProperty" || type === "SetProperty" || type === "OptionalProperty") extras.inner = readFName(r, names)
479
495
  else if (type === "MapProperty") { extras.keyType = readFName(r, names); extras.valueType = readFName(r, names) }
480
- if (r.u8()) r.skip(16) // optional property guid
496
+ if (ue4 >= UE4.PROPERTY_GUID_IN_PROPERTY_TAG && r.u8()) r.skip(16) // optional property guid
481
497
  if (ue5 >= UE5.PROPERTY_TAG_EXTENSION_AND_OVERRIDABLE_SERIALIZATION) skipTagExtension(r)
482
498
  tag = { name, type, typeName: liftTypeName(type, extras), size, arrayIndex, data: 0, ...extras }
483
499
  if (bool !== undefined) tag.bool = bool
@@ -501,7 +517,7 @@ export type Properties = {
501
517
  * class default are OMITTED by the serializer — absent is not zero, so every reader below treats a
502
518
  * missing tag as "the engine's default", never as 0.
503
519
  */
504
- export const readProperties = (r: BinaryReader, pkg: { names: string[], summary: { ue5: number } }): Properties => {
520
+ export const readProperties = (r: BinaryReader, pkg: { names: string[], summary: { ue4?: number, ue5: number } }): Properties => {
505
521
  const list: PropertyTag[] = []
506
522
  for (;;) {
507
523
  const tag = readPropertyTag(r, pkg)
@@ -557,16 +573,18 @@ export const NATIVE_STRUCT_SIZE: Record<string, number> = {
557
573
  * already carries the element type and the inner tag is gone: the elements follow the count directly.
558
574
  * `elementStruct` is the struct's name either way.
559
575
  */
560
- export const readArrayHeader = (r: BinaryReader, pkg: { names: string[], summary: { ue5: number } }, tag: PropertyTag): { count: number, inner: PropertyTag | null, elementStruct: string } => {
576
+ export const readArrayHeader = (r: BinaryReader, pkg: { names: string[], summary: { ue4?: number, ue5: number } }, tag: PropertyTag): { count: number, inner: PropertyTag | null, elementStruct: string } => {
561
577
  r.offset = tag.data
562
578
  const count = r.i32()
563
- const inner = tag.inner === "StructProperty" && pkg.summary.ue5 < UE5.PROPERTY_TAG_COMPLETE_TYPE_NAME ? readPropertyTag(r, pkg) : null
579
+ // a struct array's payload opens with an inner tag only from `INNER_ARRAY_TAG_INFO` (UE 4.12)
580
+ const innerTag = pkg.summary.ue5 < UE5.PROPERTY_TAG_COMPLETE_TYPE_NAME && (pkg.summary.ue4 ?? UE4.FROZEN) >= UE4.INNER_ARRAY_TAG_INFO
581
+ const inner = tag.inner === "StructProperty" && innerTag ? readPropertyTag(r, pkg) : null
564
582
  const elementStruct = inner?.struct ?? tag.typeName.params[0]?.params[0]?.name ?? ""
565
583
  return { count, inner, elementStruct }
566
584
  }
567
585
 
568
586
  /** `n` float triples/quads straight out of an `ArrayProperty<Vector3f | Quat4f>` payload. */
569
- export const readFloatArray = (r: BinaryReader, pkg: { names: string[], summary: { ue5: number } }, tag: PropertyTag): { comps: number, values: Float32Array } => {
587
+ export const readFloatArray = (r: BinaryReader, pkg: { names: string[], summary: { ue4?: number, ue5: number } }, tag: PropertyTag): { comps: number, values: Float32Array } => {
570
588
  const { count, elementStruct } = readArrayHeader(r, pkg, tag)
571
589
  const struct = elementStruct || tag.inner || ""
572
590
  const comps = struct === "Quat4f" || struct === "Quat" || struct === "Vector4f" ? 4 : struct === "Vector2f" ? 2 : 3
@@ -8,11 +8,13 @@ import { dirname, join, resolve, sep } from "node:path"
8
8
 
9
9
  export const GAME_PREFIX = "/Game/"
10
10
 
11
- /** `/Game/A/B/Name` → `A/B/Name.uasset`. */
11
+ /** `/Game/A/B/Name` → `A/B/Name.uasset`. A PLUGIN's content mounts at `/<PluginName>/` instead of
12
+ * `/Game/` (KINEMATION's FPS Animation Pack: `/FPSAnimationPack/Meshes/…/SK_Mannequin`), and its
13
+ * Content folder on disk is just as unmarked — so any mount point is stripped the same way. */
12
14
  export const objectPathToFile = (objectPath: string): string | null => {
13
- if (!objectPath.startsWith(GAME_PREFIX)) return null
14
- const rel = objectPath.slice(GAME_PREFIX.length)
15
- return rel ? `${rel.replace(/\//g, sep)}.uasset` : null
15
+ const m = /^\/[^/]+\/(.+)$/.exec(objectPath)
16
+ if (!m) return null
17
+ return `${m[1].replace(/\//g, sep)}.uasset`
16
18
  }
17
19
 
18
20
  export type ResolveResult = { file: string, contentRoot: string } | null
@@ -0,0 +1,128 @@
1
+ // SoundWave → the WAV the artist imported. An uncooked `SoundWave` keeps its source audio whole
2
+ // (`RawData`, an editor bulk payload — not the platform-cooked Vorbis/ADPCM a packaged game
3
+ // ships), wrapped in the same Oodle `FCompressedBuffer` a texture's source image lives in, so it
4
+ // is found and decoded the same way (`texture.ts`). What comes out is a complete RIFF/WAVE file,
5
+ // PCM, exactly as imported — 48 kHz 16-bit mono for the engine packs seen; stereo where the pack
6
+ // author kept it.
7
+ //
8
+ // `--ogg` hands the WAV to ffmpeg (on PATH, or `LECODES_FFMPEG`) for a mono Vorbis file, which is
9
+ // what a game clip wants: `Sound.load` decodes mono by default anyway, and Vorbis is a tenth of
10
+ // the size. Loop points are not touched — the packs' loops are cut to the sample already.
11
+
12
+ import { basename, join } from "node:path"
13
+ import { writeFileSync, mkdirSync, unlinkSync } from "node:fs"
14
+ import { spawnSync } from "node:child_process"
15
+ import { classOf, findExport, readObjectProperties, propInt, propFloat, propName, type UePackage } from "./package"
16
+ import { findCompressedBuffer, decodeCompressedBuffer } from "./texture"
17
+
18
+ export type UeSoundWave = {
19
+ name: string
20
+ /** `NumChannels`, `SampleRate`, `Duration` as the asset declares them (0 when absent). */
21
+ channels: number
22
+ sampleRate: number
23
+ duration: number
24
+ /** `SoundGroup` / `bLooping` when present. */
25
+ looping: boolean
26
+ }
27
+
28
+ /** The `SoundWave` export's own facts (no payload work). */
29
+ export const readSoundWave = (pkg: UePackage): UeSoundWave => {
30
+ const found = findExport(pkg, "SoundWave")
31
+ if (!found) throw new Error(`${pkg.label}: no SoundWave export (${pkg.exports.map((e) => classOf(pkg, e)).join(", ")})`)
32
+ const { reader: r, props } = readObjectProperties(pkg, found.export)
33
+ const one = (k: string) => props.byName.get(k)
34
+ return {
35
+ name: found.export.objectName,
36
+ channels: propInt(r, one("NumChannels")) ?? 0,
37
+ sampleRate: propInt(r, one("SampleRate")) ?? 0,
38
+ duration: propFloat(r, one("Duration")) ?? 0,
39
+ looping: one("bLooping")?.bool ?? false,
40
+ }
41
+ }
42
+
43
+ export type WavInfo = { channels: number, sampleRate: number, bits: number, frames: number, duration: number }
44
+
45
+ /** The fmt/data facts of a RIFF/WAVE file — a sanity check on what came out of the buffer. */
46
+ export const readWavInfo = (wav: Uint8Array): WavInfo => {
47
+ const tag = String.fromCharCode(...wav.subarray(0, 4)), wave = String.fromCharCode(...wav.subarray(8, 12))
48
+ if (tag !== "RIFF" || wave !== "WAVE") throw new Error(`payload is not a RIFF/WAVE file (${tag} ${wave})`)
49
+ const dv = new DataView(wav.buffer, wav.byteOffset, wav.byteLength)
50
+ let o = 12, fmt: { channels: number, sampleRate: number, bits: number } | null = null, data = 0
51
+ while (o + 8 <= wav.byteLength) {
52
+ const id = String.fromCharCode(...wav.subarray(o, o + 4)), size = dv.getUint32(o + 4, true)
53
+ if (id === "fmt ") fmt = { channels: dv.getUint16(o + 10, true), sampleRate: dv.getUint32(o + 12, true), bits: dv.getUint16(o + 22, true) }
54
+ if (id === "data") data = size
55
+ o += 8 + size + (size & 1)
56
+ }
57
+ if (!fmt) throw new Error("WAV has no fmt chunk")
58
+ const frames = data / Math.max(1, fmt.channels * (fmt.bits / 8))
59
+ return { ...fmt, frames, duration: frames / fmt.sampleRate }
60
+ }
61
+
62
+ /** The imported WAV out of a SoundWave asset. Needs Oodle unless the buffer is stored plain. */
63
+ export const extractSound = async (pkg: UePackage): Promise<{ wave: UeSoundWave, wav: Uint8Array, info: WavInfo }> => {
64
+ const wave = readSoundWave(pkg)
65
+ const buf = findCompressedBuffer(pkg)
66
+ if (!buf) throw new Error(`${pkg.label}: no FCompressedBuffer after the exports — a cooked package, or the audio lives in a .ubulk`)
67
+ const wav = await decodeCompressedBuffer(pkg, buf)
68
+ return { wave, wav, info: readWavInfo(wav) }
69
+ }
70
+
71
+ /** Where ffmpeg is: `LECODES_FFMPEG`, else PATH. null when it cannot be run. */
72
+ export const findFfmpeg = (): string | null => {
73
+ const exe = process.env.LECODES_FFMPEG || "ffmpeg"
74
+ const p = spawnSync(exe, [ "-version" ], { encoding: "utf8" })
75
+ return p.status === 0 ? exe : null
76
+ }
77
+
78
+ export type OggOptions = {
79
+ /** Downmix to one channel (default true — what a game clip wants; `Sound.load` is mono anyway). */
80
+ mono?: boolean
81
+ /** libvorbis quality −1..10 (default 5 ≈ 160 kb/s stereo, ~80 mono). */
82
+ quality?: number
83
+ }
84
+
85
+ /** WAV bytes → an OGG Vorbis file on disk through ffmpeg. Returns the written size. */
86
+ export const wavToOgg = (ffmpeg: string, wav: Uint8Array, outPath: string, o: OggOptions = {}): number => {
87
+ const tmp = outPath + ".tmp.wav"
88
+ writeFileSync(tmp, wav)
89
+ try {
90
+ const args = [ "-hide_banner", "-loglevel", "error", "-y", "-i", tmp ]
91
+ if (o.mono !== false) args.push("-ac", "1")
92
+ args.push("-c:a", "libvorbis", "-q:a", String(o.quality ?? 5), outPath)
93
+ const p = spawnSync(ffmpeg, args, { encoding: "utf8" })
94
+ if (p.status !== 0) throw new Error(`ffmpeg failed on ${basename(outPath)}: ${(p.stderr || p.stdout || "").trim()}`)
95
+ } finally {
96
+ try { unlinkSync(tmp) } catch { /* nothing to clean */ }
97
+ }
98
+ return Bun.file(outPath).size
99
+ }
100
+
101
+ export type SoundExportRow = { name: string, out: string, info: WavInfo, bytes: number }
102
+
103
+ /**
104
+ * Every SoundWave under `inputs` (files or folders) → `outDir/<name>.wav` (or `.ogg`). Non-sound
105
+ * packages (SoundCue, MetaSound…) are skipped silently — a pack's `Cue` folder sits next to its
106
+ * `Sound` folder. Throws on the first sound that cannot be decoded.
107
+ */
108
+ export const exportSounds = async (
109
+ files: string[], outDir: string,
110
+ opts: { ogg?: OggOptions | false, readPackage: (path: string) => UePackage, log?: (s: string) => void },
111
+ ): Promise<SoundExportRow[]> => {
112
+ mkdirSync(outDir, { recursive: true })
113
+ const ffmpeg = opts.ogg ? findFfmpeg() : null
114
+ if (opts.ogg && !ffmpeg) throw new Error("--ogg needs ffmpeg on PATH (or LECODES_FFMPEG=<path to ffmpeg>)")
115
+ const rows: SoundExportRow[] = []
116
+ for (const f of files) {
117
+ const pkg = opts.readPackage(f)
118
+ if (!findExport(pkg, "SoundWave")) continue
119
+ const { wave, wav, info } = await extractSound(pkg)
120
+ const out = join(outDir, wave.name + (ffmpeg ? ".ogg" : ".wav"))
121
+ let bytes = wav.byteLength
122
+ if (ffmpeg) bytes = wavToOgg(ffmpeg, wav, out, opts.ogg || {})
123
+ else writeFileSync(out, wav)
124
+ rows.push({ name: wave.name, out, info, bytes })
125
+ opts.log?.(` ${wave.name.padEnd(28)} ${info.channels}ch ${info.sampleRate} Hz ${info.bits}-bit ${info.duration.toFixed(3)} s → ${(bytes / 1024).toFixed(0)} KB`)
126
+ }
127
+ return rows
128
+ }
@@ -11,7 +11,7 @@
11
11
  // the package trailer): same container either way, so it is found by its magic at or after the
12
12
  // bulk-data start rather than through the trailer's own table of contents.
13
13
 
14
- import { BinaryReader, findExport, readProperties, readObjectProperties, propInt, propEnum, propString, type UePackage } from "./package"
14
+ import { BinaryReader, findExport, readProperties, readObjectProperties, readBulkHeader, readBulkPayload, propInt, propEnum, propString, type UePackage } from "./package"
15
15
  import { getOodle, OODLE_MISSING_MESSAGE, findOodle, OODLE_NO_RUNTIME_MESSAGE } from "./oodle"
16
16
 
17
17
  /** `b7 75 63 62` — `FCompressedBuffer`'s magic, big-endian like the rest of its header. */
@@ -178,14 +178,81 @@ const imageAt = (payload: Uint8Array): { at: number, mimeType: string } | null =
178
178
  return null
179
179
  }
180
180
 
181
+ /** The image's own size, so a signature found in the bulk region can be checked against the asset. */
182
+ const pngHeader = (b: Uint8Array, at: number): { width: number, height: number } | null => {
183
+ const dv = new DataView(b.buffer, b.byteOffset, b.byteLength)
184
+ if (at + 24 > b.byteLength) return null
185
+ if (String.fromCharCode(b[at + 12], b[at + 13], b[at + 14], b[at + 15]) !== "IHDR") return null
186
+ return { width: dv.getUint32(at + 16), height: dv.getUint32(at + 20) }
187
+ }
188
+
189
+ /** `IEND` + its CRC ends a PNG; bytes after it (the trailer's own tail) are not part of the image. */
190
+ const pngEnd = (b: Uint8Array, at: number): number => {
191
+ for (let i = at; i + 8 <= b.byteLength; i++) {
192
+ if (b[i] === 0x49 && b[i + 1] === 0x45 && b[i + 2] === 0x4e && b[i + 3] === 0x44) return i + 8
193
+ }
194
+ return b.byteLength
195
+ }
196
+
197
+ /**
198
+ * Some assets keep the source image in the package trailer WITHOUT the `FCompressedBuffer` wrapper —
199
+ * the payload is stored, so what sits there is the PNG file itself (this pack's gloves, eyes and a
200
+ * few masks). Found by its signature in the bulk region and only accepted when its IHDR agrees with
201
+ * the size the asset reports, so a stray signature cannot be mistaken for the source.
202
+ */
203
+ const findRawSource = (pkg: UePackage, texture: UeTexture): ExtractedImage | null => {
204
+ const b = pkg.bytes
205
+ const start = Math.max(0, Math.min(pkg.summary.bulkDataStartOffset, b.byteLength))
206
+ for (let i = start; i + 24 <= b.byteLength; i++) {
207
+ if (!startsWith(b, i, PNG_SIG)) continue
208
+ const head = pngHeader(b, i)
209
+ if (!head) continue
210
+ if (texture.source.width && (head.width !== texture.source.width || head.height !== texture.source.height)) continue
211
+ return {
212
+ data: b.subarray(i, pngEnd(b, i)),
213
+ mimeType: "image/png",
214
+ swapRb: /^TSF_BGRA8/i.test(texture.source.format),
215
+ texture,
216
+ }
217
+ }
218
+ return null
219
+ }
220
+
221
+ /**
222
+ * UE4 keeps the source in a plain `FByteBulkData` that `UTexture::Serialize` writes right after the
223
+ * object's tag list — UObject's has-guid flag (a 4-byte bool, + the guid), `FStripDataFlags` (two
224
+ * bytes), then the bulk header. The payload is UE's zlib-chunked container (`inflateChunked`), and
225
+ * what it holds is the PNG/JPEG the artist imported, like the UE5 buffer's.
226
+ */
227
+ const ue4Source = (pkg: UePackage, texture: UeTexture): ExtractedImage => {
228
+ const found = findExport(pkg, "Texture2D")!
229
+ const { reader: r, props } = readObjectProperties(pkg, found.export)
230
+ r.offset = props.end
231
+ if (r.i32()) r.skip(16) // UObject: its guid, when it has one
232
+ const strip = r.u8(); r.u8() // FStripDataFlags: global, class
233
+ if (strip & 0x1) throw new Error(`${pkg.label}: the editor data is stripped (a cooked texture) — no source image to read`)
234
+ const payload = readBulkPayload(pkg, readBulkHeader(r))
235
+ const img = imageAt(payload)
236
+ if (!img) {
237
+ throw new Error(`${pkg.label}: the UE4 source is raw ${texture.source.format || "pixels"} (${texture.source.width}×${texture.source.height}), not a PNG/JPEG — not supported yet`)
238
+ }
239
+ return { data: payload.subarray(img.at), mimeType: img.mimeType, swapRb: /^TSF_BGRA8/i.test(texture.source.format), texture }
240
+ }
241
+
181
242
  /**
182
243
  * The image inside a texture asset. Throws with the Oodle message when the decoder is missing —
183
- * callers that can live without textures should catch it and say so once.
244
+ * callers that can live without textures should catch it and say so once. A UE4 package (read with
245
+ * `{ ue4: true }`) takes the plain bulk-data path instead.
184
246
  */
185
247
  export const extractTexture = async (pkg: UePackage): Promise<ExtractedImage> => {
186
248
  const texture = readTexture2D(pkg)
249
+ if (pkg.summary.ue5 === 0) return ue4Source(pkg, texture)
187
250
  const buf = findCompressedBuffer(pkg)
188
- if (!buf) throw new Error(`${pkg.label}: no source image payload (a texture with its source stripped?)`)
251
+ if (!buf) {
252
+ const raw = findRawSource(pkg, texture)
253
+ if (raw) return raw
254
+ throw new Error(`${pkg.label}: no source image payload (a texture with its source stripped?)`)
255
+ }
189
256
  const payload = await decodeCompressedBuffer(pkg, buf)
190
257
  const img = imageAt(payload)
191
258
  if (!img) {
@@ -193,6 +193,7 @@ export const unrealToDocument = (input: UnrealDocumentInput, opts: UnrealDocumen
193
193
 
194
194
  let flippedWinding = false
195
195
  let droppedWeight = 0
196
+ let unweighted = 0
196
197
 
197
198
  // ---- mesh + skin ----
198
199
  if (mesh) {
@@ -218,12 +219,18 @@ export const unrealToDocument = (input: UnrealDocumentInput, opts: UnrealDocumen
218
219
  const total = list.reduce((n, i) => n + i.weight, 0)
219
220
  const kept = keep.reduce((n, i) => n + i.weight, 0)
220
221
  if (total > 0) droppedWeight += total - kept
222
+ // A point with NO influence is bound to the ROOT bone — what UE's importer does with every
223
+ // unweighted vertex. Left at weight 0 it skins to the origin: the FPS Animation Pack's MX16A4
224
+ // keeps its whole barrel and handguard unweighted (14 088 of 15 696 points in one section),
225
+ // and those collapsed triangles took the desktop host down inside the next UI draw.
226
+ if (kept <= 0) { pointJoints[p * MAX_JOINTS_PER_VERTEX] = 0; pointWeights[p * MAX_JOINTS_PER_VERTEX] = 1; unweighted++; continue }
221
227
  const norm = kept > 0 ? 1 / kept : 0
222
228
  keep.forEach((inf, k) => {
223
229
  pointJoints[p * MAX_JOINTS_PER_VERTEX + k] = inf.bone >= 0 && inf.bone < joints.length ? inf.bone : 0
224
230
  pointWeights[p * MAX_JOINTS_PER_VERTEX + k] = inf.weight * norm
225
231
  })
226
232
  }
233
+ if (unweighted) warn({ level: "info", message: `mesh '${mesh.name}': ${unweighted} of ${perPoint.length} points carry no skin weight — bound to the root bone '${mesh.bones[0]?.name ?? "?"}', as UE's importer does` })
227
234
 
228
235
  // normals: the per-corner TangentZ of every face using a wedge, accumulated
229
236
  const wedgeCount = mesh.wedgePoint.length
@@ -60,7 +60,10 @@ export const UE4 = {
60
60
  PACKAGE_SUMMARY_HAS_COMPATIBLE_ENGINE_VERSION: 444,
61
61
  SERIALIZE_TEXT_IN_PACKAGES: 459,
62
62
  COOKED_ASSETS_IN_EDITOR_SUPPORT: 485,
63
+ /** A struct array's payload opens with an inner tag naming the struct. */
63
64
  INNER_ARRAY_TAG_INFO: 500,
65
+ /** A property tag may carry a property guid (a has-guid byte after the extras). */
66
+ PROPERTY_GUID_IN_PROPERTY_TAG: 503,
64
67
  NAME_HASHES_SERIALIZED: 504,
65
68
  PRELOAD_DEPENDENCIES_IN_COOKED_EXPORTS: 507,
66
69
  TEMPLATE_INDEX_IN_COOKED_EXPORTS: 508,
@@ -83,6 +86,11 @@ export const LEGACY = {
83
86
  UE5_6: -9,
84
87
  } as const
85
88
 
89
+ /** The oldest `FileVersionUE4` a UE4 package may carry when a reader opts in (`{ ue4: true }`):
90
+ * `ADDED_PACKAGE_OWNER`, UE 4.25 — the "Bullet Impact VFX" pack, whose "UE_5.0" download is 4.25
91
+ * packages throughout. */
92
+ export const UE4_VERSION_MIN = 459
93
+
86
94
  /** The newest `FileVersionUE5` this reader has verified against real files. */
87
95
  export const UE5_VERSION_MAX = UE5.IMPORT_TYPE_HIERARCHIES
88
96
  /** The oldest — UE 5.0. Earlier UE5 previews are refused. */