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.
- package/README.md +203 -26
- package/dist/cli.js +17948 -11911
- package/dist/index.js +17525 -11479
- package/package.json +3 -1
- package/src/cli-kit/command.ts +100 -0
- package/src/cli-kit/help.ts +72 -0
- package/src/cli-kit/index.ts +4 -0
- package/src/cli-kit/parse.ts +72 -0
- package/src/cli-kit/run.ts +52 -0
- package/src/cli.ts +24 -1039
- package/src/commands/avatar.ts +77 -0
- package/src/commands/bvh.ts +26 -0
- package/src/commands/convert.ts +156 -0
- package/src/commands/doctor.ts +67 -0
- package/src/commands/geometry.ts +45 -0
- package/src/commands/index.ts +24 -0
- package/src/commands/marks.ts +38 -0
- package/src/commands/mirror.ts +35 -0
- package/src/commands/probe.ts +43 -0
- package/src/commands/retarget/donor.ts +63 -0
- package/src/commands/retarget/index.ts +169 -0
- package/src/commands/retarget/map.ts +81 -0
- package/src/commands/retarget/report.ts +77 -0
- package/src/commands/scene.ts +103 -0
- package/src/commands/shared.ts +121 -0
- package/src/commands/sky.ts +35 -0
- package/src/commands/terrainPack.ts +40 -0
- package/src/commands/unpack.ts +43 -0
- package/src/commands/unreal.ts +109 -0
- package/src/commands/unwrap.ts +56 -0
- package/src/doctor/cardTrim.ts +286 -0
- package/src/doctor/doctor.ts +94 -30
- package/src/doctor/merge/accessors.ts +30 -0
- package/src/doctor/merge/atlasLayout.ts +94 -0
- package/src/doctor/merge/atlasPixels.ts +114 -0
- package/src/doctor/merge/bindSpace.ts +59 -0
- package/src/doctor/merge/classify.ts +136 -0
- package/src/doctor/merge/geometry.ts +167 -0
- package/src/doctor/merge/index.ts +98 -0
- package/src/doctor/merge/matrix.ts +60 -0
- package/src/doctor/merge/plan.ts +88 -0
- package/src/doctor/merge/textures.ts +60 -0
- package/src/doctor/merge/types.ts +77 -0
- package/src/doctor/transcodeKtx2.ts +179 -28
- package/src/doctor/validateGlb.ts +2 -2
- package/src/doctor/vertexAo.ts +321 -0
- package/src/fbx/read.ts +2 -0
- package/src/fbx/toDocument.ts +227 -209
- package/src/geometry.ts +222 -0
- package/src/index.ts +2 -0
- package/src/retarget/heading.ts +238 -171
- package/src/retarget/humanoid.ts +572 -561
- package/src/retarget/retarget.ts +2375 -2272
- package/src/retarget/rig.ts +4 -1
- package/src/retarget/writeMarks.ts +134 -132
- package/src/sky/cmgen.ts +3 -3
- package/src/sky/index.ts +57 -8
- package/src/terrainPack.ts +61 -22
- package/src/unity/colliderFit.ts +287 -0
- package/src/unity/fbxModel.ts +194 -0
- package/src/unity/foliage.ts +63 -0
- package/src/unity/gameObjects.ts +81 -0
- package/src/unity/guid.ts +104 -0
- package/src/unity/image.ts +58 -0
- package/src/unity/kindGlb.ts +364 -0
- package/src/unity/lecodesLevel.ts +381 -0
- package/src/unity/levelTerrain.ts +116 -0
- package/src/unity/matFile.ts +179 -0
- package/src/unity/materialSlots.ts +53 -0
- package/src/unity/materialWriter.ts +306 -0
- package/src/unity/materials.ts +34 -429
- package/src/unity/math.ts +82 -0
- package/src/unity/prefabTree.ts +285 -0
- package/src/unity/prefabs.ts +10 -36
- package/src/unity/propertyPath.ts +43 -0
- package/src/unity/scene.ts +325 -304
- package/src/unity/serialized.ts +242 -0
- package/src/unity/terrain.ts +221 -0
- package/src/unity/triBudget.ts +77 -0
- package/src/unity/triplanar.ts +122 -0
- package/src/unity/unityMesh.ts +151 -0
- package/src/unity/unpack.ts +24 -5
- package/src/unity/yaml.ts +131 -0
- package/src/unreal/index.ts +6 -5
- package/src/unreal/materialGraph.ts +224 -0
- package/src/unreal/materials.ts +117 -9
- package/src/unreal/package.ts +30 -12
- package/src/unreal/resolve.ts +6 -4
- package/src/unreal/sound.ts +128 -0
- package/src/unreal/texture.ts +70 -3
- package/src/unreal/toDocument.ts +7 -0
- package/src/unreal/versions.ts +8 -0
- package/src/unwrap/index.ts +167 -0
- package/src/unwrap/lightmapUv.ts +761 -0
- package/src/wasm/fbxReader.js +1 -1
- package/src/wasm/lightmapUv.js +1 -1
- package/src/doctor/lightmapUv.ts +0 -452
package/src/unreal/materials.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
package/src/unreal/package.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
package/src/unreal/resolve.ts
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
return
|
|
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
|
+
}
|
package/src/unreal/texture.ts
CHANGED
|
@@ -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)
|
|
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) {
|
package/src/unreal/toDocument.ts
CHANGED
|
@@ -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
|
package/src/unreal/versions.ts
CHANGED
|
@@ -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. */
|