@threenative/ueformat 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ThreeNative contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # @threenative/ueformat
2
+
3
+ A strict UEFormat v10 `.uemodel` parser and Three.js loader for assets exported from Unreal
4
+ packages by CUE4Parse/FModel.
5
+
6
+ ```text
7
+ .uasset / .uexp / .ubulk / .pak / .utoc
8
+ ↓ CUE4Parse
9
+ .uemodel
10
+ ↓ this package
11
+ BufferGeometry / Mesh / LOD / Group
12
+ ```
13
+
14
+ This package does **not** pretend that an isolated `.uasset` is a self-contained mesh. It handles
15
+ the stable interchange boundary: CUE4Parse extracts UE4/UE5 packages, then this loader validates
16
+ and constructs renderable Three.js objects.
17
+
18
+ ## Supported
19
+
20
+ - UEFormat v10 `UEMODEL` headers and named attribute sets
21
+ - uncompressed, GZIP, and injected-decoder ZSTD bodies
22
+ - static and skeletal mesh LOD records
23
+ - positions, normals, tangents, indices, multiple UV/color channels
24
+ - material sections as `BufferGeometry.groups`
25
+ - sparse morph targets
26
+ - skin indices/weights and skeleton metadata parsing
27
+ - sockets, virtual bones, and convex collision geometry
28
+ - Unreal centimeters/Z-up/left-handed coordinates to Three.js meters/Y-up/right-handed coordinates
29
+ - automatic winding and tangent-sign repair
30
+ - browser `Loader.load`, synchronous `parse`, and `ueformat-inspect` CLI validation
31
+
32
+ ## Not implemented
33
+
34
+ - **Automatic texture/material extraction** — `.uemodel` carries material *slots* (name, path,
35
+ index range), not texture payloads. Resolve textures through your own pipeline (the asset MCP
36
+ does this) and supply materials through `materialFactory`.
37
+ - **Skeletal binding** — parsed bones, sockets and skin weights are exposed on `userData` and as
38
+ `skinIndex`/`skinWeight` attributes, but the package never constructs a `THREE.SkinnedMesh` or
39
+ binds a skeleton. That is deliberately left to the game; nothing here invents it.
40
+ - ZSTD bodies require an injected `zstdDecoder` — the package never bundles a ZSTD implementation.
41
+
42
+ ## The look is the game's
43
+
44
+ Every material comes from the game through `materialFactory`. The fallback matches three.js's own
45
+ `GLTFLoader`: a plain `MeshStandardMaterial` named after the slot. No colour, roughness, or
46
+ metalness is decided here.
47
+
48
+ ## Provenance
49
+
50
+ Absorbed from the standalone `three-ueformat-loader` package (MIT, ThreeNative contributors, 2026)
51
+ into the engine. The parser is pure TypeScript with no browser or Node globals — the same code runs
52
+ in the browser, in Node, and on the native arm.
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/cli.js ADDED
@@ -0,0 +1,567 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from 'fs/promises';
3
+ import { gunzipSync } from 'fflate';
4
+
5
+ // src/errors.ts
6
+ var UEFormatError = class extends Error {
7
+ code;
8
+ offset;
9
+ constructor(code, message, offset = -1, cause) {
10
+ super(message, cause === void 0 ? void 0 : { cause });
11
+ this.name = "UEFormatError";
12
+ this.code = code;
13
+ this.offset = offset;
14
+ }
15
+ };
16
+
17
+ // src/binary.ts
18
+ var BinaryReader = class _BinaryReader {
19
+ bytes;
20
+ limits;
21
+ baseOffset;
22
+ view;
23
+ offset = 0;
24
+ constructor(bytes, limits, baseOffset = 0) {
25
+ this.bytes = bytes;
26
+ this.limits = limits;
27
+ this.baseOffset = baseOffset;
28
+ this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
29
+ }
30
+ get remaining() {
31
+ return this.bytes.length - this.offset;
32
+ }
33
+ get absoluteOffset() {
34
+ return this.baseOffset + this.offset;
35
+ }
36
+ ensure(size) {
37
+ if (!Number.isSafeInteger(size) || size < 0 || size > this.remaining) {
38
+ throw new UEFormatError(
39
+ "TRUNCATED_FILE",
40
+ `Need ${size} byte(s), but only ${this.remaining} remain`,
41
+ this.absoluteOffset
42
+ );
43
+ }
44
+ }
45
+ uint8() {
46
+ this.ensure(1);
47
+ return this.view.getUint8(this.offset++);
48
+ }
49
+ bool() {
50
+ const value = this.uint8();
51
+ if (value > 1) {
52
+ throw new UEFormatError(
53
+ "INVALID_LENGTH",
54
+ `Invalid boolean value ${value}`,
55
+ this.absoluteOffset - 1
56
+ );
57
+ }
58
+ return value === 1;
59
+ }
60
+ uint16() {
61
+ this.ensure(2);
62
+ const value = this.view.getUint16(this.offset, true);
63
+ this.offset += 2;
64
+ return value;
65
+ }
66
+ int32() {
67
+ this.ensure(4);
68
+ const value = this.view.getInt32(this.offset, true);
69
+ this.offset += 4;
70
+ return value;
71
+ }
72
+ uint32() {
73
+ this.ensure(4);
74
+ const value = this.view.getUint32(this.offset, true);
75
+ this.offset += 4;
76
+ return value;
77
+ }
78
+ float32() {
79
+ this.ensure(4);
80
+ const value = this.view.getFloat32(this.offset, true);
81
+ this.offset += 4;
82
+ return value;
83
+ }
84
+ raw(size) {
85
+ this.ensure(size);
86
+ const result = this.bytes.subarray(this.offset, this.offset + size);
87
+ this.offset += size;
88
+ return result;
89
+ }
90
+ fixedString(size) {
91
+ return new TextDecoder("utf-8", { fatal: true }).decode(this.raw(size)).replace(/\0+$/, "");
92
+ }
93
+ string() {
94
+ const start = this.absoluteOffset;
95
+ const size = this.int32();
96
+ if (size < 0 || size > this.limits.maxStringBytes) {
97
+ throw new UEFormatError("INVALID_LENGTH", `Invalid string byte length ${size}`, start);
98
+ }
99
+ try {
100
+ return new TextDecoder("utf-8", { fatal: true }).decode(this.raw(size)).replace(/\0+$/, "");
101
+ } catch (error) {
102
+ if (error instanceof UEFormatError) throw error;
103
+ throw new UEFormatError("INVALID_LENGTH", "String is not valid UTF-8", start, error);
104
+ }
105
+ }
106
+ count(label) {
107
+ const start = this.absoluteOffset;
108
+ const count = this.int32();
109
+ if (count < 0 || count > this.limits.maxArrayElements) {
110
+ throw new UEFormatError("INVALID_COUNT", `Invalid ${label} count ${count}`, start);
111
+ }
112
+ return count;
113
+ }
114
+ vector2() {
115
+ return { u: this.float32(), v: this.float32() };
116
+ }
117
+ vector3() {
118
+ return { x: this.float32(), y: this.float32(), z: this.float32() };
119
+ }
120
+ quaternion() {
121
+ return { ...this.vector3(), w: this.float32() };
122
+ }
123
+ array(label, read) {
124
+ const count = this.count(label);
125
+ const values = new Array(count);
126
+ for (let index = 0; index < count; index++) values[index] = read(this, index);
127
+ return values;
128
+ }
129
+ chunk(size) {
130
+ const start = this.absoluteOffset;
131
+ return new _BinaryReader(this.raw(size), this.limits, start);
132
+ }
133
+ };
134
+ function toUint8Array(input) {
135
+ return input instanceof ArrayBuffer ? new Uint8Array(input) : new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
136
+ }
137
+ function crc32(bytes) {
138
+ let crc = 4294967295;
139
+ for (const byte of bytes) {
140
+ crc ^= byte;
141
+ for (let bit = 0; bit < 8; bit++) {
142
+ crc = crc >>> 1 ^ 3988292384 & -(crc & 1);
143
+ }
144
+ }
145
+ return (crc ^ 4294967295) >>> 0;
146
+ }
147
+ function validateGzipTrailer(compressed, decoded) {
148
+ if (compressed.length < 18)
149
+ throw new TypeError("GZIP payload is shorter than its minimum header and trailer");
150
+ const trailer = new DataView(
151
+ compressed.buffer,
152
+ compressed.byteOffset + compressed.byteLength - 8,
153
+ 8
154
+ );
155
+ const expectedCrc = trailer.getUint32(0, true);
156
+ const expectedSize = trailer.getUint32(4, true);
157
+ if (crc32(decoded) !== expectedCrc || decoded.length >>> 0 !== expectedSize) {
158
+ throw new TypeError("GZIP CRC32 or ISIZE trailer check failed");
159
+ }
160
+ }
161
+ function decompressBody(format, compressed, uncompressedSize, zstdDecoder) {
162
+ let result;
163
+ try {
164
+ if (format === "GZIP") {
165
+ result = gunzipSync(compressed);
166
+ validateGzipTrailer(compressed, result);
167
+ } else if (format === "ZSTD") {
168
+ if (!zstdDecoder) {
169
+ throw new UEFormatError(
170
+ "INVALID_COMPRESSION",
171
+ "ZSTD body requires ParseUEModelOptions.zstdDecoder"
172
+ );
173
+ }
174
+ result = zstdDecoder(compressed, uncompressedSize);
175
+ if (!(result instanceof Uint8Array)) {
176
+ throw new TypeError("ZSTD decoder must return Uint8Array");
177
+ }
178
+ } else {
179
+ throw new UEFormatError(
180
+ "INVALID_COMPRESSION",
181
+ `Unsupported compression format ${JSON.stringify(format)}`
182
+ );
183
+ }
184
+ } catch (error) {
185
+ if (error instanceof UEFormatError) throw error;
186
+ throw new UEFormatError(
187
+ "DECOMPRESSION_FAILED",
188
+ `Failed to decompress ${format} body`,
189
+ -1,
190
+ error
191
+ );
192
+ }
193
+ if (result.length !== uncompressedSize) {
194
+ throw new UEFormatError(
195
+ "SIZE_MISMATCH",
196
+ `Decoded body has ${result.length} byte(s); header declares ${uncompressedSize}`
197
+ );
198
+ }
199
+ return result;
200
+ }
201
+
202
+ // src/parser.ts
203
+ var DEFAULT_LIMITS = {
204
+ maxStringBytes: 16 * 1024 * 1024,
205
+ maxArrayElements: 5e7,
206
+ maxAttributes: 1e5
207
+ };
208
+ function readAttributes(reader, handlers, unknown) {
209
+ const count = reader.int32();
210
+ if (count < 0 || count > reader.limits.maxAttributes) {
211
+ throw new UEFormatError(
212
+ "INVALID_COUNT",
213
+ `Invalid attribute count ${count}`,
214
+ reader.absoluteOffset - 4
215
+ );
216
+ }
217
+ for (let index = 0; index < count; index++) {
218
+ const name = reader.string();
219
+ const byteSizeOffset = reader.absoluteOffset;
220
+ const byteSize = reader.int32();
221
+ if (byteSize < 0) {
222
+ throw new UEFormatError(
223
+ "INVALID_LENGTH",
224
+ `Invalid byte size ${byteSize} for attribute ${name}`,
225
+ byteSizeOffset
226
+ );
227
+ }
228
+ const payload = reader.chunk(byteSize);
229
+ const handler = handlers[name];
230
+ if (!handler) {
231
+ unknown.push(name);
232
+ continue;
233
+ }
234
+ handler(payload);
235
+ if (payload.remaining !== 0) {
236
+ throw new UEFormatError(
237
+ "ATTRIBUTE_SIZE_MISMATCH",
238
+ `Attribute ${name} has ${payload.remaining} unread byte(s)`,
239
+ payload.absoluteOffset
240
+ );
241
+ }
242
+ }
243
+ }
244
+ function readVectorArray(reader, label) {
245
+ return reader.array(label, (item) => item.vector3());
246
+ }
247
+ function readLod(reader) {
248
+ const lod = {
249
+ name: reader.string(),
250
+ vertices: [],
251
+ normals: [],
252
+ tangents: [],
253
+ texCoords: [],
254
+ indices: [],
255
+ vertexColors: [],
256
+ materials: [],
257
+ weights: [],
258
+ morphTargets: [],
259
+ unknownAttributes: []
260
+ };
261
+ readAttributes(
262
+ reader,
263
+ {
264
+ VERTICES(payload) {
265
+ lod.vertices = readVectorArray(payload, "vertex");
266
+ },
267
+ INDICES(payload) {
268
+ lod.indices = payload.array("index", (item) => item.uint32());
269
+ },
270
+ NORMALS(payload) {
271
+ lod.normals = payload.array("normal", (item) => ({
272
+ binormalSign: item.float32(),
273
+ ...item.vector3()
274
+ }));
275
+ },
276
+ TANGENTS(payload) {
277
+ lod.tangents = readVectorArray(payload, "tangent");
278
+ },
279
+ TEXCOORDS(payload) {
280
+ lod.texCoords = payload.array("texture-coordinate channel", (item) => ({
281
+ name: item.string(),
282
+ uvs: item.array("texture coordinate", (uvReader) => uvReader.vector2())
283
+ }));
284
+ },
285
+ VERTEXCOLORS(payload) {
286
+ lod.vertexColors = payload.array("vertex-color channel", (item) => ({
287
+ name: item.string(),
288
+ colors: item.array("vertex color", (colorReader) => ({
289
+ r: colorReader.uint8(),
290
+ g: colorReader.uint8(),
291
+ b: colorReader.uint8(),
292
+ a: colorReader.uint8()
293
+ }))
294
+ }));
295
+ },
296
+ MATERIALS(payload) {
297
+ lod.materials = payload.array("material", (item) => ({
298
+ materialName: item.string(),
299
+ materialPath: item.string(),
300
+ firstIndex: item.int32(),
301
+ numFaces: item.int32()
302
+ }));
303
+ },
304
+ WEIGHTS(payload) {
305
+ lod.weights = payload.array("weight", (item) => ({
306
+ boneIndex: item.uint16(),
307
+ vertexIndex: item.int32(),
308
+ weight: item.float32()
309
+ }));
310
+ },
311
+ MORPHTARGETS(payload) {
312
+ lod.morphTargets = payload.array("morph target", (item) => ({
313
+ name: item.string(),
314
+ deltas: item.array("morph delta", (deltaReader) => ({
315
+ positionDelta: deltaReader.vector3(),
316
+ tangentZDelta: deltaReader.vector3(),
317
+ vertexIndex: deltaReader.uint32()
318
+ }))
319
+ }));
320
+ }
321
+ },
322
+ lod.unknownAttributes
323
+ );
324
+ return lod;
325
+ }
326
+ function readBone(reader) {
327
+ return {
328
+ name: reader.string(),
329
+ parentIndex: reader.int32(),
330
+ position: reader.vector3(),
331
+ orientation: reader.quaternion(),
332
+ scale: reader.vector3()
333
+ };
334
+ }
335
+ function readSkeleton(reader) {
336
+ const skeleton = {
337
+ metadata: "",
338
+ bones: [],
339
+ sockets: [],
340
+ virtualBones: [],
341
+ unknownAttributes: []
342
+ };
343
+ readAttributes(
344
+ reader,
345
+ {
346
+ METADATA(payload) {
347
+ skeleton.metadata = payload.string();
348
+ },
349
+ BONES(payload) {
350
+ skeleton.bones = payload.array("bone", (item) => readBone(item));
351
+ },
352
+ SOCKETS(payload) {
353
+ skeleton.sockets = payload.array("socket", (item) => ({
354
+ name: item.string(),
355
+ boneName: item.string(),
356
+ relativeLocation: item.vector3(),
357
+ relativeRotation: item.quaternion(),
358
+ relativeScale: item.vector3()
359
+ }));
360
+ },
361
+ VIRTUALBONES(payload) {
362
+ skeleton.virtualBones = payload.array("virtual bone", (item) => ({
363
+ sourceBoneName: item.string(),
364
+ targetBoneName: item.string(),
365
+ virtualBoneName: item.string()
366
+ }));
367
+ }
368
+ },
369
+ skeleton.unknownAttributes
370
+ );
371
+ return skeleton;
372
+ }
373
+ function readCollision(reader) {
374
+ return {
375
+ name: reader.string(),
376
+ vertices: readVectorArray(reader, "collision vertex"),
377
+ indices: reader.array("collision index", (item) => item.uint32())
378
+ };
379
+ }
380
+ function parseUEModel(input, options = {}) {
381
+ const limits = {
382
+ maxStringBytes: options.maxStringBytes ?? DEFAULT_LIMITS.maxStringBytes,
383
+ maxArrayElements: options.maxArrayElements ?? DEFAULT_LIMITS.maxArrayElements,
384
+ maxAttributes: options.maxAttributes ?? DEFAULT_LIMITS.maxAttributes
385
+ };
386
+ const reader = new BinaryReader(toUint8Array(input), limits);
387
+ const magic = reader.fixedString(8);
388
+ if (magic !== "UEFORMAT") {
389
+ throw new UEFormatError(
390
+ "INVALID_MAGIC",
391
+ `Expected UEFORMAT, received ${JSON.stringify(magic)}`,
392
+ 0
393
+ );
394
+ }
395
+ const identifier = reader.string();
396
+ if (identifier !== "UEMODEL") {
397
+ throw new UEFormatError(
398
+ "INVALID_IDENTIFIER",
399
+ `Expected UEMODEL, received ${JSON.stringify(identifier)}`,
400
+ 8
401
+ );
402
+ }
403
+ const fileVersion = reader.uint8();
404
+ if (fileVersion !== 10) {
405
+ throw new UEFormatError(
406
+ "UNSUPPORTED_VERSION",
407
+ `Only UEFormat v10 is supported; received v${fileVersion}`,
408
+ reader.absoluteOffset - 1
409
+ );
410
+ }
411
+ const objectName = reader.string();
412
+ const objectPath = reader.string();
413
+ const compressed = reader.bool();
414
+ let bodyReader = reader;
415
+ let compression = null;
416
+ if (compressed) {
417
+ const format = reader.string();
418
+ const uncompressedSize = reader.int32();
419
+ const compressedSize = reader.int32();
420
+ if (uncompressedSize < 0 || compressedSize < 0) {
421
+ throw new UEFormatError(
422
+ "INVALID_LENGTH",
423
+ "Compression sizes must be non-negative",
424
+ reader.absoluteOffset - 8
425
+ );
426
+ }
427
+ if (compressedSize !== reader.remaining) {
428
+ throw new UEFormatError(
429
+ "SIZE_MISMATCH",
430
+ `Compressed body has ${reader.remaining} byte(s); header declares ${compressedSize}`,
431
+ reader.absoluteOffset
432
+ );
433
+ }
434
+ if (format !== "GZIP" && format !== "ZSTD") {
435
+ throw new UEFormatError(
436
+ "INVALID_COMPRESSION",
437
+ `Unsupported compression format ${JSON.stringify(format)}`,
438
+ reader.absoluteOffset
439
+ );
440
+ }
441
+ const decoded = decompressBody(
442
+ format,
443
+ reader.raw(compressedSize),
444
+ uncompressedSize,
445
+ options.zstdDecoder
446
+ );
447
+ bodyReader = new BinaryReader(decoded, limits);
448
+ compression = { format, uncompressedSize, compressedSize };
449
+ }
450
+ const header = {
451
+ magic: "UEFORMAT",
452
+ identifier: "UEMODEL",
453
+ fileVersion: 10,
454
+ objectName,
455
+ objectPath,
456
+ compression
457
+ };
458
+ const model = {
459
+ header,
460
+ lods: [],
461
+ skeleton: null,
462
+ collision: [],
463
+ unknownAttributes: []
464
+ };
465
+ readAttributes(
466
+ bodyReader,
467
+ {
468
+ LODS(payload) {
469
+ model.lods = payload.array("LOD", (item) => readLod(item));
470
+ },
471
+ SKELETON(payload) {
472
+ model.skeleton = readSkeleton(payload);
473
+ },
474
+ COLLISION(payload) {
475
+ model.collision = payload.array("collision mesh", (item) => readCollision(item));
476
+ }
477
+ },
478
+ model.unknownAttributes
479
+ );
480
+ if (bodyReader.remaining !== 0) {
481
+ throw new UEFormatError(
482
+ "ATTRIBUTE_SIZE_MISMATCH",
483
+ `Model has ${bodyReader.remaining} trailing byte(s)`,
484
+ bodyReader.absoluteOffset
485
+ );
486
+ }
487
+ return model;
488
+ }
489
+
490
+ // src/summary.ts
491
+ function summarizeUEModel(model) {
492
+ return {
493
+ objectName: model.header.objectName,
494
+ objectPath: model.header.objectPath,
495
+ formatVersion: model.header.fileVersion,
496
+ compression: model.header.compression?.format ?? null,
497
+ lods: model.lods.map((lod) => ({
498
+ name: lod.name,
499
+ vertices: lod.vertices.length,
500
+ triangles: lod.indices.length / 3,
501
+ materials: lod.materials.map((material) => material.materialName),
502
+ uvChannels: lod.texCoords.map((channel) => channel.name),
503
+ vertexColorChannels: lod.vertexColors.map((channel) => channel.name),
504
+ weights: lod.weights.length,
505
+ morphTargets: lod.morphTargets.map((target) => target.name)
506
+ })),
507
+ skeleton: model.skeleton === null ? null : {
508
+ bones: model.skeleton.bones.length,
509
+ sockets: model.skeleton.sockets.length,
510
+ virtualBones: model.skeleton.virtualBones.length
511
+ },
512
+ collisionMeshes: model.collision.map((collision) => ({
513
+ name: collision.name,
514
+ vertices: collision.vertices.length,
515
+ triangles: collision.indices.length / 3
516
+ })),
517
+ unknownAttributes: [
518
+ ...model.unknownAttributes,
519
+ ...model.lods.flatMap((lod) => lod.unknownAttributes.map((name) => `${lod.name}:${name}`)),
520
+ ...model.skeleton?.unknownAttributes.map((name) => `SKELETON:${name}`) ?? []
521
+ ]
522
+ };
523
+ }
524
+
525
+ // src/cli.ts
526
+ function usage() {
527
+ return [
528
+ "Usage: ueformat-inspect [--json] <file.uemodel>",
529
+ "",
530
+ "Validates a UEFormat v10 model and prints a compact summary.",
531
+ "ZSTD files require using the JavaScript API with an injected decoder."
532
+ ].join("\n");
533
+ }
534
+ async function main() {
535
+ const args = process.argv.slice(2);
536
+ if (args.includes("--help") || args.includes("-h")) {
537
+ process.stdout.write(`${usage()}
538
+ `);
539
+ return;
540
+ }
541
+ const json = args.includes("--json");
542
+ const path = args.find((argument) => !argument.startsWith("-"));
543
+ if (!path) {
544
+ process.stderr.write(`${usage()}
545
+ `);
546
+ process.exitCode = 2;
547
+ return;
548
+ }
549
+ try {
550
+ const bytes = await readFile(path);
551
+ const summary = summarizeUEModel(parseUEModel(bytes));
552
+ process.stdout.write(`${JSON.stringify(summary, null, json ? 0 : 2)}
553
+ `);
554
+ } catch (error) {
555
+ if (error instanceof UEFormatError) {
556
+ process.stderr.write(
557
+ `${error.code}: ${error.message}${error.offset >= 0 ? ` (offset ${error.offset})` : ""}
558
+ `
559
+ );
560
+ } else {
561
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}
562
+ `);
563
+ }
564
+ process.exitCode = 1;
565
+ }
566
+ }
567
+ await main();