threenative-asset-mcp 0.9.2 → 0.9.4

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 (81) hide show
  1. package/README.md +4 -1
  2. package/dist/bundle/client.d.ts +2 -0
  3. package/dist/bundle/client.d.ts.map +1 -1
  4. package/dist/bundle/client.js +6 -0
  5. package/dist/bundle/client.js.map +1 -1
  6. package/dist/download/direct-asset-downloader.d.ts +3 -0
  7. package/dist/download/direct-asset-downloader.d.ts.map +1 -1
  8. package/dist/download/direct-asset-downloader.js +4 -1
  9. package/dist/download/direct-asset-downloader.js.map +1 -1
  10. package/dist/fab/api-download.d.ts.map +1 -1
  11. package/dist/fab/api-download.js +23 -3
  12. package/dist/fab/api-download.js.map +1 -1
  13. package/dist/fab/client.d.ts.map +1 -1
  14. package/dist/fab/client.js +5 -10
  15. package/dist/fab/client.js.map +1 -1
  16. package/dist/fab/direct-transport.d.ts +3 -0
  17. package/dist/fab/direct-transport.d.ts.map +1 -1
  18. package/dist/fab/direct-transport.js.map +1 -1
  19. package/dist/fab/download-store.d.ts.map +1 -1
  20. package/dist/fab/download-store.js +6 -0
  21. package/dist/fab/download-store.js.map +1 -1
  22. package/dist/fab/fabcli.d.ts +1 -1
  23. package/dist/fab/fabcli.d.ts.map +1 -1
  24. package/dist/fab/fabcli.js +20 -4
  25. package/dist/fab/fabcli.js.map +1 -1
  26. package/dist/fab/normalize.d.ts.map +1 -1
  27. package/dist/fab/normalize.js +4 -0
  28. package/dist/fab/normalize.js.map +1 -1
  29. package/dist/gltf-size.d.ts +19 -0
  30. package/dist/gltf-size.d.ts.map +1 -0
  31. package/dist/gltf-size.js +33 -0
  32. package/dist/gltf-size.js.map +1 -0
  33. package/dist/server.js +5 -5
  34. package/dist/server.js.map +1 -1
  35. package/dist/tools/bundle.d.ts +10 -0
  36. package/dist/tools/bundle.d.ts.map +1 -1
  37. package/dist/tools/bundle.js +6 -0
  38. package/dist/tools/bundle.js.map +1 -1
  39. package/dist/tools/direct-download.d.ts +10 -0
  40. package/dist/tools/direct-download.d.ts.map +1 -1
  41. package/dist/tools/direct-download.js +7 -0
  42. package/dist/tools/direct-download.js.map +1 -1
  43. package/dist/tools/download-free-asset.d.ts +10 -0
  44. package/dist/tools/download-free-asset.d.ts.map +1 -1
  45. package/dist/tools/download-free-asset.js +6 -0
  46. package/dist/tools/download-free-asset.js.map +1 -1
  47. package/dist/tools/import-unreal.d.ts +40 -0
  48. package/dist/tools/import-unreal.d.ts.map +1 -1
  49. package/dist/tools/import-unreal.js +13 -0
  50. package/dist/tools/import-unreal.js.map +1 -1
  51. package/dist/tools/list-filters.d.ts +1 -15
  52. package/dist/tools/list-filters.d.ts.map +1 -1
  53. package/dist/tools/list-filters.js +1 -3
  54. package/dist/tools/list-filters.js.map +1 -1
  55. package/dist/tools/search-assets.d.ts +2 -4
  56. package/dist/tools/search-assets.d.ts.map +1 -1
  57. package/dist/tools/search-assets.js +1 -2
  58. package/dist/tools/search-assets.js.map +1 -1
  59. package/dist/unreal/cooking.d.ts +7 -0
  60. package/dist/unreal/cooking.d.ts.map +1 -1
  61. package/dist/unreal/cooking.js +101 -62
  62. package/dist/unreal/cooking.js.map +1 -1
  63. package/dist/unreal/importer.d.ts +8 -1
  64. package/dist/unreal/importer.d.ts.map +1 -1
  65. package/dist/unreal/importer.js +181 -75
  66. package/dist/unreal/importer.js.map +1 -1
  67. package/dist/unreal/materials.d.ts.map +1 -1
  68. package/dist/unreal/materials.js +39 -7
  69. package/dist/unreal/materials.js.map +1 -1
  70. package/dist/unreal/provision.d.ts +2 -0
  71. package/dist/unreal/provision.d.ts.map +1 -1
  72. package/dist/unreal/provision.js +15 -4
  73. package/dist/unreal/provision.js.map +1 -1
  74. package/dist/unreal/scenes.d.ts +7 -0
  75. package/dist/unreal/scenes.d.ts.map +1 -1
  76. package/dist/unreal/scenes.js +13 -1
  77. package/dist/unreal/scenes.js.map +1 -1
  78. package/dist/unreal/toolchain.d.ts.map +1 -1
  79. package/dist/unreal/toolchain.js +11 -1
  80. package/dist/unreal/toolchain.js.map +1 -1
  81. package/package.json +1 -1
