lecodes-assets 0.2.0 → 2.4.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +738 -449
- package/dist/cli.js +19760 -8612
- package/dist/index.js +19334 -8167
- package/package.json +15 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- 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 -0
- 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 +305 -0
- package/src/doctor/lod.ts +177 -0
- 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/normalizeScale.ts +220 -0
- package/src/doctor/reduceGlbBones.ts +218 -0
- package/src/doctor/shading.ts +95 -0
- package/src/doctor/transcodeKtx2.ts +275 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/doctor/vertexAo.ts +321 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +111 -0
- package/src/fbx/toDocument.ts +227 -0
- package/src/filmstrip.ts +263 -0
- package/src/geometry.ts +222 -0
- package/src/index.ts +19 -0
- package/src/mergeSkins.ts +117 -0
- package/src/probe.ts +167 -0
- package/src/props.ts +137 -0
- package/src/retarget/automap.ts +276 -0
- package/src/retarget/ghost.ts +23 -0
- package/src/retarget/heading.ts +238 -0
- package/src/retarget/humanoid.ts +572 -0
- package/src/retarget/inPlace.ts +130 -0
- package/src/retarget/marks.ts +581 -0
- package/src/retarget/mirror.ts +0 -0
- package/src/retarget/retarget.ts +2375 -0
- package/src/retarget/rig.ts +545 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +134 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +200 -0
- package/src/terrainPack.ts +208 -0
- package/src/textures/resolveImage.ts +202 -0
- 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 +198 -0
- package/src/unity/math.ts +82 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabTree.ts +285 -0
- package/src/unity/prefabs.ts +54 -0
- package/src/unity/propertyPath.ts +43 -0
- package/src/unity/scene.ts +384 -0
- 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 +149 -0
- package/src/unity/yaml.ts +131 -0
- package/src/unreal/anim.ts +284 -0
- package/src/unreal/clipSource.ts +25 -0
- package/src/unreal/convert.ts +67 -0
- package/src/unreal/convertAsset.ts +81 -0
- package/src/unreal/index.ts +115 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materialGraph.ts +224 -0
- package/src/unreal/materials.ts +399 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +677 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +53 -0
- package/src/unreal/rig.ts +149 -0
- package/src/unreal/sequencer.ts +193 -0
- package/src/unreal/skeleton.ts +74 -0
- package/src/unreal/sound.ts +128 -0
- package/src/unreal/texture.ts +269 -0
- package/src/unreal/toDocument.ts +367 -0
- package/src/unreal/versions.ts +108 -0
- package/src/unwrap/index.ts +167 -0
- package/src/unwrap/lightmapUv.ts +761 -0
- package/src/wasm/fbxReader.js +14 -0
- package/src/wasm/lightmapUv.js +14 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// Texture2D → the SOURCE image the artist imported. An uncooked asset keeps it whole (not the
|
|
2
|
+
// compressed BCn mips a cooked build ships), wrapped in an `FCompressedBuffer`: a big-endian header
|
|
3
|
+
// followed by fixed-size blocks, Oodle-compressed (`oodle.ts` loads the decoder).
|
|
4
|
+
//
|
|
5
|
+
// What comes out is usually a PNG or JPEG file, ready to embed in a GLB as it is. One catch: UE
|
|
6
|
+
// writes a `TSF_BGRA8` source into that PNG straight from BGRA memory, so its red and blue channels
|
|
7
|
+
// are swapped — `FTextureSource.Format` says when, and the swap needs a decode/encode round trip
|
|
8
|
+
// (`../textures/resolveImage`).
|
|
9
|
+
//
|
|
10
|
+
// Verified on UE 5.0 (buffer right after the exports, `payloadTocOffset = -1`) and UE 5.3 (inside
|
|
11
|
+
// the package trailer): same container either way, so it is found by its magic at or after the
|
|
12
|
+
// bulk-data start rather than through the trailer's own table of contents.
|
|
13
|
+
|
|
14
|
+
import { BinaryReader, findExport, readProperties, readObjectProperties, readBulkHeader, readBulkPayload, propInt, propEnum, propString, type UePackage } from "./package"
|
|
15
|
+
import { getOodle, OODLE_MISSING_MESSAGE, findOodle, OODLE_NO_RUNTIME_MESSAGE } from "./oodle"
|
|
16
|
+
|
|
17
|
+
/** `b7 75 63 62` — `FCompressedBuffer`'s magic, big-endian like the rest of its header. */
|
|
18
|
+
const COMPRESSED_BUFFER_MAGIC = [ 0xb7, 0x75, 0x63, 0x62 ]
|
|
19
|
+
const HEADER_SIZE = 64 // magic + crc + 4 bytes + blockCount + rawSize + compSize + a 32-byte hash
|
|
20
|
+
|
|
21
|
+
export type CompressedBuffer = {
|
|
22
|
+
offset: number
|
|
23
|
+
/** 0 = stored, 3 = Oodle (the only two seen); anything else is refused by name. */
|
|
24
|
+
method: number
|
|
25
|
+
compressor: number
|
|
26
|
+
blockSize: number
|
|
27
|
+
rawSize: number
|
|
28
|
+
compressedSize: number
|
|
29
|
+
blocks: { offset: number, size: number, rawSize: number }[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The header at `offset`, or null when the bytes there are not one. */
|
|
33
|
+
export const readCompressedBuffer = (bytes: Uint8Array, offset: number): CompressedBuffer | null => {
|
|
34
|
+
if (offset + HEADER_SIZE > bytes.byteLength) return null
|
|
35
|
+
for (let i = 0; i < 4; i++) if (bytes[offset + i] !== COMPRESSED_BUFFER_MAGIC[i]) return null
|
|
36
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
37
|
+
const method = bytes[offset + 8], compressor = bytes[offset + 9], blockExp = bytes[offset + 11]
|
|
38
|
+
const blockCount = dv.getUint32(offset + 12)
|
|
39
|
+
const rawSize = Number(dv.getBigUint64(offset + 16))
|
|
40
|
+
const compressedSize = Number(dv.getBigUint64(offset + 24))
|
|
41
|
+
if (blockExp < 8 || blockExp > 30 || blockCount > 1 << 20 || rawSize < 0) return null
|
|
42
|
+
const blockSize = 1 << blockExp
|
|
43
|
+
if (method !== 0 && blockCount !== Math.ceil(rawSize / blockSize)) return null
|
|
44
|
+
const blocks: CompressedBuffer["blocks"] = []
|
|
45
|
+
let o = offset + HEADER_SIZE + (method === 0 ? 0 : blockCount * 4)
|
|
46
|
+
if (method === 0) {
|
|
47
|
+
blocks.push({ offset: offset + HEADER_SIZE, size: rawSize, rawSize })
|
|
48
|
+
} else {
|
|
49
|
+
let written = 0
|
|
50
|
+
for (let i = 0; i < blockCount; i++) {
|
|
51
|
+
const size = dv.getUint32(offset + HEADER_SIZE + i * 4)
|
|
52
|
+
const want = Math.min(blockSize, rawSize - written)
|
|
53
|
+
blocks.push({ offset: o, size, rawSize: want })
|
|
54
|
+
o += size
|
|
55
|
+
written += want
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (o > bytes.byteLength) return null
|
|
59
|
+
return { offset, method, compressor, blockSize, rawSize, compressedSize, blocks }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The first `FCompressedBuffer` at or after `from` (default: where a package's bulk data starts). */
|
|
63
|
+
export const findCompressedBuffer = (pkg: UePackage, from = pkg.summary.bulkDataStartOffset): CompressedBuffer | null => {
|
|
64
|
+
const bytes = pkg.bytes
|
|
65
|
+
const start = Math.max(0, Math.min(from, bytes.byteLength))
|
|
66
|
+
for (let i = start; i + HEADER_SIZE <= bytes.byteLength; i++) {
|
|
67
|
+
if (bytes[i] !== COMPRESSED_BUFFER_MAGIC[0]) continue
|
|
68
|
+
const buf = readCompressedBuffer(bytes, i)
|
|
69
|
+
if (buf) return buf
|
|
70
|
+
}
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Decompress a buffer's blocks. Needs the Oodle decoder unless every block is stored. */
|
|
75
|
+
export const decodeCompressedBuffer = async (pkg: UePackage, buf: CompressedBuffer): Promise<Uint8Array> => {
|
|
76
|
+
const out = new Uint8Array(buf.rawSize)
|
|
77
|
+
const needsOodle = buf.method !== 0 && buf.blocks.some((b) => b.size !== b.rawSize)
|
|
78
|
+
const oodle = needsOodle ? await getOodle() : null
|
|
79
|
+
if (needsOodle && !oodle) throw new Error(findOodle() ? OODLE_NO_RUNTIME_MESSAGE : OODLE_MISSING_MESSAGE)
|
|
80
|
+
if (buf.method !== 0 && buf.method !== 3) throw new Error(`${pkg.label}: compressed buffer method ${buf.method} is not supported (0 = stored, 3 = Oodle)`)
|
|
81
|
+
let written = 0
|
|
82
|
+
for (const b of buf.blocks) {
|
|
83
|
+
const src = pkg.bytes.subarray(b.offset, b.offset + b.size)
|
|
84
|
+
if (src.byteLength !== b.size) throw new Error(`${pkg.label}: compressed block at ${b.offset} runs past the file`)
|
|
85
|
+
out.set(b.size === b.rawSize ? src : oodle!.decompress(src, b.rawSize), written)
|
|
86
|
+
written += b.rawSize
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---- the texture asset ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
/** `ETextureSourceFormat` values that matter here; anything else is reported, not guessed at. */
|
|
94
|
+
export type UeTextureSource = {
|
|
95
|
+
width: number
|
|
96
|
+
height: number
|
|
97
|
+
mips: number
|
|
98
|
+
slices: number
|
|
99
|
+
/** `TSF_BGRA8`, `TSF_G8`, `TSF_RGBA16F`, … (absent in a few 5.0 assets → ""). */
|
|
100
|
+
format: string
|
|
101
|
+
/** `TSCF_PNG` / `TSCF_JPEG` / `TSCF_None`; UE 5.0 says the same thing with `bPNGCompressed`. */
|
|
102
|
+
compression: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type UeTexture = {
|
|
106
|
+
name: string
|
|
107
|
+
source: UeTextureSource
|
|
108
|
+
/** `TC_Default`, `TC_Normalmap`, `TC_Masks`, … — what the material does with it. */
|
|
109
|
+
compressionSettings: string
|
|
110
|
+
srgb: boolean
|
|
111
|
+
/** The imported file's own path, when the asset still remembers it. */
|
|
112
|
+
sourceFile: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The `Texture2D` export's own facts (no payload work). */
|
|
116
|
+
export const readTexture2D = (pkg: UePackage): UeTexture => {
|
|
117
|
+
const found = findExport(pkg, "Texture2D")
|
|
118
|
+
if (!found) throw new Error(`${pkg.label}: no Texture2D export`)
|
|
119
|
+
const { reader: r, props } = readObjectProperties(pkg, found.export)
|
|
120
|
+
const srcTag = props.byName.get("Source")
|
|
121
|
+
const source: UeTextureSource = { width: 0, height: 0, mips: 1, slices: 1, format: "", compression: "" }
|
|
122
|
+
if (srcTag) {
|
|
123
|
+
r.offset = srcTag.data
|
|
124
|
+
const s = readProperties(r, pkg)
|
|
125
|
+
source.width = propInt(r, s.byName.get("SizeX")) ?? 0
|
|
126
|
+
source.height = propInt(r, s.byName.get("SizeY")) ?? 0
|
|
127
|
+
source.mips = propInt(r, s.byName.get("NumMips")) ?? 1
|
|
128
|
+
source.slices = propInt(r, s.byName.get("NumSlices")) ?? 1
|
|
129
|
+
source.format = propEnum(r, pkg.names, s.byName.get("Format")) ?? ""
|
|
130
|
+
source.compression = propEnum(r, pkg.names, s.byName.get("CompressionFormat"))
|
|
131
|
+
?? (s.byName.get("bPNGCompressed")?.bool ? "TSCF_PNG" : "")
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
name: found.export.objectName,
|
|
135
|
+
source,
|
|
136
|
+
compressionSettings: propEnum(r, pkg.names, props.byName.get("CompressionSettings")) ?? "TC_Default",
|
|
137
|
+
srgb: props.byName.get("SRGB")?.bool ?? true,
|
|
138
|
+
sourceFile: sourceFileOf(pkg, r, props),
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** `AssetImportData`'s JSON blob keeps the file the texture was imported from; nice for reports. */
|
|
143
|
+
const sourceFileOf = (pkg: UePackage, r: BinaryReader, props: ReturnType<typeof readProperties>): string => {
|
|
144
|
+
try {
|
|
145
|
+
const tag = props.byName.get("AssetImportData")
|
|
146
|
+
if (!tag) return ""
|
|
147
|
+
const idx = propInt(r, tag) ?? 0
|
|
148
|
+
if (idx <= 0) return ""
|
|
149
|
+
const e = pkg.exports[idx - 1]
|
|
150
|
+
if (!e) return ""
|
|
151
|
+
const { reader: er, props: p } = readObjectProperties(pkg, e)
|
|
152
|
+
const json = propString(er, p.byName.get("SourceData")) ?? ""
|
|
153
|
+
const m = /"RelativeFilename"\s*:\s*"([^"]+)"/.exec(json)
|
|
154
|
+
return m ? m[1] : ""
|
|
155
|
+
} catch { return "" }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export type ExtractedImage = {
|
|
159
|
+
/** The source file as it sits in the asset (PNG/JPEG bytes), plus what it is. */
|
|
160
|
+
data: Uint8Array
|
|
161
|
+
mimeType: string
|
|
162
|
+
/** True when the channels are B,G,R and a red/blue swap is owed (`TSF_BGRA8`). */
|
|
163
|
+
swapRb: boolean
|
|
164
|
+
texture: UeTexture
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const startsWith = (b: Uint8Array, at: number, sig: number[]): boolean => sig.every((v, i) => b[at + i] === v)
|
|
168
|
+
const PNG_SIG = [ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a ]
|
|
169
|
+
const JPEG_SIG = [ 0xff, 0xd8, 0xff ]
|
|
170
|
+
|
|
171
|
+
/** Where the image file starts inside a decompressed payload (UE writes it verbatim). */
|
|
172
|
+
const imageAt = (payload: Uint8Array): { at: number, mimeType: string } | null => {
|
|
173
|
+
const limit = Math.min(payload.byteLength, 4096)
|
|
174
|
+
for (let i = 0; i < limit; i++) {
|
|
175
|
+
if (startsWith(payload, i, PNG_SIG)) return { at: i, mimeType: "image/png" }
|
|
176
|
+
if (startsWith(payload, i, JPEG_SIG)) return { at: i, mimeType: "image/jpeg" }
|
|
177
|
+
}
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
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
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The image inside a texture asset. Throws with the Oodle message when the decoder is missing —
|
|
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.
|
|
246
|
+
*/
|
|
247
|
+
export const extractTexture = async (pkg: UePackage): Promise<ExtractedImage> => {
|
|
248
|
+
const texture = readTexture2D(pkg)
|
|
249
|
+
if (pkg.summary.ue5 === 0) return ue4Source(pkg, texture)
|
|
250
|
+
const buf = findCompressedBuffer(pkg)
|
|
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
|
+
}
|
|
256
|
+
const payload = await decodeCompressedBuffer(pkg, buf)
|
|
257
|
+
const img = imageAt(payload)
|
|
258
|
+
if (!img) {
|
|
259
|
+
throw new Error(`${pkg.label}: the payload is raw ${texture.source.format || "pixels"} (${texture.source.width}×${texture.source.height}), not a PNG/JPEG — not supported yet`)
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
data: payload.subarray(img.at),
|
|
263
|
+
mimeType: img.mimeType,
|
|
264
|
+
// UE fills a TSF_BGRA8 source straight from BGRA memory, so the file's R and B are the other
|
|
265
|
+
// way round. Formats that name their order (RGBA8, G8, …) need no swap.
|
|
266
|
+
swapRb: /^TSF_BGRA8/i.test(texture.source.format),
|
|
267
|
+
texture,
|
|
268
|
+
}
|
|
269
|
+
}
|