lecodes-assets 0.1.2 → 0.2.1

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