@@ -8,7 +8,7 @@ import { Document, NodeIO, VertexLayout } from "@gltf-transform/core";
8
8
  import { EXTMeshGPUInstancing, KHRLightsPunctual, KHRMaterialsUnlit } from "@gltf-transform/extensions";
9
9
  import { attachPsaAnimations, parsePsa } from "./psa.js";
10
10
  import { resolveMaterial, } from "./materials.js";
11
- import { readPackageCooking } from "./cooking.js";
11
+ import { readPackageCooking, readPackageObjectNames } from "./cooking.js";
12
12
  import { extractUnrealFonts } from "./fonts.js";
13
13
  import { parseOfflineFontDescriptor, writeOfflineFont } from "./bitmap-fonts.js";
14
14
  import { paperObjectPathToPackage, parsePaperFlipbookDescriptor, parsePaperSpriteDescriptor, writePaperSpriteGlb, } from "./paper2d.js";
@@ -18,7 +18,21 @@ import { assembleSceneGlb, parseUnrealSceneSource, } from "./scenes.js";
18
18
  import { ToolchainError, assertSupportedHost, runBounded } from "./toolchain.js";
19
19
  const statfsAsync = promisify(statfs);
20
20
  /** Bumped whenever the conversion contract changes; it participates in the reuse cache key. */
