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,677 @@
|
|
|
1
|
+
// Reader for UNCOOKED Unreal Engine 5 editor packages (`.uasset`: one file per asset, everything
|
|
2
|
+
// inline — no `.uexp`/`.ubulk`/`.pak`). What an asset-store pack's Content folder is made of.
|
|
3
|
+
//
|
|
4
|
+
// A package = summary + name table + import table (references to other packages/classes) + export
|
|
5
|
+
// table (the objects in this file) + the exports' serialized bytes + bulk data + a trailing tag.
|
|
6
|
+
// Object references are PACKAGE INDICES: > 0 → export n−1, < 0 → import −n−1, 0 → null.
|
|
7
|
+
//
|
|
8
|
+
// Only the uncooked editor form is handled, for UE 5.0 … 5.7 (`FileVersionUE5` 1004–1018, see
|
|
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 — 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.
|
|
14
|
+
//
|
|
15
|
+
// See docs/unreal-assets-plan.md §3 — every layout here was verified byte-for-byte against real
|
|
16
|
+
// files from packs saved by 5.0, 5.3, 5.4, 5.5, 5.6 and 5.7.
|
|
17
|
+
|
|
18
|
+
import { inflateSync } from "node:zlib"
|
|
19
|
+
import { UE4, UE5, LEGACY, UE5_VERSION_MIN, UE5_VERSION_MAX, UE4_VERSION_MIN, releaseOfVersion } from "./versions"
|
|
20
|
+
|
|
21
|
+
export { UE5_VERSION_MIN, UE5_VERSION_MAX } from "./versions"
|
|
22
|
+
|
|
23
|
+
export const PACKAGE_FILE_TAG = 0x9e2a83c1
|
|
24
|
+
|
|
25
|
+
const PKG_FILTER_EDITOR_ONLY = 0x80000000
|
|
26
|
+
|
|
27
|
+
// ---- binary reader ------------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
export class BinaryReader {
|
|
30
|
+
readonly data: Uint8Array
|
|
31
|
+
readonly view: DataView
|
|
32
|
+
offset: number
|
|
33
|
+
/** Named in error messages so a malformed file says WHERE it broke. */
|
|
34
|
+
label: string
|
|
35
|
+
|
|
36
|
+
constructor(data: Uint8Array, offset = 0, label = "package") {
|
|
37
|
+
this.data = data
|
|
38
|
+
this.view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
|
39
|
+
this.offset = offset
|
|
40
|
+
this.label = label
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get remaining(): number { return this.data.byteLength - this.offset }
|
|
44
|
+
|
|
45
|
+
private need(n: number): void {
|
|
46
|
+
if (n < 0 || this.offset + n > this.data.byteLength) {
|
|
47
|
+
throw new Error(`${this.label}: read of ${n} bytes at offset ${this.offset} runs past the end (${this.data.byteLength})`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
u8(): number { this.need(1); return this.data[this.offset++] }
|
|
51
|
+
u16(): number { this.need(2); const v = this.view.getUint16(this.offset, true); this.offset += 2; return v }
|
|
52
|
+
i32(): number { this.need(4); const v = this.view.getInt32(this.offset, true); this.offset += 4; return v }
|
|
53
|
+
u32(): number { this.need(4); const v = this.view.getUint32(this.offset, true); this.offset += 4; return v }
|
|
54
|
+
f32(): number { this.need(4); const v = this.view.getFloat32(this.offset, true); this.offset += 4; return v }
|
|
55
|
+
f64(): number { this.need(8); const v = this.view.getFloat64(this.offset, true); this.offset += 8; return v }
|
|
56
|
+
i64(): number {
|
|
57
|
+
this.need(8)
|
|
58
|
+
const v = this.view.getBigInt64(this.offset, true)
|
|
59
|
+
this.offset += 8
|
|
60
|
+
if (v > 9007199254740991n || v < -9007199254740991n) throw new Error(`${this.label}: 64-bit value ${v} at ${this.offset - 8} is out of the safe integer range`)
|
|
61
|
+
return Number(v)
|
|
62
|
+
}
|
|
63
|
+
raw(n: number): Uint8Array { this.need(n); const v = this.data.subarray(this.offset, this.offset + n); this.offset += n; return v }
|
|
64
|
+
skip(n: number): void { this.need(n); this.offset += n }
|
|
65
|
+
/** `f32` × n as a plain array (small fixed-size records: transforms, keys). */
|
|
66
|
+
floats(n: number): number[] { const out = new Array<number>(n); for (let i = 0; i < n; i++) out[i] = this.f32(); return out }
|
|
67
|
+
doubles(n: number): number[] { const out = new Array<number>(n); for (let i = 0; i < n; i++) out[i] = this.f64(); return out }
|
|
68
|
+
|
|
69
|
+
/** FString: `i32 len`; len > 0 → Latin-1 bytes incl. trailing NUL; len < 0 → UTF-16LE units incl. NUL. */
|
|
70
|
+
fstring(): string {
|
|
71
|
+
const n = this.i32()
|
|
72
|
+
if (n === 0) return ""
|
|
73
|
+
if (n < 0) {
|
|
74
|
+
const units = -n
|
|
75
|
+
if (units > 1 << 22) throw new Error(`${this.label}: implausible UTF-16 string length ${units} at ${this.offset - 4}`)
|
|
76
|
+
const b = this.raw(units * 2)
|
|
77
|
+
let s = ""
|
|
78
|
+
for (let i = 0; i < units; i++) s += String.fromCharCode(b[i * 2] | (b[i * 2 + 1] << 8))
|
|
79
|
+
return s.replace(/\0+$/, "")
|
|
80
|
+
}
|
|
81
|
+
if (n > 1 << 22) throw new Error(`${this.label}: implausible string length ${n} at ${this.offset - 4}`)
|
|
82
|
+
const b = this.raw(n)
|
|
83
|
+
let s = ""
|
|
84
|
+
for (let i = 0; i < n; i++) s += String.fromCharCode(b[i])
|
|
85
|
+
return s.replace(/\0+$/, "")
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---- summary ------------------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
export type EngineVersion = { major: number, minor: number, patch: number, changelist: number, branch: string }
|
|
92
|
+
|
|
93
|
+
export type UeSummary = {
|
|
94
|
+
legacy: number
|
|
95
|
+
/** `FileVersionUE4` — 522 in every UE5 file seen. */
|
|
96
|
+
ue4: number
|
|
97
|
+
/** `FileVersionUE5` — 1004 (5.0) … 1018 (5.7); 0 for UE4 packages. */
|
|
98
|
+
ue5: number
|
|
99
|
+
totalHeaderSize: number
|
|
100
|
+
packageName: string
|
|
101
|
+
packageFlags: number
|
|
102
|
+
nameCount: number
|
|
103
|
+
nameOffset: number
|
|
104
|
+
exportCount: number
|
|
105
|
+
exportOffset: number
|
|
106
|
+
importCount: number
|
|
107
|
+
importOffset: number
|
|
108
|
+
/** Base for every bulk payload offset (§3.6). */
|
|
109
|
+
bulkDataStartOffset: number
|
|
110
|
+
/** UE 5.2+ package trailer (textures live there); −1 when absent. */
|
|
111
|
+
payloadTocOffset: number
|
|
112
|
+
/** UE 5.3+ bulk/data resource table; 0 when the package has none. */
|
|
113
|
+
dataResourceOffset: number
|
|
114
|
+
savedBy?: EngineVersion
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* `FPackageFileSummary`, field by field (versions gate the layout — never a fixed stride).
|
|
119
|
+
*
|
|
120
|
+
* Two shapes of head exist: up to UE 5.5 the custom versions come right after the licensee version
|
|
121
|
+
* and the header size after them; from `PACKAGE_SAVED_HASH` (5.6, which also bumped the legacy
|
|
122
|
+
* version to −9) a 20-byte saved hash and the header size are written BEFORE the custom versions,
|
|
123
|
+
* and the guid further down is gone. The UE3 field is still written at −9.
|
|
124
|
+
*/
|
|
125
|
+
export const readSummary = (bytes: Uint8Array, label: string): UeSummary => {
|
|
126
|
+
const r = new BinaryReader(bytes, 0, label)
|
|
127
|
+
const tag = r.u32()
|
|
128
|
+
if (tag !== PACKAGE_FILE_TAG) throw new Error(`${label}: not a .uasset (tag 0x${tag.toString(16)})`)
|
|
129
|
+
const legacy = r.i32()
|
|
130
|
+
// The legacy version fixes the shape of everything after it. Guessing past an unknown one reads
|
|
131
|
+
// garbage (a 5.6 file decoded as an "implausible string length" before −9 was known), so stop here.
|
|
132
|
+
if (legacy < LEGACY.UE5_6) throw new Error(`${label}: package format ${legacy} is newer than any this reader knows (UE 5.7 = ${LEGACY.UE5_6}) — re-save the asset from UE 5.7 or earlier, or export FBX.`)
|
|
133
|
+
if (legacy > -6) throw new Error(`${label}: package format ${legacy} predates UE4 — not supported`)
|
|
134
|
+
if (legacy !== -4) r.i32() // legacy UE3 file version
|
|
135
|
+
const ue4 = r.i32()
|
|
136
|
+
const ue5 = legacy <= LEGACY.UE5 ? r.i32() : 0
|
|
137
|
+
r.i32() // licensee version
|
|
138
|
+
let totalHeaderSize = 0
|
|
139
|
+
if (ue5 >= UE5.PACKAGE_SAVED_HASH) { r.skip(20); totalHeaderSize = r.i32() } // saved hash (FIoHash)
|
|
140
|
+
const nCustom = r.i32()
|
|
141
|
+
for (let i = 0; i < nCustom; i++) { r.skip(16); r.i32() }
|
|
142
|
+
if (ue5 < UE5.PACKAGE_SAVED_HASH) totalHeaderSize = r.i32()
|
|
143
|
+
const packageName = r.fstring()
|
|
144
|
+
const packageFlags = r.u32()
|
|
145
|
+
const editorOnly = (packageFlags & PKG_FILTER_EDITOR_ONLY) !== 0
|
|
146
|
+
const nameCount = r.i32(), nameOffset = r.i32()
|
|
147
|
+
if (ue5 >= UE5.ADD_SOFTOBJECTPATH_LIST) { r.i32(); r.i32() } // soft object paths count/offset
|
|
148
|
+
if (!editorOnly && ue4 >= UE4.ADDED_PACKAGE_SUMMARY_LOCALIZATION_ID) r.fstring()
|
|
149
|
+
if (ue4 >= UE4.SERIALIZE_TEXT_IN_PACKAGES) { r.i32(); r.i32() } // gatherable text count/offset
|
|
150
|
+
const exportCount = r.i32(), exportOffset = r.i32()
|
|
151
|
+
const importCount = r.i32(), importOffset = r.i32()
|
|
152
|
+
if (ue5 >= UE5.VERSE_CELLS) r.skip(16) // cell export/import count + offset
|
|
153
|
+
if (ue5 >= UE5.METADATA_SERIALIZATION_OFFSET) r.i32() // metadata offset
|
|
154
|
+
r.i32() // depends offset
|
|
155
|
+
if (ue4 >= UE4.ADD_STRING_ASSET_REFERENCES_MAP) { r.i32(); r.i32() } // soft package references
|
|
156
|
+
if (ue4 >= UE4.ADDED_SEARCHABLE_NAMES) r.i32() // searchable names offset
|
|
157
|
+
r.i32() // thumbnail table offset
|
|
158
|
+
if (ue5 >= UE5.IMPORT_TYPE_HIERARCHIES) { r.i32(); r.i32() } // import type hierarchies count/offset
|
|
159
|
+
if (ue5 < UE5.PACKAGE_SAVED_HASH) r.skip(16) // guid (replaced by the saved hash)
|
|
160
|
+
if (!editorOnly) {
|
|
161
|
+
if (ue4 >= UE4.ADDED_PACKAGE_OWNER) r.skip(16) // persistent guid
|
|
162
|
+
if (ue4 >= UE4.ADDED_PACKAGE_OWNER && ue4 < UE4.NON_OUTER_PACKAGE_IMPORT) r.skip(16) // owner persistent guid
|
|
163
|
+
}
|
|
164
|
+
const nGen = r.i32()
|
|
165
|
+
if (nGen < 0 || nGen > 1024) throw new Error(`${label}: implausible generation count ${nGen} at ${r.offset - 4} — the summary layout is not the one this reader expects`)
|
|
166
|
+
for (let i = 0; i < nGen; i++) { r.i32(); r.i32() }
|
|
167
|
+
const engver = (): EngineVersion => ({ major: r.u16(), minor: r.u16(), patch: r.u16(), changelist: r.u32(), branch: r.fstring() })
|
|
168
|
+
const savedBy = ue4 >= UE4.ENGINE_VERSION_OBJECT ? engver() : undefined
|
|
169
|
+
if (ue4 >= UE4.PACKAGE_SUMMARY_HAS_COMPATIBLE_ENGINE_VERSION) engver()
|
|
170
|
+
r.u32() // compression flags
|
|
171
|
+
const nChunks = r.i32()
|
|
172
|
+
if (nChunks !== 0) throw new Error(`${label}: package-level compression is not supported (${nChunks} chunks)`)
|
|
173
|
+
r.u32() // package source
|
|
174
|
+
const nAdditional = r.i32()
|
|
175
|
+
for (let i = 0; i < nAdditional; i++) r.fstring()
|
|
176
|
+
if (legacy > LEGACY.UE4) r.i32() // num texture allocations
|
|
177
|
+
r.i32() // asset registry data offset
|
|
178
|
+
const bulkDataStartOffset = r.i64()
|
|
179
|
+
r.i32() // world tile info offset
|
|
180
|
+
{ const n = r.i32(); for (let i = 0; i < n; i++) r.i32() } // chunk ids
|
|
181
|
+
if (ue4 >= UE4.PRELOAD_DEPENDENCIES_IN_COOKED_EXPORTS) { r.i32(); r.i32() }
|
|
182
|
+
if (ue5 >= UE5.NAMES_REFERENCED_FROM_EXPORT_DATA) r.i32()
|
|
183
|
+
const payloadTocOffset = ue5 >= UE5.PAYLOAD_TOC ? r.i64() : -1
|
|
184
|
+
const dataResourceOffset = ue5 >= UE5.DATA_RESOURCES ? r.i32() : 0
|
|
185
|
+
if (r.offset !== nameOffset) {
|
|
186
|
+
throw new Error(`${label}: the summary ends at ${r.offset} but the name table starts at ${nameOffset} — the summary layout of this ${engineLabel({ ue4, ue5, savedBy } as UeSummary)} file is not the one this reader expects`)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { legacy, ue4, ue5, totalHeaderSize, packageName, packageFlags, nameCount, nameOffset, exportCount, exportOffset, importCount, importOffset, bulkDataStartOffset, payloadTocOffset, dataResourceOffset, savedBy }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The engine that saved a package, as "UE 5.0" / "UE 5.4.4" (what the refusals talk about). */
|
|
193
|
+
export const engineLabel = (s: UeSummary): string =>
|
|
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})`
|
|
195
|
+
|
|
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 => {
|
|
206
|
+
if (s.legacy > LEGACY.UE5 || s.ue5 === 0) {
|
|
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
|
|
211
|
+
}
|
|
212
|
+
if (s.ue5 < UE5_VERSION_MIN) throw new Error(`${label}: package version ${s.ue5} predates UE 5.0 — not supported`)
|
|
213
|
+
if (s.ue5 > UE5_VERSION_MAX) {
|
|
214
|
+
throw new Error(`${label}: ${engineLabel(s)} package (version ${s.ue5}) is newer than any this reader has been verified on (UE 5.7 = ${UE5_VERSION_MAX}). Re-save the asset from UE 5.7 or earlier, or export FBX.`)
|
|
215
|
+
}
|
|
216
|
+
if ((s.packageFlags & PKG_FILTER_EDITOR_ONLY) !== 0) {
|
|
217
|
+
throw new Error(`${label}: a COOKED package (editor data stripped) — this reader needs the uncooked editor asset from a pack's Content folder`)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---- name / import / export tables ---------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
export type UeImport = { classPackage: string, className: string, outerIndex: number, objectName: string, packageName: string }
|
|
224
|
+
export type UeExport = {
|
|
225
|
+
classIndex: number, superIndex: number, templateIndex: number, outerIndex: number
|
|
226
|
+
objectName: string, objectFlags: number
|
|
227
|
+
serialSize: number, serialOffset: number
|
|
228
|
+
/** `bIsAsset` — the export the package is named for (the AnimSequence, not its data model). */
|
|
229
|
+
isAsset: boolean
|
|
230
|
+
/**
|
|
231
|
+
* Where the tagged properties sit inside the export's bytes (UE 5.4+, `SCRIPT_SERIALIZATION_OFFSET`),
|
|
232
|
+
* relative to `serialOffset`; −1 when the file predates them. Some classes write native data
|
|
233
|
+
* BEFORE their tag list (`UAssetImportData`'s JSON), which is what these offsets are for.
|
|
234
|
+
*/
|
|
235
|
+
scriptStart: number
|
|
236
|
+
scriptEnd: number
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const readNames = (bytes: Uint8Array, s: UeSummary, label: string): string[] => {
|
|
240
|
+
const r = new BinaryReader(bytes, s.nameOffset, label)
|
|
241
|
+
const out: string[] = new Array(s.nameCount)
|
|
242
|
+
for (let i = 0; i < s.nameCount; i++) {
|
|
243
|
+
out[i] = r.fstring()
|
|
244
|
+
if (s.ue4 >= UE4.NAME_HASHES_SERIALIZED) r.u32() // two u16 hashes
|
|
245
|
+
}
|
|
246
|
+
return out
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** FName: `i32 index, i32 number`; number > 0 appends `_${number-1}` (`AnimationDataModel_0`). */
|
|
250
|
+
export const readFName = (r: BinaryReader, names: string[]): string => {
|
|
251
|
+
const i = r.i32(), n = r.i32()
|
|
252
|
+
if (i < 0 || i >= names.length) throw new Error(`${r.label}: name index ${i} out of range (${names.length}) at offset ${r.offset - 8}`)
|
|
253
|
+
return n > 0 ? `${names[i]}_${n - 1}` : names[i]
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const readImports = (bytes: Uint8Array, s: UeSummary, names: string[], label: string): UeImport[] => {
|
|
257
|
+
const r = new BinaryReader(bytes, s.importOffset, label)
|
|
258
|
+
const editorOnly = (s.packageFlags & PKG_FILTER_EDITOR_ONLY) !== 0
|
|
259
|
+
const out: UeImport[] = []
|
|
260
|
+
for (let i = 0; i < s.importCount; i++) {
|
|
261
|
+
const classPackage = readFName(r, names)
|
|
262
|
+
const className = readFName(r, names)
|
|
263
|
+
const outerIndex = r.i32()
|
|
264
|
+
const objectName = readFName(r, names)
|
|
265
|
+
const packageName = s.ue4 >= UE4.NON_OUTER_PACKAGE_IMPORT && !editorOnly ? readFName(r, names) : ""
|
|
266
|
+
if (s.ue5 >= UE5.OPTIONAL_RESOURCES) r.i32() // bImportOptional
|
|
267
|
+
out.push({ classPackage, className, outerIndex, objectName, packageName })
|
|
268
|
+
}
|
|
269
|
+
if (r.offset !== s.exportOffset) throw new Error(`${label}: the import table ends at ${r.offset}, the export table starts at ${s.exportOffset} — import layout mismatch`)
|
|
270
|
+
return out
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const readExports = (bytes: Uint8Array, s: UeSummary, names: string[], label: string): UeExport[] => {
|
|
274
|
+
const r = new BinaryReader(bytes, s.exportOffset, label)
|
|
275
|
+
const out: UeExport[] = []
|
|
276
|
+
for (let i = 0; i < s.exportCount; i++) {
|
|
277
|
+
const classIndex = r.i32(), superIndex = r.i32()
|
|
278
|
+
const templateIndex = s.ue4 >= UE4.TEMPLATE_INDEX_IN_COOKED_EXPORTS ? r.i32() : 0
|
|
279
|
+
const outerIndex = r.i32()
|
|
280
|
+
const objectName = readFName(r, names)
|
|
281
|
+
const objectFlags = r.u32()
|
|
282
|
+
const serialSize = s.ue4 >= UE4.BIG_EXPORT_SIZE_FIXES ? r.i64() : r.i32()
|
|
283
|
+
const serialOffset = s.ue4 >= UE4.BIG_EXPORT_SIZE_FIXES ? r.i64() : r.i32()
|
|
284
|
+
r.i32(); r.i32(); r.i32() // bForcedExport / bNotForClient / bNotForServer
|
|
285
|
+
if (s.ue5 < UE5.REMOVE_OBJECT_EXPORT_PACKAGE_GUID) r.skip(16) // package guid
|
|
286
|
+
if (s.ue5 >= UE5.TRACK_OBJECT_EXPORT_IS_INHERITED) r.i32() // bIsInheritedInstance
|
|
287
|
+
r.u32() // package flags
|
|
288
|
+
r.i32() // bNotAlwaysLoadedForEditorGame
|
|
289
|
+
const isAsset = s.ue4 >= UE4.COOKED_ASSETS_IN_EDITOR_SUPPORT ? r.i32() !== 0 : false
|
|
290
|
+
if (s.ue5 >= UE5.OPTIONAL_RESOURCES) r.i32() // bGeneratePublicHash
|
|
291
|
+
if (s.ue4 >= UE4.PRELOAD_DEPENDENCIES_IN_COOKED_EXPORTS) r.skip(20) // 5 × dependency index/count
|
|
292
|
+
let scriptStart = -1, scriptEnd = -1
|
|
293
|
+
if (s.ue5 >= UE5.SCRIPT_SERIALIZATION_OFFSET) { scriptStart = r.i64(); scriptEnd = r.i64() }
|
|
294
|
+
if (serialOffset < 0 || serialSize < 0 || serialOffset + serialSize > bytes.byteLength) {
|
|
295
|
+
throw new Error(`${label}: export ${i} ("${objectName}") claims bytes [${serialOffset}, ${serialOffset + serialSize}) outside the file (${bytes.byteLength})`)
|
|
296
|
+
}
|
|
297
|
+
if (scriptStart >= 0 && (scriptStart > scriptEnd || scriptEnd > serialSize)) {
|
|
298
|
+
throw new Error(`${label}: export ${i} ("${objectName}") puts its properties at [${scriptStart}, ${scriptEnd}) of ${serialSize} bytes — export layout mismatch`)
|
|
299
|
+
}
|
|
300
|
+
out.push({ classIndex, superIndex, templateIndex, outerIndex, objectName, objectFlags, serialSize, serialOffset, isAsset, scriptStart, scriptEnd })
|
|
301
|
+
}
|
|
302
|
+
return out
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ---- the package --------------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
export type UePackage = {
|
|
308
|
+
label: string
|
|
309
|
+
bytes: Uint8Array
|
|
310
|
+
summary: UeSummary
|
|
311
|
+
names: string[]
|
|
312
|
+
imports: UeImport[]
|
|
313
|
+
exports: UeExport[]
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export const readPackage = (bytes: Uint8Array, label = "package", opts: ReadPackageOptions = {}): UePackage => {
|
|
317
|
+
const summary = readSummary(bytes, label)
|
|
318
|
+
checkVersion(summary, label, opts)
|
|
319
|
+
const names = readNames(bytes, summary, label)
|
|
320
|
+
const imports = readImports(bytes, summary, names, label)
|
|
321
|
+
const exports = readExports(bytes, summary, names, label)
|
|
322
|
+
return { label, bytes, summary, names, imports, exports }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Class name of an export ("AnimSequence", "SkeletalMesh", "AnimDataModel", …). */
|
|
326
|
+
export const classOf = (pkg: UePackage, e: UeExport): string => {
|
|
327
|
+
if (e.classIndex < 0) return pkg.imports[-e.classIndex - 1]?.objectName ?? "?"
|
|
328
|
+
if (e.classIndex > 0) return pkg.exports[e.classIndex - 1]?.objectName ?? "?"
|
|
329
|
+
return "Class"
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The first export of a class, or undefined. */
|
|
333
|
+
export const findExport = (pkg: UePackage, className: string): { index: number, export: UeExport } | undefined => {
|
|
334
|
+
for (let i = 0; i < pkg.exports.length; i++) if (classOf(pkg, pkg.exports[i]) === className) return { index: i, export: pkg.exports[i] }
|
|
335
|
+
return undefined
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The export the package IS — the one flagged as the asset (an AnimMontage, not the AnimDataModel
|
|
340
|
+
* sitting next to it); falls back to the first export that is not the metadata object.
|
|
341
|
+
*/
|
|
342
|
+
export const assetExport = (pkg: UePackage): UeExport | undefined =>
|
|
343
|
+
pkg.exports.find((e) => e.isAsset) ?? pkg.exports.find((e) => classOf(pkg, e) !== "MetaData") ?? pkg.exports[0]
|
|
344
|
+
|
|
345
|
+
/** A reader over one export's serialized bytes. Offsets stay FILE-absolute so bulk offsets line up. */
|
|
346
|
+
export const exportReader = (pkg: UePackage, e: UeExport): BinaryReader =>
|
|
347
|
+
new BinaryReader(pkg.bytes.subarray(0, e.serialOffset + e.serialSize), e.serialOffset, `${pkg.label}:${e.objectName}`)
|
|
348
|
+
|
|
349
|
+
export type UeObjectRef = { kind: "import" | "export", index: number, name: string, className: string, packagePath: string }
|
|
350
|
+
|
|
351
|
+
/** Resolve a package index (what an ObjectProperty carries) to what it points at. */
|
|
352
|
+
export const resolveObject = (pkg: UePackage, packageIndex: number): UeObjectRef | null => {
|
|
353
|
+
if (packageIndex === 0) return null
|
|
354
|
+
if (packageIndex > 0) {
|
|
355
|
+
const i = packageIndex - 1
|
|
356
|
+
const e = pkg.exports[i]
|
|
357
|
+
if (!e) return null
|
|
358
|
+
return { kind: "export", index: i, name: e.objectName, className: classOf(pkg, e), packagePath: pkg.summary.packageName }
|
|
359
|
+
}
|
|
360
|
+
const i = -packageIndex - 1
|
|
361
|
+
const imp = pkg.imports[i]
|
|
362
|
+
if (!imp) return null
|
|
363
|
+
return { kind: "import", index: i, name: imp.objectName, className: imp.className, packagePath: importPackagePath(pkg, i) }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Walk an import's outer chain to the `/Game/...` package it lives in. */
|
|
367
|
+
export const importPackagePath = (pkg: UePackage, index: number): string => {
|
|
368
|
+
let cur: UeImport | undefined = pkg.imports[index]
|
|
369
|
+
const seen = new Set<number>([ index ])
|
|
370
|
+
while (cur) {
|
|
371
|
+
if (cur.className === "Package" && cur.objectName.startsWith("/")) return cur.objectName
|
|
372
|
+
if (cur.packageName.startsWith("/")) return cur.packageName
|
|
373
|
+
if (cur.outerIndex >= 0) break
|
|
374
|
+
const next: number = -cur.outerIndex - 1
|
|
375
|
+
if (seen.has(next)) break
|
|
376
|
+
seen.add(next)
|
|
377
|
+
cur = pkg.imports[next]
|
|
378
|
+
}
|
|
379
|
+
return ""
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ---- tagged properties (§3.5) ---------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* A property's type as the UE 5.4+ tag writes it: a tree of names — `StructProperty` with the struct
|
|
386
|
+
* (`Vector3f`, itself carrying its package `/Script/CoreUObject`) as its parameter, `ArrayProperty`
|
|
387
|
+
* with its element type, `EnumProperty` with the enum and the underlying type. Older files are lifted
|
|
388
|
+
* into the same shape from their per-type extras, so readers see one representation.
|
|
389
|
+
*/
|
|
390
|
+
export type PropertyTypeName = { name: string, params: PropertyTypeName[] }
|
|
391
|
+
|
|
392
|
+
export type PropertyTag = {
|
|
393
|
+
name: string
|
|
394
|
+
/** Root of the type tree: `IntProperty`, `StructProperty`, `ArrayProperty`, … */
|
|
395
|
+
type: string
|
|
396
|
+
typeName: PropertyTypeName
|
|
397
|
+
size: number
|
|
398
|
+
arrayIndex: number
|
|
399
|
+
/** File offset of the payload (`size` bytes). */
|
|
400
|
+
data: number
|
|
401
|
+
/** StructProperty: the struct's name; Array/Set/OptionalProperty: the element type; Byte/EnumProperty: the enum. */
|
|
402
|
+
struct?: string
|
|
403
|
+
inner?: string
|
|
404
|
+
enumName?: string
|
|
405
|
+
keyType?: string
|
|
406
|
+
valueType?: string
|
|
407
|
+
/** BoolProperty carries its value in the tag itself. */
|
|
408
|
+
bool?: boolean
|
|
409
|
+
/** UE 5.4+ flags say the struct payload is native bytes rather than a tag list; undefined before. */
|
|
410
|
+
native?: boolean
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// EPropertyTagFlags (UE 5.4+, `PROPERTY_TAG_COMPLETE_TYPE_NAME`)
|
|
414
|
+
const TAG_HAS_ARRAY_INDEX = 0x01
|
|
415
|
+
const TAG_HAS_PROPERTY_GUID = 0x02
|
|
416
|
+
const TAG_HAS_PROPERTY_EXTENSIONS = 0x04
|
|
417
|
+
const TAG_HAS_BINARY_OR_NATIVE_SERIALIZE = 0x08
|
|
418
|
+
const TAG_BOOL_TRUE = 0x10
|
|
419
|
+
// EPropertyTagExtension
|
|
420
|
+
const TAG_EXT_OVERRIDABLE_INFORMATION = 0x02
|
|
421
|
+
// EClassSerializationControlExtension (the byte that opens an object's tag list, UE 5.4+)
|
|
422
|
+
const CLASS_EXT_OVERRIDABLE_INFORMATION = 0x02
|
|
423
|
+
|
|
424
|
+
/** `FPropertyTypeName`: nodes in pre-order, each an FName and how many parameters follow it. */
|
|
425
|
+
const readTypeName = (r: BinaryReader, names: string[], depth = 0): PropertyTypeName => {
|
|
426
|
+
const name = readFName(r, names)
|
|
427
|
+
const n = r.i32()
|
|
428
|
+
if (n < 0 || n > 16 || depth > 16) throw new Error(`${r.label}: property type "${name}" claims ${n} parameters at ${r.offset - 4} — not a type name`)
|
|
429
|
+
const params: PropertyTypeName[] = []
|
|
430
|
+
for (let i = 0; i < n; i++) params.push(readTypeName(r, names, depth + 1))
|
|
431
|
+
return { name, params }
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** The old per-type extras (struct name, inner type, enum) as the same tree the new files write. */
|
|
435
|
+
const liftTypeName = (type: string, extras: { struct?: string, inner?: string, enumName?: string, keyType?: string, valueType?: string }): PropertyTypeName => {
|
|
436
|
+
const leaf = (name?: string): PropertyTypeName[] => (name && name !== "None" ? [ { name, params: [] } ] : [])
|
|
437
|
+
if (type === "StructProperty") return { name: type, params: leaf(extras.struct) }
|
|
438
|
+
if (type === "ArrayProperty" || type === "SetProperty" || type === "OptionalProperty") return { name: type, params: leaf(extras.inner) }
|
|
439
|
+
if (type === "ByteProperty" || type === "EnumProperty") return { name: type, params: leaf(extras.enumName) }
|
|
440
|
+
if (type === "MapProperty") return { name: type, params: [ ...leaf(extras.keyType), ...leaf(extras.valueType) ] }
|
|
441
|
+
return { name: type, params: [] }
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Fill the convenience fields (`struct`, `inner`, …) from the tree. */
|
|
445
|
+
const deriveTypeFields = (tag: PropertyTag): void => {
|
|
446
|
+
const t = tag.typeName
|
|
447
|
+
const p0 = t.params[0]?.name
|
|
448
|
+
if (t.name === "StructProperty") tag.struct = p0
|
|
449
|
+
else if (t.name === "ArrayProperty" || t.name === "SetProperty" || t.name === "OptionalProperty") tag.inner = p0
|
|
450
|
+
else if (t.name === "ByteProperty" || t.name === "EnumProperty") tag.enumName = p0
|
|
451
|
+
else if (t.name === "MapProperty") { tag.keyType = p0; tag.valueType = t.params[1]?.name }
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** The extension byte both layouts may end with (UE 5.4+): overridable-serialization bookkeeping. */
|
|
455
|
+
const skipTagExtension = (r: BinaryReader): void => {
|
|
456
|
+
const ext = r.u8()
|
|
457
|
+
if (ext & TAG_EXT_OVERRIDABLE_INFORMATION) { r.u8(); r.u8() } // override operation + experimental flag
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* One `FPropertyTag`; null at the `None` that ends a list. Leaves the reader AT the payload.
|
|
462
|
+
*
|
|
463
|
+
* Two layouts. Up to UE 5.3 (and 5.4's early `1011`): type name, size, array index, per-type extras
|
|
464
|
+
* (struct name + guid, bool value, enum, inner types), an optional property guid. From `1012`: the
|
|
465
|
+
* type TREE, size, one flags byte that says which of array index / guid / extensions follow and
|
|
466
|
+
* carries the bool value itself.
|
|
467
|
+
*/
|
|
468
|
+
export const readPropertyTag = (r: BinaryReader, pkg: { names: string[], summary: { ue4?: number, ue5: number } }): PropertyTag | null => {
|
|
469
|
+
const { names } = pkg
|
|
470
|
+
const ue5 = pkg.summary.ue5
|
|
471
|
+
const ue4 = pkg.summary.ue4 ?? UE4.FROZEN
|
|
472
|
+
const name = readFName(r, names)
|
|
473
|
+
if (name === "None") return null
|
|
474
|
+
let tag: PropertyTag
|
|
475
|
+
if (ue5 >= UE5.PROPERTY_TAG_COMPLETE_TYPE_NAME) {
|
|
476
|
+
const typeName = readTypeName(r, names)
|
|
477
|
+
const size = r.i32()
|
|
478
|
+
const flags = r.u8()
|
|
479
|
+
tag = { name, type: typeName.name, typeName, size, arrayIndex: 0, data: 0, native: (flags & TAG_HAS_BINARY_OR_NATIVE_SERIALIZE) !== 0 }
|
|
480
|
+
if (flags & TAG_HAS_ARRAY_INDEX) tag.arrayIndex = r.i32()
|
|
481
|
+
if (flags & TAG_HAS_PROPERTY_GUID) r.skip(16)
|
|
482
|
+
if (flags & TAG_HAS_PROPERTY_EXTENSIONS) skipTagExtension(r)
|
|
483
|
+
if (typeName.name === "BoolProperty") tag.bool = (flags & TAG_BOOL_TRUE) !== 0
|
|
484
|
+
deriveTypeFields(tag)
|
|
485
|
+
} else {
|
|
486
|
+
const type = readFName(r, names)
|
|
487
|
+
const size = r.i32()
|
|
488
|
+
const arrayIndex = r.i32()
|
|
489
|
+
const extras: { struct?: string, inner?: string, enumName?: string, keyType?: string, valueType?: string } = {}
|
|
490
|
+
let bool: boolean | undefined
|
|
491
|
+
if (type === "StructProperty") { extras.struct = readFName(r, names); r.skip(16) }
|
|
492
|
+
else if (type === "BoolProperty") bool = r.u8() !== 0
|
|
493
|
+
else if (type === "ByteProperty" || type === "EnumProperty") extras.enumName = readFName(r, names)
|
|
494
|
+
else if (type === "ArrayProperty" || type === "SetProperty" || type === "OptionalProperty") extras.inner = readFName(r, names)
|
|
495
|
+
else if (type === "MapProperty") { extras.keyType = readFName(r, names); extras.valueType = readFName(r, names) }
|
|
496
|
+
if (ue4 >= UE4.PROPERTY_GUID_IN_PROPERTY_TAG && r.u8()) r.skip(16) // optional property guid
|
|
497
|
+
if (ue5 >= UE5.PROPERTY_TAG_EXTENSION_AND_OVERRIDABLE_SERIALIZATION) skipTagExtension(r)
|
|
498
|
+
tag = { name, type, typeName: liftTypeName(type, extras), size, arrayIndex, data: 0, ...extras }
|
|
499
|
+
if (bool !== undefined) tag.bool = bool
|
|
500
|
+
}
|
|
501
|
+
tag.data = r.offset
|
|
502
|
+
if (tag.size < 0 || tag.data + tag.size > r.data.byteLength) {
|
|
503
|
+
throw new Error(`${r.label}: property "${name}" (${tag.type}) claims ${tag.size} bytes at ${tag.data} — past the end (${r.data.byteLength})`)
|
|
504
|
+
}
|
|
505
|
+
return tag
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export type Properties = {
|
|
509
|
+
list: PropertyTag[]
|
|
510
|
+
byName: Map<string, PropertyTag>
|
|
511
|
+
/** Offset just past the terminating `None`. */
|
|
512
|
+
end: number
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* A whole tag list (a UObject's script properties, or a tagged struct's). Properties equal to their
|
|
517
|
+
* class default are OMITTED by the serializer — absent is not zero, so every reader below treats a
|
|
518
|
+
* missing tag as "the engine's default", never as 0.
|
|
519
|
+
*/
|
|
520
|
+
export const readProperties = (r: BinaryReader, pkg: { names: string[], summary: { ue4?: number, ue5: number } }): Properties => {
|
|
521
|
+
const list: PropertyTag[] = []
|
|
522
|
+
for (;;) {
|
|
523
|
+
const tag = readPropertyTag(r, pkg)
|
|
524
|
+
if (!tag) break
|
|
525
|
+
list.push(tag)
|
|
526
|
+
r.offset = tag.data + tag.size
|
|
527
|
+
}
|
|
528
|
+
const byName = new Map<string, PropertyTag>()
|
|
529
|
+
for (const t of list) if (!byName.has(t.name)) byName.set(t.name, t)
|
|
530
|
+
return { list, byName, end: r.offset }
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* An OBJECT's tag list — the entry point every class reader uses. Differs from a struct's list in
|
|
535
|
+
* two ways a caller should not have to know: since UE 5.4 the export table says where the properties
|
|
536
|
+
* start (a class may write native data first), and the list opens with a class-serialization-control
|
|
537
|
+
* byte. The reader is left just past the terminating `None`, on the object's native data.
|
|
538
|
+
*/
|
|
539
|
+
export const readObjectProperties = (pkg: UePackage, e: UeExport): { reader: BinaryReader, props: Properties } => {
|
|
540
|
+
const r = exportReader(pkg, e)
|
|
541
|
+
if (e.scriptStart >= 0) r.offset = e.serialOffset + e.scriptStart
|
|
542
|
+
if (pkg.summary.ue5 >= UE5.PROPERTY_TAG_EXTENSION_AND_OVERRIDABLE_SERIALIZATION) {
|
|
543
|
+
const control = r.u8()
|
|
544
|
+
if (control & CLASS_EXT_OVERRIDABLE_INFORMATION) r.u8() // overridable operation
|
|
545
|
+
}
|
|
546
|
+
const props = readProperties(r, pkg)
|
|
547
|
+
return { reader: r, props }
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// scalar payload helpers — each seeks to the tag's payload itself
|
|
551
|
+
export const propInt = (r: BinaryReader, t: PropertyTag | undefined): number | undefined => { if (!t) return undefined; r.offset = t.data; return r.i32() }
|
|
552
|
+
export const propFloat = (r: BinaryReader, t: PropertyTag | undefined): number | undefined => { if (!t) return undefined; r.offset = t.data; return r.f32() }
|
|
553
|
+
export const propName = (r: BinaryReader, names: string[], t: PropertyTag | undefined): string | undefined => { if (!t) return undefined; r.offset = t.data; return readFName(r, names) }
|
|
554
|
+
export const propString = (r: BinaryReader, t: PropertyTag | undefined): string | undefined => { if (!t) return undefined; r.offset = t.data; return r.fstring() }
|
|
555
|
+
export const propObject = (r: BinaryReader, t: PropertyTag | undefined): number | undefined => { if (!t) return undefined; r.offset = t.data; return r.i32() }
|
|
556
|
+
/** ByteProperty is an FName when it names an enum, a raw byte otherwise; EnumProperty is always an FName. */
|
|
557
|
+
export const propEnum = (r: BinaryReader, names: string[], t: PropertyTag | undefined): string | undefined => {
|
|
558
|
+
if (!t) return undefined
|
|
559
|
+
r.offset = t.data
|
|
560
|
+
if (t.type === "EnumProperty" || (t.enumName && t.enumName !== "None")) return readFName(r, names)
|
|
561
|
+
return String(r.u8())
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Structs serialized as fixed bytes (no tag list inside). Only what this pipeline meets. */
|
|
565
|
+
export const NATIVE_STRUCT_SIZE: Record<string, number> = {
|
|
566
|
+
Vector3f: 12, Quat4f: 16, Vector2f: 8, Vector4f: 16, Color: 4, LinearColor: 16, Guid: 16,
|
|
567
|
+
IntPoint: 8, IntVector: 12, Vector: 24, Vector2D: 16, Vector4: 32, Quat: 32, Rotator: 24, Transform: 80,
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* `ArrayProperty` header: the element count, then — for struct elements in files up to UE 5.3 — an
|
|
572
|
+
* INNER TAG naming the struct. From `PROPERTY_TAG_COMPLETE_TYPE_NAME` the outer tag's type tree
|
|
573
|
+
* already carries the element type and the inner tag is gone: the elements follow the count directly.
|
|
574
|
+
* `elementStruct` is the struct's name either way.
|
|
575
|
+
*/
|
|
576
|
+
export const readArrayHeader = (r: BinaryReader, pkg: { names: string[], summary: { ue4?: number, ue5: number } }, tag: PropertyTag): { count: number, inner: PropertyTag | null, elementStruct: string } => {
|
|
577
|
+
r.offset = tag.data
|
|
578
|
+
const count = r.i32()
|
|
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
|
|
582
|
+
const elementStruct = inner?.struct ?? tag.typeName.params[0]?.params[0]?.name ?? ""
|
|
583
|
+
return { count, inner, elementStruct }
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** `n` float triples/quads straight out of an `ArrayProperty<Vector3f | Quat4f>` payload. */
|
|
587
|
+
export const readFloatArray = (r: BinaryReader, pkg: { names: string[], summary: { ue4?: number, ue5: number } }, tag: PropertyTag): { comps: number, values: Float32Array } => {
|
|
588
|
+
const { count, elementStruct } = readArrayHeader(r, pkg, tag)
|
|
589
|
+
const struct = elementStruct || tag.inner || ""
|
|
590
|
+
const comps = struct === "Quat4f" || struct === "Quat" || struct === "Vector4f" ? 4 : struct === "Vector2f" ? 2 : 3
|
|
591
|
+
const values = new Float32Array(count * comps)
|
|
592
|
+
for (let i = 0; i < values.length; i++) values[i] = r.f32()
|
|
593
|
+
return { comps, values }
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// ---- bulk data (§3.6) ------------------------------------------------------------------------------
|
|
597
|
+
|
|
598
|
+
const BULKDATA_SERIALIZE_COMPRESSED = 0x2
|
|
599
|
+
const BULKDATA_PAYLOAD_IN_SEPARATE_FILE = 0x100
|
|
600
|
+
const BULKDATA_SIZE_64BIT = 0x2000
|
|
601
|
+
const BULKDATA_DUPLICATE_NON_OPTIONAL = 0x4000
|
|
602
|
+
const BULKDATA_BAD_DATA_VERSION = 0x8000
|
|
603
|
+
const BULKDATA_NO_OFFSET_FIX_UP = 0x10000
|
|
604
|
+
|
|
605
|
+
export type BulkDataHeader = { flags: number, elementCount: number, sizeOnDisk: number, offsetInFile: number }
|
|
606
|
+
|
|
607
|
+
/** `FByteBulkData`'s header, wherever a class embeds one. Leaves the reader past it. */
|
|
608
|
+
export const readBulkHeader = (r: BinaryReader): BulkDataHeader => {
|
|
609
|
+
const flags = r.u32()
|
|
610
|
+
const wide = (flags & BULKDATA_SIZE_64BIT) !== 0
|
|
611
|
+
const elementCount = wide ? r.i64() : r.i32()
|
|
612
|
+
const sizeOnDisk = wide ? r.i64() : r.i32()
|
|
613
|
+
const offsetInFile = r.i64()
|
|
614
|
+
if (flags & BULKDATA_BAD_DATA_VERSION) r.u16()
|
|
615
|
+
if (flags & BULKDATA_DUPLICATE_NON_OPTIONAL) { r.u32(); r.i64(); r.i64() }
|
|
616
|
+
return { flags, elementCount, sizeOnDisk, offsetInFile }
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export const bulkIsCompressed = (h: BulkDataHeader): boolean => (h.flags & BULKDATA_SERIALIZE_COMPRESSED) !== 0
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* The payload a bulk header points at, decompressed when it needs to be.
|
|
623
|
+
*
|
|
624
|
+
* The compressed container names its codec with a byte whose table is not reliably known, so the
|
|
625
|
+
* first chunk is SNIFFED instead: 0x78 = zlib (every UE 5.0 mesh bulk seen); anything else is
|
|
626
|
+
* reported by name rather than fed to the wrong decoder.
|
|
627
|
+
*/
|
|
628
|
+
export const readBulkPayload = (pkg: UePackage, h: BulkDataHeader): Uint8Array => {
|
|
629
|
+
if (h.flags & BULKDATA_PAYLOAD_IN_SEPARATE_FILE) {
|
|
630
|
+
throw new Error(`${pkg.label}: bulk payload lives in a separate .ubulk file — only fully inline (uncooked) packages are supported`)
|
|
631
|
+
}
|
|
632
|
+
const base = (h.flags & BULKDATA_NO_OFFSET_FIX_UP) ? 0 : pkg.summary.bulkDataStartOffset
|
|
633
|
+
const start = base + h.offsetInFile
|
|
634
|
+
if (start < 0 || start + h.sizeOnDisk > pkg.bytes.byteLength) {
|
|
635
|
+
throw new Error(`${pkg.label}: bulk payload [${start}, ${start + h.sizeOnDisk}) is outside the file (${pkg.bytes.byteLength})`)
|
|
636
|
+
}
|
|
637
|
+
const raw = pkg.bytes.subarray(start, start + h.sizeOnDisk)
|
|
638
|
+
if (!bulkIsCompressed(h)) return raw
|
|
639
|
+
return inflateChunked(raw, pkg.label)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** UE's chunked compressed container (`FArchive::SerializeCompressed`). */
|
|
643
|
+
export const inflateChunked = (raw: Uint8Array, label: string): Uint8Array => {
|
|
644
|
+
const r = new BinaryReader(raw, 0, `${label} bulk`)
|
|
645
|
+
const lo = r.u32(), hi = r.u32()
|
|
646
|
+
if (lo !== PACKAGE_FILE_TAG) throw new Error(`${label}: compressed bulk header tag 0x${lo.toString(16)} is not a package tag — unknown container`)
|
|
647
|
+
const v2 = hi === 0x22222222
|
|
648
|
+
const chunkSize = r.i64()
|
|
649
|
+
let codecName = "zlib"
|
|
650
|
+
if (v2) {
|
|
651
|
+
const codec = r.u8()
|
|
652
|
+
codecName = codec === 0 ? r.fstring() : `codec ${codec}`
|
|
653
|
+
}
|
|
654
|
+
const totalCompressed = r.i64(), totalUncompressed = r.i64()
|
|
655
|
+
if (chunkSize <= 0 || totalUncompressed < 0) throw new Error(`${label}: nonsense compressed bulk header (chunk ${chunkSize}, total ${totalUncompressed})`)
|
|
656
|
+
const n = Math.ceil(totalUncompressed / chunkSize)
|
|
657
|
+
const chunks: { c: number, u: number }[] = []
|
|
658
|
+
for (let i = 0; i < n; i++) chunks.push({ c: r.i64(), u: r.i64() })
|
|
659
|
+
const out = new Uint8Array(totalUncompressed)
|
|
660
|
+
let written = 0
|
|
661
|
+
for (let i = 0; i < n; i++) {
|
|
662
|
+
const { c, u } = chunks[i]
|
|
663
|
+
const data = r.raw(c)
|
|
664
|
+
if (c === u) { out.set(data, written); written += u; continue }
|
|
665
|
+
const magic = data[0]
|
|
666
|
+
if (magic !== 0x78) {
|
|
667
|
+
throw new Error(`${label}: bulk chunk ${i} is ${magic === 0x8c ? "Oodle-compressed" : `compressed with an unsupported codec (${codecName}, first byte 0x${magic.toString(16)})`} — not supported. Export the asset to FBX from the editor instead.`)
|
|
668
|
+
}
|
|
669
|
+
const inflated = inflateSync(data)
|
|
670
|
+
if (inflated.byteLength !== u) throw new Error(`${label}: bulk chunk ${i} inflated to ${inflated.byteLength} bytes, expected ${u}`)
|
|
671
|
+
out.set(inflated, written)
|
|
672
|
+
written += inflated.byteLength
|
|
673
|
+
}
|
|
674
|
+
if (written !== totalUncompressed) throw new Error(`${label}: bulk payload came to ${written} bytes, header says ${totalUncompressed}`)
|
|
675
|
+
void totalCompressed
|
|
676
|
+
return out
|
|
677
|
+
}
|