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.
Files changed (128) hide show
  1. package/README.md +738 -449
  2. package/dist/cli.js +19760 -8612
  3. package/dist/index.js +19334 -8167
  4. package/package.json +15 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli-kit/command.ts +100 -0
  8. package/src/cli-kit/help.ts +72 -0
  9. package/src/cli-kit/index.ts +4 -0
  10. package/src/cli-kit/parse.ts +72 -0
  11. package/src/cli-kit/run.ts +52 -0
  12. package/src/cli.ts +24 -0
  13. package/src/commands/avatar.ts +77 -0
  14. package/src/commands/bvh.ts +26 -0
  15. package/src/commands/convert.ts +156 -0
  16. package/src/commands/doctor.ts +67 -0
  17. package/src/commands/geometry.ts +45 -0
  18. package/src/commands/index.ts +24 -0
  19. package/src/commands/marks.ts +38 -0
  20. package/src/commands/mirror.ts +35 -0
  21. package/src/commands/probe.ts +43 -0
  22. package/src/commands/retarget/donor.ts +63 -0
  23. package/src/commands/retarget/index.ts +169 -0
  24. package/src/commands/retarget/map.ts +81 -0
  25. package/src/commands/retarget/report.ts +77 -0
  26. package/src/commands/scene.ts +103 -0
  27. package/src/commands/shared.ts +121 -0
  28. package/src/commands/sky.ts +35 -0
  29. package/src/commands/terrainPack.ts +40 -0
  30. package/src/commands/unpack.ts +43 -0
  31. package/src/commands/unreal.ts +109 -0
  32. package/src/commands/unwrap.ts +56 -0
  33. package/src/doctor/cardTrim.ts +286 -0
  34. package/src/doctor/doctor.ts +305 -0
  35. package/src/doctor/lod.ts +177 -0
  36. package/src/doctor/merge/accessors.ts +30 -0
  37. package/src/doctor/merge/atlasLayout.ts +94 -0
  38. package/src/doctor/merge/atlasPixels.ts +114 -0
  39. package/src/doctor/merge/bindSpace.ts +59 -0
  40. package/src/doctor/merge/classify.ts +136 -0
  41. package/src/doctor/merge/geometry.ts +167 -0
  42. package/src/doctor/merge/index.ts +98 -0
  43. package/src/doctor/merge/matrix.ts +60 -0
  44. package/src/doctor/merge/plan.ts +88 -0
  45. package/src/doctor/merge/textures.ts +60 -0
  46. package/src/doctor/merge/types.ts +77 -0
  47. package/src/doctor/normalizeScale.ts +220 -0
  48. package/src/doctor/reduceGlbBones.ts +218 -0
  49. package/src/doctor/shading.ts +95 -0
  50. package/src/doctor/transcodeKtx2.ts +275 -0
  51. package/src/doctor/transcodeTextures.ts +101 -0
  52. package/src/doctor/validateGlb.ts +264 -0
  53. package/src/doctor/vertexAo.ts +321 -0
  54. package/src/fbx/convert.ts +327 -0
  55. package/src/fbx/read.ts +111 -0
  56. package/src/fbx/toDocument.ts +227 -0
  57. package/src/filmstrip.ts +263 -0
  58. package/src/geometry.ts +222 -0
  59. package/src/index.ts +19 -0
  60. package/src/mergeSkins.ts +117 -0
  61. package/src/probe.ts +167 -0
  62. package/src/props.ts +137 -0
  63. package/src/retarget/automap.ts +276 -0
  64. package/src/retarget/ghost.ts +23 -0
  65. package/src/retarget/heading.ts +238 -0
  66. package/src/retarget/humanoid.ts +572 -0
  67. package/src/retarget/inPlace.ts +130 -0
  68. package/src/retarget/marks.ts +581 -0
  69. package/src/retarget/mirror.ts +0 -0
  70. package/src/retarget/retarget.ts +2375 -0
  71. package/src/retarget/rig.ts +545 -0
  72. package/src/retarget/unityAnim.ts +171 -0
  73. package/src/retarget/writeMarks.ts +134 -0
  74. package/src/sky/analyze.ts +136 -0
  75. package/src/sky/cmgen.ts +71 -0
  76. package/src/sky/index.ts +200 -0
  77. package/src/terrainPack.ts +208 -0
  78. package/src/textures/resolveImage.ts +202 -0
  79. package/src/unity/colliderFit.ts +287 -0
  80. package/src/unity/fbxModel.ts +194 -0
  81. package/src/unity/foliage.ts +63 -0
  82. package/src/unity/gameObjects.ts +81 -0
  83. package/src/unity/guid.ts +104 -0
  84. package/src/unity/image.ts +58 -0
  85. package/src/unity/kindGlb.ts +364 -0
  86. package/src/unity/lecodesLevel.ts +381 -0
  87. package/src/unity/levelTerrain.ts +116 -0
  88. package/src/unity/matFile.ts +179 -0
  89. package/src/unity/materialSlots.ts +53 -0
  90. package/src/unity/materialWriter.ts +306 -0
  91. package/src/unity/materials.ts +198 -0
  92. package/src/unity/math.ts +82 -0
  93. package/src/unity/png.ts +75 -0
  94. package/src/unity/prefabTree.ts +285 -0
  95. package/src/unity/prefabs.ts +54 -0
  96. package/src/unity/propertyPath.ts +43 -0
  97. package/src/unity/scene.ts +384 -0
  98. package/src/unity/serialized.ts +242 -0
  99. package/src/unity/terrain.ts +221 -0
  100. package/src/unity/triBudget.ts +77 -0
  101. package/src/unity/triplanar.ts +122 -0
  102. package/src/unity/unityMesh.ts +151 -0
  103. package/src/unity/unpack.ts +149 -0
  104. package/src/unity/yaml.ts +131 -0
  105. package/src/unreal/anim.ts +284 -0
  106. package/src/unreal/clipSource.ts +25 -0
  107. package/src/unreal/convert.ts +67 -0
  108. package/src/unreal/convertAsset.ts +81 -0
  109. package/src/unreal/index.ts +115 -0
  110. package/src/unreal/list.ts +113 -0
  111. package/src/unreal/materialGraph.ts +224 -0
  112. package/src/unreal/materials.ts +399 -0
  113. package/src/unreal/mesh.ts +205 -0
  114. package/src/unreal/oodle.ts +153 -0
  115. package/src/unreal/package.ts +677 -0
  116. package/src/unreal/probe.ts +144 -0
  117. package/src/unreal/resolve.ts +53 -0
  118. package/src/unreal/rig.ts +149 -0
  119. package/src/unreal/sequencer.ts +193 -0
  120. package/src/unreal/skeleton.ts +74 -0
  121. package/src/unreal/sound.ts +128 -0
  122. package/src/unreal/texture.ts +269 -0
  123. package/src/unreal/toDocument.ts +367 -0
  124. package/src/unreal/versions.ts +108 -0
  125. package/src/unwrap/index.ts +167 -0
  126. package/src/unwrap/lightmapUv.ts +761 -0
  127. package/src/wasm/fbxReader.js +14 -0
  128. 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
+ }