21
- export const IMPORTER_VERSION = 44;
21
+ export const IMPORTER_VERSION = 46;
22
+ /** First and last UE4 object versions whose uncooked StaticMesh source models are FMeshDescription
23
+ * bulk data (UE4.25–4.27), which only the engine-free converter reads. Below that window UE Viewer
24
+ * reads uncooked source geometry itself, static and skeletal alike: verified on FAB packs saved at
25
+ * object versions 401–516 (UE4.0–4.20), where it matched or beat every hand-written decoder. */
26
+ const MESH_DESCRIPTION_FIRST_VERSION = 517;
27
+ const MESH_DESCRIPTION_LAST_VERSION = 522;
28
+ /** Which tool decodes an uncooked mesh package, or undefined when it must be refused. */
29
+ export function uncookedMeshRoute(meshKind, fileVersionUE4) {
30
+ if (fileVersionUE4 !== undefined && fileVersionUE4 < MESH_DESCRIPTION_FIRST_VERSION)
31
+ return "umodel";
32
+ if (meshKind === "skeletal")
33
+ return "modern";
34
+ return fileVersionUE4 !== undefined && fileVersionUE4 <= MESH_DESCRIPTION_LAST_VERSION ? "mesh-description" : undefined;
35
+ }
22
36
  export class ImportError extends Error {
23
37
  code;
24
38
  retryable;
@@ -57,6 +71,23 @@ export function summarizeClasses(classes, keep = 4) {
57
71
  return unique.join(", ");
58
72
  return `${unique.slice(0, keep).join(", ")} and ${unique.length - keep} more`;
59
73
  }
74
+ /** Staging older than this belongs to an import that was killed before its `finally` ran. */
75
+ const STALE_STAGING_MS = 24 * 60 * 60 * 1000;
76
+ /** Removes `run-*` staging left by killed imports. One such run can hold gigabytes, and they
77
+ * accumulate until the cache volume fills and every later import fails its free-space check. */
78
+ export async function sweepStaleStaging(root, now = Date.now()) {
79
+ for (const key of await readdir(root).catch(() => [])) {
80
+ for (const run of await readdir(join(root, key)).catch(() => [])) {
81
+ if (!run.startsWith("run-"))
82
+ continue;
83
+ const path = join(root, key, run);
84
+ const modified = (await stat(path).catch(() => undefined))?.mtimeMs;
85
+ if (modified !== undefined && now - modified > STALE_STAGING_MS) {
86
+ await rm(path, { recursive: true, force: true }).catch(() => { });
87
+ }
88
+ }
89
+ }
90
+ }
60
91
  function cacheRoot(environment) {
61
92
  return (environment.THREENATIVE_UNREAL_CACHE_DIR?.trim() ||
62
93
  join(environment.XDG_CACHE_HOME?.trim() || join(homedir(), ".cache"), "threenative-asset-mcp", "unreal-import"));
@@ -815,7 +846,9 @@ export function inspectWebAudio(data) {
815
846
  }
816
847
  return undefined;
817
848
  }
818
- async function freeBytes(path) {
849
+ async function freeBytes(path, override) {
850
+ if (override !== undefined)
851
+ return override;
819
852
  try {
820
853
  const info = await statfsAsync(path);
821
854
  return Number(info.bavail) * Number(info.bsize);
@@ -824,6 +857,66 @@ async function freeBytes(path) {
824
857
  return Number.POSITIVE_INFINITY;
825
858
  }
826
859
  }
860
+ /** `Foo.uasset` is accompanied by `Foo.uexp`, `Foo.ubulk`, and `Foo.uptnl`; a partial import
861
+ * reads every file of the stem it converts, and only files of that stem. */
862
+ function packageStem(file) {
863
+ return join(dirname(file), basename(file, extname(file)));
864
+ }
865
+ /** Source bytes a filtered import will actually read: the requested packages, their sidecars, and
866
+ * the packages they name in their import tables — a mesh's materials and textures are separate
867
+ * packages and hold most of a pack's bytes. Counting the whole tree instead refuses every
868
+ * partial import on a full volume, and counting only the requested `.uasset` would let an import
869
+ * start that cannot finish. */
870
+ async function importedPackageBytes(files, wanted) {
871
+ const groups = new Map();
872
+ for (const file of files) {
873
+ const stem = packageStem(file.path);
874
+ const group = groups.get(stem) ?? { primary: file.path, bytes: 0 };
875
+ group.bytes += file.size;
876
+ if ([".uasset", ".umap"].includes(extname(file.path).toLowerCase()))
877
+ group.primary = file.path;
878
+ groups.set(stem, group);
879
+ }
880
+ const stemsByName = new Map();
881
+ for (const stem of groups.keys()) {
882
+ const name = basename(stem);
883
+ stemsByName.set(name, [...(stemsByName.get(name) ?? []), stem]);
884
+ }
885
+ let total = 0;
886
+ const counted = new Set();
887
+ let frontier = [...groups.keys()].filter((stem) => wanted.has(basename(stem)));
888
+ while (frontier.length > 0) {
889
+ const next = [];
890
+ for (const stem of frontier) {
891
+ counted.add(stem);
892
+ total += groups.get(stem).bytes;
893
+ // ponytail: a bounded byte scan for names, not a parsed import table. A name that merely
894
+ // shares a string with the package counts its bytes; a name in another encoding does not.
895
+ for (const name of await readPackageObjectNames(groups.get(stem).primary)) {
896
+ for (const referenced of stemsByName.get(name) ?? []) {
897
+ if (counted.has(referenced))
898
+ continue;
899
+ next.push(referenced);
900
+ }
901
+ }
902
+ }
903
+ frontier = next;
904
+ }
905
+ return total;
906
+ }
907
+ /**
908
+ * Why the modern converter produced nothing, in the terms that decide the next move: what it
909
+ * printed (its last lines, not only the one it exits on), which engine wrote the package, and
910
+ * whether the mesh is Nanite — the commonest reason a UE5 editor mesh has no render data.
911
+ */
912
+ function describeModernFailure(stderr, packages) {
913
+ const lines = stderr.trim().split(/\r?\n/).filter((line) => line.trim()).slice(-20);
914
+ const versions = packages.map(({ entry, legacyFileVersion, fileVersionUE4, fileVersionUE5, naniteHint }) => `${entry.package} is ${legacyFileVersion !== undefined && legacyFileVersion <= -8 ? "UE5" : "UE4"} (legacy file version ${legacyFileVersion ?? "unknown"}), UE4 object version ${fileVersionUE4 ?? "unknown"}, UE5 object version ${fileVersionUE5 ?? "unknown"}; Nanite: ${naniteHint ? "likely (NaniteSettings present)" : "not detected"}`);
915
+ return [
916
+ ...versions,
917
+ ...(lines.length > 0 ? [`Converter output (last ${lines.length} lines):`, ...lines.map((line) => ` ${line.trim()}`)] : []),
918
+ ].join(" ");
919
+ }
827
920
  /**
828
921
  * Converts a local Unreal asset directory into self-contained source GLBs plus a provenance
829
922
  * report. Provider-independent: a pack downloaded by FabCLI and one unzipped by hand take the
@@ -854,6 +947,11 @@ export async function importUnrealDirectory(request) {
854
947
  throw new ImportError("UNREAL_SOURCE_EMPTY", `"${sourceDir}" contains no .uasset or .umap files.`);
855
948
  }
856
949
  const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
950
+ // Only a single-package request is actually filtered: both converters take `--filter` for one
951
+ // package alone and convert the whole tree otherwise, so two or more still need the whole tree.
952
+ const neededBytes = request.onlyPackages?.length === 1 && request.onlyPackages[0]
953
+ ? await importedPackageBytes(files, new Set(request.onlyPackages))
954
+ : totalBytes;
857
955
  const sourceHash = await hashSourceTree(sourceDir, files);
858
956
  const umodel = request.umodel ?? (await ensureUmodel(environment, log));
859
957
  const cacheKey = createHash("sha256")
@@ -886,6 +984,7 @@ export async function importUnrealDirectory(request) {
886
984
  throw new ImportError("UNREAL_OUTPUT_COLLISION", `${outputDir} is not empty and holds no import report. Choose a new output directory.`);
887
985
  }
888
986
  }
987
+ await sweepStaleStaging(cacheRoot(environment));
889
988
  const stagingRoot = join(cacheRoot(environment), cacheKey);
890
989
  await mkdir(stagingRoot, { recursive: true });
891
990
  // The cache key identifies reusable results, not ownership of mutable scratch space. Give every
@@ -899,17 +998,17 @@ export async function importUnrealDirectory(request) {
899
998
  // Unbounded scene assembly can temporarily hold model GLBs plus a source-sized aggregate GLB.
900
999
  // Staging and output may be different volumes, so checking only the cache volume is insufficient.
901
1000
  const minimum = 2 * 1024 ** 3;
902
- const stagingRequired = totalBytes;
903
- const outputRequired = totalBytes * (request.maxTextureSize === undefined ? 2 : 1);
1001
+ const stagingRequired = neededBytes;
1002
+ const outputRequired = neededBytes * (request.maxTextureSize === undefined ? 2 : 1);
904
1003
  const stagingDevice = (await stat(staging)).dev;
905
1004
  const outputDevice = (await stat(promotionParent)).dev;
906
1005
  const required = Math.max(stagingDevice === outputDevice ? stagingRequired + outputRequired : outputRequired, minimum);
907
- const available = await freeBytes(promotionParent);
1006
+ const available = await freeBytes(promotionParent, request.freeSpaceBytes);
908
1007
  if (available < required) {
909
1008
  throw new ImportError("UNREAL_DISK_SPACE", `The import needs about ${Math.ceil(required / 1024 ** 3)} GiB on the output volume; ${Math.floor(available / 1024 ** 3)} GiB is free at ${promotionParent}. Use --max-texture-size or choose an output on a larger volume.`);
910
1009
  }
911
1010
  if (stagingDevice !== outputDevice) {
912
- const stagingAvailable = await freeBytes(staging);
1011
+ const stagingAvailable = await freeBytes(staging, request.freeSpaceBytes);
913
1012
  const separateStagingRequired = Math.max(stagingRequired, minimum);
914
1013
  if (stagingAvailable < separateStagingRequired) {
915
1014
  throw new ImportError("UNREAL_DISK_SPACE", `The import needs about ${Math.ceil(separateStagingRequired / 1024 ** 3)} GiB of staging space; ${Math.floor(stagingAvailable / 1024 ** 3)} GiB is free at ${staging}.`);
@@ -964,12 +1063,16 @@ export async function importUnrealDirectory(request) {
964
1063
  ? "static"
965
1064
  : cooking?.meshKindHint;
966
1065
  const isMapBuildData = classes.includes("MapBuildDataRegistry");
967
- const hasTexture = !isMapBuildData && (classes.includes("Texture2D") || cooking?.textureHint === true);
1066
+ // The name-table hints stand in for a listing UE Viewer could not produce. When it did list
1067
+ // the package, its classes win: a material instance names Texture2D and carries
1068
+ // AssetImportData too, and reading that as a texture drops the material entirely.
1069
+ const listed = run.code === 0 && classes.length > 0;
1070
+ const hasTexture = !isMapBuildData && (classes.includes("Texture2D") || (!listed && cooking?.textureHint === true));
968
1071
  // MapBuildDataRegistry owns transient reflection-capture cubes that are tied to baked level
969
1072
  // lighting. They are not standalone environment assets and cannot be meaningfully reused.
970
- const hasCubemap = !isMapBuildData && (classes.includes("TextureCube") || cooking?.cubemapHint === true);
1073
+ const hasCubemap = !isMapBuildData && (classes.includes("TextureCube") || (!listed && cooking?.cubemapHint === true));
971
1074
  const hasMaterial = classes.some((className) => ["Material", "Material3", "MaterialInstance", "MaterialInstanceConstant"].includes(className));
972
- const hasSound = classes.includes("SoundWave") || cooking?.soundHint === true;
1075
+ const hasSound = classes.includes("SoundWave") || (!listed && cooking?.soundHint === true);
973
1076
  const dataClass = classes.find((className) => DATA_ASSET_CLASSES.has(className)) ?? cooking?.dataClassHint;
974
1077
  const textureStackClass = classes.find((className) => TEXTURE_STACK_CLASSES.has(className)) ?? cooking?.textureStackClassHint;
975
1078
  const hasFont = classes.some((className) => className === "Font" || className === "FontFace") || cooking?.fontHint === true;
@@ -1132,64 +1235,43 @@ export async function importUnrealDirectory(request) {
1132
1235
  package: entry.package,
1133
1236
  ...(await readPackageCooking(entry.file)),
1134
1237
  }));
1135
- const modernPackages = cooking.filter(({ entry, state }) => entry.needsModernConverter || (entry.meshKind === "skeletal" && state === "uncooked"));
1238
+ const modernPackages = cooking.filter(({ entry, state, fileVersionUE4 }) => entry.needsModernConverter ||
1239
+ (entry.meshKind === "skeletal" && state === "uncooked" && uncookedMeshRoute("skeletal", fileVersionUE4) === "modern"));
1136
1240
  const modernTexturePackages = texturePackages.filter((entry) => entry.needsModernConverter);
1137
1241
  const modernMaterialPackages = materialPackages.filter((entry) => entry.needsModernConverter);
1138
1242
  const modernSoundPackages = soundPackages.filter((entry) => entry.needsModernConverter);
1139
1243
  const modernFiles = new Set(modernPackages.map(({ entry }) => entry.file));
1140
1244
  const exportableMeshPackages = assetMeshPackages.filter((entry) => !modernFiles.has(entry.file));
1141
1245
  const uncooked = cooking.filter(({ entry, state }) => entry.meshKind === "static" && state === "uncooked" && !entry.needsModernConverter);
1142
- const uncookedMeshDescription = uncooked.filter((entry) => entry.fileVersionUE4 !== undefined &&
1143
- entry.fileVersionUE4 >= 517 &&
1144
- entry.fileVersionUE4 <= 522);
1145
- const legacyRawMesh = uncooked.filter((entry) => entry.fileVersionUE4 === 516);
1146
- const unsupportedUncooked = uncooked.filter((entry) => !legacyRawMesh.includes(entry) &&
1147
- (entry.fileVersionUE4 === undefined ||
1148
- entry.fileVersionUE4 < 517 ||
1149
- entry.fileVersionUE4 > 522));
1246
+ const uncookedMeshDescription = uncooked.filter((entry) => uncookedMeshRoute("static", entry.fileVersionUE4) === "mesh-description");
1247
+ const unsupportedUncooked = uncooked.filter((entry) => uncookedMeshRoute("static", entry.fileVersionUE4) === undefined);
1150
1248
  if (unsupportedUncooked.length > 0) {
1151
1249
  const versions = [...new Set(unsupportedUncooked.map((entry) => entry.fileVersionUE4 ?? "unknown"))];
1152
- throw new ImportError("UNREAL_SOURCE_UNSUPPORTED", `${unsupportedUncooked.length} uncooked static-mesh package${unsupportedUncooked.length === 1 ? " uses" : "s use"} UE4 object version ${versions.join(", ")}. The engine-free MeshDescription path is verified for versions 517–522 (UE4.26/4.27-era source assets); refusing to guess at a different binary layout.`);
1250
+ throw new ImportError("UNREAL_SOURCE_UNSUPPORTED", `${unsupportedUncooked.length} uncooked static-mesh package${unsupportedUncooked.length === 1 ? " uses" : "s use"} UE4 object version ${versions.join(", ")}. UE Viewer reads versions below 517 and the engine-free MeshDescription path reads 517–522 (UE4.25–4.27-era source assets); refusing to guess at a different binary layout.`);
1153
1251
  }
1154
- // Packages share textures, so two exporters can write the same PNG at once and `-nooverwrite`
1155
- // would see a half-written file as done. Listing is read-only and parallel; exporting is not.
1156
- log(`Exporting ${exportableMeshPackages.length} static/skeletal mesh packages with UE Viewer…`);
1157
- const exportFailures = await mapWithConcurrency(exportableMeshPackages, 1, async (entry) => {
1252
+ /** UE Viewer's mesh export, in one place: the primary route for a mesh package, and the retry
1253
+ * for one the modern converter could not read. Returns the reason it failed, or undefined. */
1254
+ const exportMeshWithUmodel = async (entry, out) => {
1255
+ const run = () => runBounded(umodel.path, [`-path=${sourceDir}`, "-export", "-gltf", "-png", "-nooverwrite", `-out=${out}`, entry.selector], { timeoutMs: 1_800_000, maxOutputBytes: 32 * 1024 * 1024 });
1158
1256
  try {
1159
- let run = await runBounded(umodel.path, [
1160
- `-path=${sourceDir}`,
1161
- "-export",
1162
- "-gltf",
1163
- "-png",
1164
- "-nooverwrite",
1165
- `-out=${raw}`,
1166
- entry.selector,
1167
- ], { timeoutMs: 1_800_000, maxOutputBytes: 32 * 1024 * 1024 });
1168
1257
  // UE Viewer occasionally exits non-zero for one package under filesystem pressure even
1169
1258
  // though the same read-only export succeeds immediately afterward. One bounded retry keeps
1170
1259
  // a transient process failure from silently removing a mesh from an otherwise valid pack.
1171
- if (run.code !== 0) {
1172
- run = await runBounded(umodel.path, [
1173
- `-path=${sourceDir}`,
1174
- "-export",
1175
- "-gltf",
1176
- "-png",
1177
- "-nooverwrite",
1178
- `-out=${raw}`,
1179
- entry.selector,
1180
- ], { timeoutMs: 1_800_000, maxOutputBytes: 32 * 1024 * 1024 });
1181
- }
1182
- if (run.code !== 0) {
1183
- return { package: entry.package, reason: `UE Viewer export exited ${run.code}.` };
1184
- }
1185
- return undefined;
1260
+ let outcome = await run();
1261
+ if (outcome.code !== 0)
1262
+ outcome = await run();
1263
+ return outcome.code === 0 ? undefined : `UE Viewer export exited ${outcome.code}.`;
1186
1264
  }
1187
1265
  catch (error) {
1188
- return {
1189
- package: entry.package,
1190
- reason: error instanceof ToolchainError ? error.message : "UE Viewer export failed.",
1191
- };
1266
+ return error instanceof ToolchainError ? error.message : "UE Viewer export failed.";
1192
1267
  }
1268
+ };
1269
+ // Packages share textures, so two exporters can write the same PNG at once and `-nooverwrite`
1270
+ // would see a half-written file as done. Listing is read-only and parallel; exporting is not.
1271
+ log(`Exporting ${exportableMeshPackages.length} static/skeletal mesh packages with UE Viewer…`);
1272
+ const exportFailures = await mapWithConcurrency(exportableMeshPackages, 1, async (entry) => {
1273
+ const reason = await exportMeshWithUmodel(entry, raw);
1274
+ return reason ? { package: entry.package, reason } : undefined;
1193
1275
  });
1194
1276
  for (const failure of exportFailures)
1195
1277
  if (failure)
@@ -1366,9 +1448,14 @@ export async function importUnrealDirectory(request) {
1366
1448
  `-out=${animationRaw}`,
1367
1449
  entry.selector,
1368
1450
  ], { timeoutMs: 1_800_000, maxOutputBytes: 32 * 1024 * 1024 });
1369
- return run.code === 0
1370
- ? undefined
1371
- : { package: entry.package, reason: `UE Viewer animation export exited ${run.code}.` };
1451
+ if (run.code === 0)
1452
+ return undefined;
1453
+ // UE Viewer prints its reason last; without it every failure reads the same.
1454
+ const diagnostic = `${run.stdout}\n${run.stderr}`.trim().split(/\r?\n/).filter((line) => line.trim()).at(-1)?.trim();
1455
+ return {
1456
+ package: entry.package,
1457
+ reason: `UE Viewer animation export exited ${run.code}.${diagnostic ? ` ${diagnostic.slice(0, 300)}` : ""}`,
1458
+ };
1372
1459
  }
1373
1460
  catch (error) {
1374
1461
  return {
@@ -1409,6 +1496,8 @@ export async function importUnrealDirectory(request) {
1409
1496
  let uncookedGlbs = new Map();
1410
1497
  let modernConverter;
1411
1498
  let modernGlbs = new Map();
1499
+ /** Meshes UE Viewer exported after the modern converter failed on them. */
1500
+ const recoveredByUmodel = new Set();
1412
1501
  const modernSceneModelSources = [];
1413
1502
  const sceneSources = join(staging, "scene-sources");
1414
1503
  const sceneSourcePaths = new Map();
@@ -1463,26 +1552,41 @@ export async function importUnrealDirectory(request) {
1463
1552
  maxOutputBytes: 64 * 1024 * 1024,
1464
1553
  });
1465
1554
  if (converted.code !== 0) {
1466
- const diagnosticLines = converted.stderr.trim().split(/\r?\n/);
1467
- const diagnostic = diagnosticLines.find((line) => line.includes(".usmap")) ??
1468
- diagnosticLines.find((line) => /exception:/i.test(line)) ??
1469
- diagnosticLines.at(-1);
1470
- throw new ToolchainError("UNREAL_TOOL_FAILED", `The modern UE5 asset converter exited ${converted.code}; no partial output was promoted.${diagnostic ? ` ${diagnostic.trim()}` : ""}`);
1471
- }
1472
- modernGlbs = await indexGlbs(modernRaw);
1473
- assets = mergeExported(assets, await indexExported(modernRaw));
1474
- for (const entry of modernTexturePackages) {
1475
- const name = basename(entry.package, extname(entry.package));
1476
- const source = assets.png.get(name);
1477
- if (source)
1478
- textureSources.set(entry.file, source);
1479
- else
1480
- failed.push({
1481
- package: entry.package,
1482
- reason: "The modern UE5 texture converter produced no PNG for this package.",
1483
- });
1555
+ // A static mesh old enough for UE Viewer never needed the modern converter, so one
1556
+ // converter crash must not take a mesh UE Viewer can read down with it. That fallback is only
1557
+ // honest when it covers everything: recovering part of the request would drop the rest
1558
+ // silently, so anything UE Viewer cannot supply reports the converter's own diagnostic.
1559
+ const retryable = modernPackages.filter(({ entry, fileVersionUE4 }) => entry.meshKind === "static" && uncookedMeshRoute("static", fileVersionUE4) === "umodel");
1560
+ const retried = await mapWithConcurrency(retryable, 1, async ({ entry }) => ({
1561
+ entry,
1562
+ reason: await exportMeshWithUmodel(entry, raw),
1563
+ }));
1564
+ const incomplete = retried.find((result) => result.reason !== undefined);
1565
+ if (retryable.length !== modernPackages.length || incomplete) {
1566
+ throw new ToolchainError("UNREAL_TOOL_FAILED", `The modern UE5 asset converter exited ${converted.code}; no partial output was promoted. ${describeModernFailure(converted.stderr, modernPackages)}`);
1567
+ }
1568
+ for (const result of retried) {
1569
+ recoveredByUmodel.add(basename(result.entry.package, extname(result.entry.package)));
1570
+ }
1571
+ assets = mergeExported(assets, await indexExported(raw));
1572
+ warnings.push(`The modern UE5 asset converter exited ${converted.code}; UE Viewer decoded ${recoveredByUmodel.size} static mesh package${recoveredByUmodel.size === 1 ? "" : "s"} it could read itself.`);
1573
+ }
1574
+ else {
1575
+ modernGlbs = await indexGlbs(modernRaw);
1576
+ assets = mergeExported(assets, await indexExported(modernRaw));
1577
+ for (const entry of modernTexturePackages) {
1578
+ const name = basename(entry.package, extname(entry.package));
1579
+ const source = assets.png.get(name);
1580
+ if (source)
1581
+ textureSources.set(entry.file, source);
1582
+ else
1583
+ failed.push({
1584
+ package: entry.package,
1585
+ reason: "The modern UE5 texture converter produced no PNG for this package.",
1586
+ });
1587
+ }
1588
+ warnings.push(`Decoded ${modernAssetCount} requested modern UE5 asset package${modernAssetCount === 1 ? "" : "s"} without Unreal Engine.`);
1484
1589
  }
1485
- warnings.push(`Decoded ${modernAssetCount} requested modern UE5 asset package${modernAssetCount === 1 ? "" : "s"} without Unreal Engine.`);
1486
1590
  }
1487
1591
  if (modernMapPackages.length > 0) {
1488
1592
  modernConverter ??= request.modernConverter ?? (await ensureModernConverter(environment, log));
@@ -1755,7 +1859,9 @@ export async function importUnrealDirectory(request) {
1755
1859
  }
1756
1860
  warnings.push(`Decoded ${paperPackages.length} requested Paper2D package${paperPackages.length === 1 ? "" : "s"} without Unreal Engine.`);
1757
1861
  }
1758
- const modernNames = new Set(modernPackages.map(({ entry }) => basename(entry.package, extname(entry.package))));
1862
+ const modernNames = new Set(modernPackages
1863
+ .map(({ entry }) => basename(entry.package, extname(entry.package)))
1864
+ .filter((name) => !recoveredByUmodel.has(name)));
1759
1865
  const promotion = await mkdtemp(join(promotionParent, ".threenative-import-"));
1760
1866
  // Only a verified entitlement earns a copyright line. A local pack whose licence nobody
1761
1867
  // checked stays blank so the game's asset health check keeps saying "unknown".