@helix3/helix-cli 0.1.18-helix3.273 → 0.1.18-helix3.275

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 (40) hide show
  1. package/bridge-plugins/helix.bridge.unreal/bin/unreal-skeletal-mesh-extract.js +74 -10
  2. package/bridge-plugins/helix.bridge.unreal/test/exporter.test.js +87 -1
  3. package/bridge-plugins/helix.bridge.unreal/test/handler.test.js +75 -0
  4. package/bridge-plugins/helix.bridge.unreal/unreal/export_skeletal_mesh.py +64 -3
  5. package/bridge-recipes/unreal/helix-character-creator-male/male-carnival-mask-2.addon.json +1 -0
  6. package/bridge-recipes/unreal/helix-character-creator-male/male-carnival-mask-2.json +1 -0
  7. package/bridge-recipes/unreal/helix-character-creator-male/male-carnival-mask-3.addon.json +1 -0
  8. package/bridge-recipes/unreal/helix-character-creator-male/male-carnival-mask-3.json +1 -0
  9. package/bridge-recipes/unreal/helix-character-creator-male/male-eye-hole-mask-2.addon.json +1 -0
  10. package/bridge-recipes/unreal/helix-character-creator-male/male-eye-hole-mask-2.json +1 -0
  11. package/bridge-recipes/unreal/helix-character-creator-male/male-eye-hole-mask.addon.json +1 -0
  12. package/bridge-recipes/unreal/helix-character-creator-male/male-eye-hole-mask.json +1 -0
  13. package/bridge-recipes/unreal/helix-character-creator-male/male-marshmallow-mask.addon.json +1 -0
  14. package/bridge-recipes/unreal/helix-character-creator-male/male-marshmallow-mask.json +1 -0
  15. package/bridge-recipes/unreal/helix-character-creator-male/male-nebula-executive-coat.addon.json +1 -0
  16. package/bridge-recipes/unreal/helix-character-creator-male/male-nebula-executive-coat.json +1 -0
  17. package/bridge-recipes/unreal/helix-character-creator-male/male-neo-arcane-heir-mantle.addon.json +1 -0
  18. package/bridge-recipes/unreal/helix-character-creator-male/male-neo-arcane-heir-mantle.json +1 -0
  19. package/bridge-recipes/unreal/helix-character-creator-male/male-neo-arcane-heir-tunic.addon.json +1 -0
  20. package/bridge-recipes/unreal/helix-character-creator-male/male-neo-arcane-heir-tunic.json +1 -0
  21. package/bridge-recipes/unreal/helix-character-creator-male/male-paper-bag-mask.addon.json +1 -0
  22. package/bridge-recipes/unreal/helix-character-creator-male/male-paper-bag-mask.json +1 -0
  23. package/bridge-recipes/unreal/helix-character-creator-male/male-pig-mask.addon.json +1 -0
  24. package/bridge-recipes/unreal/helix-character-creator-male/male-pig-mask.json +1 -0
  25. package/bridge-recipes/unreal/helix-character-creator-male/male-puffer-jacket.addon.json +1 -0
  26. package/bridge-recipes/unreal/helix-character-creator-male/male-puffer-jacket.json +1 -0
  27. package/bridge-recipes/unreal/helix-character-creator-male/male-pullover-hoodie.addon.json +1 -0
  28. package/bridge-recipes/unreal/helix-character-creator-male/male-pullover-hoodie.json +1 -0
  29. package/bridge-recipes/unreal/helix-character-creator-male/male-sinth-ascetic-jacket.addon.json +1 -0
  30. package/bridge-recipes/unreal/helix-character-creator-male/male-sinth-ascetic-jacket.json +1 -0
  31. package/bridge-recipes/unreal/helix-character-creator-male/male-surgeon-mask.addon.json +1 -0
  32. package/bridge-recipes/unreal/helix-character-creator-male/male-surgeon-mask.json +1 -0
  33. package/bridge-recipes/unreal/helix-character-creator-male/male-tech-hoodie.addon.json +1 -0
  34. package/bridge-recipes/unreal/helix-character-creator-male/male-tech-hoodie.json +1 -0
  35. package/bridge-recipes/unreal/helix-character-creator-male/test/recipes.test.js +31 -8
  36. package/package.json +1 -1
  37. package/plugins/beamng-vehicle/bin/__pycache__/beamng_abs.cpython-312.pyc +0 -0
  38. package/plugins/beamng-vehicle/bin/__pycache__/beamng_expression.cpython-312.pyc +0 -0
  39. package/plugins/beamng-vehicle/bin/__pycache__/beamng_jbeam.cpython-312.pyc +0 -0
  40. package/plugins/beamng-vehicle/bin/__pycache__/beamng_materials.cpython-312.pyc +0 -0
@@ -39,6 +39,13 @@ const FBX_BINARY_HEADER = Buffer.from('Kaydara FBX Binary \0\x1a\0', 'binary');
39
39
  const FBX_ASCII_HEADER = /^;\s+FBX\s+\d+\.\d+\.\d+\s+project\s+file(?:\r?\n|$)/;
40
40
  const MATERIAL_SIDECAR_SCHEMA = 'helix.bridge.unreal-material-sidecar/1';
41
41
  const MAX_MATERIAL_SIDECAR_BYTES = 16 * 1024 * 1024;
42
+ // Mirrors unreal/export_skeletal_mesh.py's EXPORT_RESULTS_SCHEMA/EXPORT_RESULTS_FILENAME
43
+ // (kept in sync by hand: that script runs standalone inside UnrealEditor's Python VM, not
44
+ // through this built JS, so it cannot import from here). Optional per-asset outcome manifest:
45
+ // absent on an older exporter build, or when the editor died before writing anything, in
46
+ // which case this handler falls back to file-based validation alone (see readExportResults).
47
+ const EXPORT_RESULTS_SCHEMA = 'helix.bridge.unreal-export-results/1';
48
+ const EXPORT_RESULTS_FILENAME = '_export-results.json';
42
49
  const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
43
50
  const SHA256 = /^[a-f0-9]{64}$/;
44
51
  const TEXTURE_RELATIVE_PATH = /^textures\/([a-f0-9]{64})\.png$/;
@@ -488,6 +495,34 @@ function validateFbxOutput(output) {
488
495
  return binary || ascii ? null : `'${basename(output)}' is not a structurally valid FBX`;
489
496
  }
490
497
 
498
+ function readExportResults(resultsPath) {
499
+ if (!existsSync(resultsPath)) return null;
500
+ try {
501
+ const parsed = JSON.parse(readFileSync(resultsPath, 'utf8'));
502
+ if (parsed?.schemaVersion !== EXPORT_RESULTS_SCHEMA || !Array.isArray(parsed.results)) return null;
503
+ const bySlug = new Map();
504
+ for (const entry of parsed.results) {
505
+ if (!entry || typeof entry.slug !== 'string' || typeof entry.status !== 'string') continue;
506
+ bySlug.set(entry.slug, { status: entry.status, error: typeof entry.error === 'string' ? entry.error : undefined });
507
+ }
508
+ return bySlug;
509
+ } catch {
510
+ // A missing or unreadable manifest just means this handler falls back to file-based
511
+ // validation alone; it should never be the reason a batch that otherwise succeeded fails.
512
+ return null;
513
+ }
514
+ }
515
+
516
+ function assetOutcome(profile, output, exportResults) {
517
+ const fileIssue = validateFbxOutput(output);
518
+ const recorded = exportResults?.get(profile.slug);
519
+ if (recorded?.status === 'failed') {
520
+ return { profile, output, ok: false, reason: recorded.error || fileIssue || 'Unreal reported this asset failed to export' };
521
+ }
522
+ if (fileIssue) return { profile, output, ok: false, reason: fileIssue };
523
+ return { profile, output, ok: true };
524
+ }
525
+
491
526
  function validateNumericDictionary(value, label) {
492
527
  const dictionary = object(value, label);
493
528
  for (const [name, number] of Object.entries(dictionary)) {
@@ -705,21 +740,37 @@ async function main() {
705
740
  `-abslog=${paths.log}`,
706
741
  ], editorEnvironment(environment, paths.wsl), paths.wsl);
707
742
 
708
- const invalidOutputs = outputs.map(validateFbxOutput).filter(Boolean);
709
- if (invalidOutputs.length > 0) {
743
+ // Per-asset isolation: one bad asset in a multi-asset batch (wrong type, won't load in the
744
+ // Unreal Editor, corrupt material, ...) must not discard every other asset that exported
745
+ // cleanly. export_skeletal_mesh.py now catches a per-asset export failure and continues, so
746
+ // this reads its optional results manifest (falling back to file-based validation alone if
747
+ // it is absent) and only fails the WHOLE batch when nothing usable came out of it. See
748
+ // batch-backlog.md item 13.
749
+ const exportResults = readExportResults(join(outputDir, EXPORT_RESULTS_FILENAME));
750
+ const outcomes = profiles.map((profile, index) => assetOutcome(profile, outputs[index], exportResults));
751
+ const succeeded = outcomes.filter((outcome) => outcome.ok);
752
+ const failed = outcomes.filter((outcome) => !outcome.ok);
753
+
754
+ if (succeeded.length === 0) {
710
755
  const suffix = editorOutcome.code === 0 && !editorOutcome.signal ? '' : `; ${editorFailure(editorOutcome)}`;
711
756
  const engineLogTail = readEngineLogTail(engineLogPath, ENGINE_LOG_TAIL_BYTES);
712
757
  const logSuffix = engineLogTail
713
758
  ? `\nUnreal Editor engine log (${engineLogPath}):\n${engineLogTail}`
714
759
  : `\n(Unreal Editor engine log not found at ${engineLogPath})`;
715
- fail(`Unreal Editor did not produce the complete reviewed export set (${invalidOutputs.join(', ')})${suffix}${logSuffix}`);
760
+ const reasons = failed.map((outcome) => `${basename(outcome.output)}: ${outcome.reason}`).join(', ');
761
+ fail(`Unreal Editor did not produce the complete reviewed export set (${reasons})${suffix}${logSuffix}`);
716
762
  }
717
- const materialSidecars = profiles.map((profile) => validateMaterialSidecar(
718
- join(outputDir, `${profile.slug}.materials.json`),
719
- profile,
763
+ const materialSidecars = succeeded.map((outcome) => validateMaterialSidecar(
764
+ join(outputDir, `${outcome.profile.slug}.materials.json`),
765
+ outcome.profile,
720
766
  outputDir,
721
767
  ));
722
768
  validateTextureInventory(outputDir, materialSidecars);
769
+
770
+ // A crashed or abnormally-terminated editor still voids the WHOLE batch even if some FBXs
771
+ // happen to be valid on disk — unlike a caught per-asset export exception, we cannot trust
772
+ // that any of this run's output is complete. This matches the pre-existing behavior for a
773
+ // fully successful editor exit.
723
774
  if (editorOutcome.signal) fail(editorFailure(editorOutcome));
724
775
  if (editorOutcome.code !== 0) {
725
776
  if (!isKnownBenignPlatformValidationShutdown(editorOutcome)) fail(editorFailure(editorOutcome));
@@ -729,16 +780,29 @@ async function main() {
729
780
  `${editorOutcome.tail ? ` ${editorOutcome.tail}` : ''}\n`,
730
781
  );
731
782
  }
783
+
784
+ for (const outcome of failed) {
785
+ process.stderr.write(
786
+ `HELIX Unreal Bridge: asset '${outcome.profile.slug}' (${outcome.profile.assetPath}) failed and was ` +
787
+ `skipped; the rest of the batch was still delivered: ${outcome.reason}\n`,
788
+ );
789
+ }
790
+
732
791
  process.stdout.write(JSON.stringify({
733
- artifacts: profiles.map((profile, index) => ({
792
+ artifacts: succeeded.map((outcome, index) => ({
734
793
  kind: 'format.fbx',
735
- path: basename(outputs[index]),
794
+ path: basename(outcome.output),
736
795
  metadata: {
737
- unrealAsset: profile.assetPath,
738
- exportMorphTargets: profile.exportMorphTargets,
796
+ unrealAsset: outcome.profile.assetPath,
797
+ exportMorphTargets: outcome.profile.exportMorphTargets,
739
798
  materialSidecar: materialSidecars[index],
740
799
  },
741
800
  })),
801
+ failures: failed.map((outcome) => ({
802
+ slug: outcome.profile.slug,
803
+ assetPath: outcome.profile.assetPath,
804
+ reason: outcome.reason,
805
+ })),
742
806
  }));
743
807
  }
744
808
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  const assert = require('node:assert/strict');
4
4
  const { spawnSync } = require('node:child_process');
5
- const { mkdirSync, mkdtempSync, readFileSync, writeFileSync } = require('node:fs');
5
+ const { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } = require('node:fs');
6
6
  const { tmpdir } = require('node:os');
7
7
  const { join, resolve } = require('node:path');
8
8
  const test = require('node:test');
@@ -99,6 +99,8 @@ class EditorAssetLibrary:
99
99
  return None
100
100
  if os.environ.get('HELIX_FAKE_UNREAL_WRONG_TYPE') == '1':
101
101
  return _WrongAsset()
102
+ if path == os.environ.get('HELIX_FAKE_UNREAL_WRONG_TYPE_PATH'):
103
+ return _WrongAsset()
102
104
  return SkeletalMesh(path)
103
105
 
104
106
  class FbxExportOption:
@@ -317,6 +319,90 @@ test('omits non-Texture2D material parameters while preserving portable Texture2
317
319
  assert.equal(observed.at(-1).kind, 'quit_editor');
318
320
  });
319
321
 
322
+ test('isolates a per-asset failure: the rest of the batch still exports and a results manifest records both outcomes', () => {
323
+ // Regression for batch-backlog.md item 13: `SKM_M_Denim_Jacket` (a MaterialInstanceConstant,
324
+ // not a SkeletalMesh) used to throw out of the batch loop and abort the whole UnrealEditor
325
+ // process, so `Combat Boots` right after it in the same batch was never even attempted.
326
+ const entry = fixture();
327
+ const outputs = [
328
+ join(entry.outputDir, 'denim-jacket.fbx'),
329
+ join(entry.outputDir, 'combat-boots.fbx'),
330
+ ];
331
+ const jobs = [
332
+ { slug: 'denim-jacket', assetPath: '/Game/Characters/SKM_M_Denim_Jacket', outputPath: outputs[0], exportMorphTargets: true },
333
+ { slug: 'combat-boots', assetPath: '/Game/Characters/SKM_M_Combat_Boots', outputPath: outputs[1], exportMorphTargets: true },
334
+ ];
335
+ const result = run(entry, {
336
+ HELIX_UNREAL_EXPORTS_JSON: JSON.stringify(jobs),
337
+ HELIX_FAKE_UNREAL_WRONG_TYPE_PATH: '/Game/Characters/SKM_M_Denim_Jacket',
338
+ });
339
+ // The exporter itself exits zero: something usable came out of this batch, so it is up to
340
+ // the Node handler (see handler.test.js) to decide how to report the mixed outcome upward,
341
+ // not for the exporter to make the whole run look like a hard failure.
342
+ assert.equal(result.status, 0, result.stderr);
343
+
344
+ const observed = events(entry);
345
+ assert.deepEqual(observed.filter((event) => event.kind === 'load_asset').map((event) => event.path), [
346
+ '/Game/Characters/SKM_M_Denim_Jacket',
347
+ '/Game/Characters/SKM_M_Combat_Boots',
348
+ ], 'combat-boots must still be attempted after denim-jacket fails');
349
+ assert.ok(
350
+ observed.some((event) => event.kind === 'log_error' && /SKM_M_Denim_Jacket/.test(event.message) && /not a SkeletalMesh/.test(event.message)),
351
+ );
352
+
353
+ assert.equal(existsSync(join(entry.outputDir, 'denim-jacket.fbx')), false);
354
+ assert.match(readFileSync(join(entry.outputDir, 'combat-boots.fbx'), 'binary'), /^Kaydara FBX Binary/);
355
+ const bootsSidecar = JSON.parse(readFileSync(join(entry.outputDir, 'combat-boots.materials.json'), 'utf8'));
356
+ assert.equal(bootsSidecar.asset.slug, 'combat-boots');
357
+
358
+ const manifest = JSON.parse(readFileSync(join(entry.outputDir, '_export-results.json'), 'utf8'));
359
+ assert.equal(manifest.schemaVersion, 'helix.bridge.unreal-export-results/1');
360
+ const bySlug = Object.fromEntries(manifest.results.map((entryResult) => [entryResult.slug, entryResult]));
361
+ assert.equal(bySlug['denim-jacket'].status, 'failed');
362
+ assert.match(bySlug['denim-jacket'].error, /not a SkeletalMesh/);
363
+ assert.equal(bySlug['combat-boots'].status, 'succeeded');
364
+ assert.equal(observed.at(-1).kind, 'quit_editor', 'the editor must still shut down cleanly after a partial batch');
365
+ });
366
+
367
+ test('exits zero when a batch fully succeeds despite an earlier asset in the same batch failing', () => {
368
+ // The overall process exit code should reflect whether the batch produced anything usable
369
+ // at all, not merely whether the FIRST asset happened to succeed — this is what lets the
370
+ // Node handler treat a partial batch as a deliverable result rather than a hard failure.
371
+ const entry = fixture();
372
+ const outputs = [join(entry.outputDir, 'body.fbx'), join(entry.outputDir, 'top.fbx')];
373
+ const jobs = [
374
+ { slug: 'body', assetPath: '/Game/Broken', outputPath: outputs[0], exportMorphTargets: true },
375
+ { slug: 'top', assetPath: '/Game/Top', outputPath: outputs[1], exportMorphTargets: true },
376
+ ];
377
+ const result = run(entry, {
378
+ HELIX_UNREAL_EXPORTS_JSON: JSON.stringify(jobs),
379
+ HELIX_FAKE_UNREAL_WRONG_TYPE_PATH: '/Game/Broken',
380
+ });
381
+ assert.equal(result.status, 0, result.stderr);
382
+ assert.match(readFileSync(outputs[1], 'binary'), /^Kaydara FBX Binary/);
383
+ const manifest = JSON.parse(readFileSync(join(entry.outputDir, '_export-results.json'), 'utf8'));
384
+ const bySlug = Object.fromEntries(manifest.results.map((entryResult) => [entryResult.slug, entryResult]));
385
+ assert.equal(bySlug.body.status, 'failed', 'the manifest still records the real failure even though the batch exits zero');
386
+ assert.equal(bySlug.top.status, 'succeeded');
387
+ });
388
+
389
+ test('reports every asset failed, with no partial output, when an entire batch fails', () => {
390
+ const entry = fixture();
391
+ const outputs = [join(entry.outputDir, 'body.fbx'), join(entry.outputDir, 'top.fbx')];
392
+ const jobs = [
393
+ { slug: 'body', assetPath: '/Game/Body', outputPath: outputs[0], exportMorphTargets: true },
394
+ { slug: 'top', assetPath: '/Game/Top', outputPath: outputs[1], exportMorphTargets: true },
395
+ ];
396
+ const result = run(entry, {
397
+ HELIX_UNREAL_EXPORTS_JSON: JSON.stringify(jobs),
398
+ HELIX_FAKE_UNREAL_WRONG_TYPE: '1',
399
+ });
400
+ assert.notEqual(result.status, 0);
401
+ assert.match(result.stderr, /Every asset in this batch failed to export/);
402
+ const manifest = JSON.parse(readFileSync(join(entry.outputDir, '_export-results.json'), 'utf8'));
403
+ assert.ok(manifest.results.every((entryResult) => entryResult.status === 'failed'));
404
+ });
405
+
320
406
  test('fails atomically when Unreal cannot export a referenced Texture2D', () => {
321
407
  const entry = fixture();
322
408
  const result = run(entry, {
@@ -52,7 +52,9 @@ function fixture(profileOverrides = {}) {
52
52
  "const texturePath = join(textureDirectory, textureSha + '.png');",
53
53
  "writeFileSync(texturePath, textureBytes);",
54
54
  "if (process.env.HELIX_TEST_TEXTURE_KIND === 'unreferenced') writeFileSync(join(textureDirectory, 'e'.repeat(64) + '.png'), textureBytes);",
55
+ "const failSlugs = new Set((process.env.HELIX_TEST_FAIL_SLUGS || '').split(',').filter(Boolean));",
55
56
  "for (const job of jobs) {",
57
+ " if (failSlugs.has(job.slug)) continue;", // simulates export_skeletal_mesh.py catching this job's failure and moving on without writing any output for it.
56
58
  " if (process.env.HELIX_TEST_OUTPUT_KIND === 'missing') continue;",
57
59
  " if (process.env.HELIX_TEST_OUTPUT_KIND === 'empty') writeFileSync(job.outputPath, Buffer.alloc(0));",
58
60
  " else if (process.env.HELIX_TEST_OUTPUT_KIND === 'corrupt') writeFileSync(job.outputPath, 'fake-fbx');",
@@ -69,6 +71,17 @@ function fixture(profileOverrides = {}) {
69
71
  " else if (process.env.HELIX_TEST_SIDECAR_KIND === 'symlink') symlinkSync(process.env.HELIX_TEST_INVOCATION, sidecarPath);",
70
72
  " else writeFileSync(sidecarPath, JSON.stringify(sidecar));",
71
73
  "}",
74
+ // Simulates export_skeletal_mesh.py's own '_export-results.json' per-asset outcome manifest
75
+ // (see EXPORT_RESULTS_SCHEMA/EXPORT_RESULTS_FILENAME) so the handler's partial-batch
76
+ // path — reading that manifest rather than only inferring outcomes from file presence —
77
+ // gets real end-to-end coverage here, not just in exporter.test.js's direct Python tests.
78
+ "if (process.env.HELIX_TEST_FAIL_SLUGS) {",
79
+ " const resultsPath = join(dirname(jobs[0].outputPath), '_export-results.json');",
80
+ " const results = jobs.map((job) => failSlugs.has(job.slug)",
81
+ " ? { slug: job.slug, assetPath: job.assetPath, outputPath: job.outputPath, status: 'failed', error: `Unreal asset ${JSON.stringify(job.assetPath)} is MaterialInstanceConstant, not a SkeletalMesh` }",
82
+ " : { slug: job.slug, assetPath: job.assetPath, outputPath: job.outputPath, status: 'succeeded' });",
83
+ " writeFileSync(resultsPath, JSON.stringify({ schemaVersion: 'helix.bridge.unreal-export-results/1', results }));",
84
+ "}",
72
85
  "if (process.env.HELIX_TEST_EDITOR_LOG) process.stderr.write(process.env.HELIX_TEST_EDITOR_LOG);",
73
86
  "if (process.env.HELIX_TEST_ABSLOG_CONTENT) {",
74
87
  " const abslogArg = process.argv.slice(2).find((arg) => arg.startsWith('-abslog='));",
@@ -297,6 +310,68 @@ test('exports a reviewed modular asset set in one editor invocation', () => {
297
310
  assert.match(readFileSync(join(entry.output, 'male-top.fbx'), 'binary'), /^Kaydara FBX Binary/);
298
311
  });
299
312
 
313
+ test('delivers the successful half of a batch when one asset fails, instead of discarding the whole run', () => {
314
+ // Regression for batch-backlog.md item 13: a batch naming SKM_M_Denim_Jacket (which won't
315
+ // load as a SkeletalMesh) + Combat Boots used to produce NOTHING — Combat Boots was never
316
+ // even attempted because the first failure aborted the whole extraction.
317
+ const entry = fixture();
318
+ writeFileSync(entry.profilePath, JSON.stringify({
319
+ schemaVersion: 'helix.bridge.unreal-asset-set/1',
320
+ assets: [
321
+ { slug: 'denim-jacket', assetPath: '/Game/Characters/SKM_M_Denim_Jacket', exportMorphTargets: true },
322
+ { slug: 'combat-boots', assetPath: '/Game/Characters/SKM_M_Combat_Boots', exportMorphTargets: true },
323
+ ],
324
+ }));
325
+ const result = run(entry, { HELIX_TEST_FAIL_SLUGS: 'denim-jacket' });
326
+ assert.equal(result.status, 0, result.stderr);
327
+
328
+ const response = JSON.parse(result.stdout);
329
+ assert.deepEqual(response.artifacts.map((artifact) => artifact.path), ['combat-boots.fbx']);
330
+ assert.equal(response.artifacts[0].metadata.unrealAsset, '/Game/Characters/SKM_M_Combat_Boots');
331
+ assert.deepEqual(response.failures, [{
332
+ slug: 'denim-jacket',
333
+ assetPath: '/Game/Characters/SKM_M_Denim_Jacket',
334
+ reason: 'Unreal asset "/Game/Characters/SKM_M_Denim_Jacket" is MaterialInstanceConstant, not a SkeletalMesh',
335
+ }]);
336
+
337
+ assert.match(result.stderr, /denim-jacket.*failed and was.*skipped/s);
338
+ assert.match(readFileSync(join(entry.output, 'combat-boots.fbx'), 'binary'), /^Kaydara FBX Binary/);
339
+ const sidecar = JSON.parse(readFileSync(join(entry.output, 'combat-boots.materials.json'), 'utf8'));
340
+ assert.equal(sidecar.asset.slug, 'combat-boots');
341
+ });
342
+
343
+ test('still fails the whole batch when every asset in it fails', () => {
344
+ const entry = fixture();
345
+ writeFileSync(entry.profilePath, JSON.stringify({
346
+ schemaVersion: 'helix.bridge.unreal-asset-set/1',
347
+ assets: [
348
+ { slug: 'base-male', assetPath: '/Game/Characters/Male/SK_BaseMale', exportMorphTargets: true },
349
+ { slug: 'male-top', assetPath: '/Game/Characters/Male/SK_Top', exportMorphTargets: false },
350
+ ],
351
+ }));
352
+ const result = run(entry, { HELIX_TEST_FAIL_SLUGS: 'base-male,male-top' });
353
+ assert.equal(result.status, 1);
354
+ assert.match(result.stderr, /did not produce the complete reviewed export set/);
355
+ assert.match(result.stderr, /base-male\.fbx.*MaterialInstanceConstant/);
356
+ assert.match(result.stderr, /male-top\.fbx.*MaterialInstanceConstant/);
357
+ });
358
+
359
+ test('a crashed editor still voids the whole batch even if the results manifest shows a success', () => {
360
+ // Per-asset isolation only ever softens a caught Python exception. It must not weaken the
361
+ // existing hard gate on the editor process itself dying — that state can't be trusted.
362
+ const entry = fixture();
363
+ writeFileSync(entry.profilePath, JSON.stringify({
364
+ schemaVersion: 'helix.bridge.unreal-asset-set/1',
365
+ assets: [
366
+ { slug: 'base-male', assetPath: '/Game/Characters/Male/SK_BaseMale', exportMorphTargets: true },
367
+ { slug: 'male-top', assetPath: '/Game/Characters/Male/SK_Top', exportMorphTargets: false },
368
+ ],
369
+ }));
370
+ const result = run(entry, { HELIX_TEST_FAIL_SLUGS: 'male-top', HELIX_TEST_EDITOR_SIGNAL: 'SIGTERM' });
371
+ assert.equal(result.status, 1);
372
+ assert.match(result.stderr, /Unreal Editor failed with signal SIGTERM/);
373
+ });
374
+
300
375
  test('rejects malformed asset profiles before starting Unreal', () => {
301
376
  const entry = fixture({ assetPath: '../Content/SK_BaseMale' });
302
377
  const result = run(entry);
@@ -15,6 +15,16 @@ UNREAL_OBJECT_PATH = re.compile(
15
15
  r"^/[A-Za-z0-9_+]+/(?:[A-Za-z0-9_+]+/)*[A-Za-z0-9_+]+(?:\.[A-Za-z0-9_+]+)?$"
16
16
  )
17
17
 
18
+ # Mirrors bin/unreal-skeletal-mesh-extract.js's EXPORT_RESULTS_FILENAME/EXPORT_RESULTS_SCHEMA
19
+ # (kept in sync by hand: this plugin script runs standalone inside UnrealEditor's Python VM,
20
+ # not through the built CLI, so it cannot import that module). This is the per-asset results
21
+ # manifest that lets a batch survive one bad asset: each job's outcome is recorded here so the
22
+ # Node wrapper can tell "this asset really failed" apart from "the editor crashed before
23
+ # writing anything", and still deliver every other asset's FBX/sidecar/textures as usable
24
+ # output instead of discarding the whole batch (see batch-backlog.md item 13).
25
+ EXPORT_RESULTS_SCHEMA = "helix.bridge.unreal-export-results/1"
26
+ EXPORT_RESULTS_FILENAME = "_export-results.json"
27
+
18
28
 
19
29
  def required_environment(name: str) -> str:
20
30
  value = os.environ.get(name, "").strip()
@@ -288,8 +298,21 @@ def export_jobs():
288
298
  return jobs
289
299
 
290
300
 
301
+ def write_export_results(output_dir: str, results) -> None:
302
+ payload = {
303
+ "schemaVersion": EXPORT_RESULTS_SCHEMA,
304
+ "results": results,
305
+ }
306
+ results_path = os.path.join(output_dir, EXPORT_RESULTS_FILENAME)
307
+ with open(results_path, "w", encoding="utf-8", newline="\n") as stream:
308
+ json.dump(payload, stream, indent=2, sort_keys=True, allow_nan=False)
309
+ stream.write("\n")
310
+
311
+
291
312
  def main() -> None:
292
313
  texture_cache = {}
314
+ results = []
315
+ output_dir = None
293
316
  for job in export_jobs():
294
317
  if not isinstance(job, dict):
295
318
  raise RuntimeError("Each Unreal export job must be an object")
@@ -306,12 +329,50 @@ def main() -> None:
306
329
  raise RuntimeError(
307
330
  "Unreal export jobs require slug, assetPath, outputPath, and a boolean exportMorphTargets"
308
331
  )
309
- export_asset({
332
+ if output_dir is None:
333
+ output_dir = os.path.dirname(output_path)
334
+
335
+ # A single bad asset (wrong type, won't load, corrupt material, ...) must not discard
336
+ # every other asset already queued in this batch commandlet run: catch it, record it,
337
+ # and move on. See batch-backlog.md item 13 — this used to propagate straight out of
338
+ # the loop and abort the whole UnrealEditor process, silently dropping every
339
+ # not-yet-processed asset even when they had nothing wrong with them.
340
+ try:
341
+ export_asset({
342
+ "slug": slug,
343
+ "assetPath": asset_path,
344
+ "outputPath": output_path,
345
+ "exportMorphTargets": export_morph_targets,
346
+ }, texture_cache)
347
+ except Exception as exc:
348
+ message = str(exc) or exc.__class__.__name__
349
+ unreal.log_error(
350
+ f"HELIX Bridge failed to export {asset_path!r} (slug {slug!r}); skipping it and "
351
+ f"continuing with the rest of the batch: {message}\n" + traceback.format_exc()
352
+ )
353
+ results.append({
354
+ "slug": slug,
355
+ "assetPath": asset_path,
356
+ "outputPath": output_path,
357
+ "status": "failed",
358
+ "error": message,
359
+ })
360
+ continue
361
+ results.append({
310
362
  "slug": slug,
311
363
  "assetPath": asset_path,
312
364
  "outputPath": output_path,
313
- "exportMorphTargets": export_morph_targets,
314
- }, texture_cache)
365
+ "status": "succeeded",
366
+ })
367
+
368
+ if output_dir is not None:
369
+ write_export_results(output_dir, results)
370
+
371
+ failed = [result for result in results if result["status"] == "failed"]
372
+ succeeded = [result for result in results if result["status"] == "succeeded"]
373
+ if failed and not succeeded:
374
+ summary = "; ".join(f"{result['slug']}: {result['error']}" for result in failed)
375
+ raise RuntimeError(f"Every asset in this batch failed to export ({summary})")
315
376
 
316
377
 
317
378
  try:
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge-character-addon-profile/1", "title": "Carnival Mask 2", "slug": "male-carnival-mask-2", "hostItemId": "b45e4a1e-0000-4000-8000-000000000001", "hostSha256": "3fdb769bf0dbcc0e1b1b1ae8dd800d6ddedfda976c6422d4de15b80ce71c132b", "slotType": "character.mask@1", "socket": "head", "nodes": [], "ops": []}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge.unreal-asset/1", "slug": "male-carnival-mask-2", "assetPath": "/HelixCharacterCreator/Characters/Clothes/Man/M_Carnival_Mask_2/M_Carnival_Mask_2", "exportMorphTargets": true}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge-character-addon-profile/1", "title": "Carnival Mask 3", "slug": "male-carnival-mask-3", "hostItemId": "b45e4a1e-0000-4000-8000-000000000001", "hostSha256": "3fdb769bf0dbcc0e1b1b1ae8dd800d6ddedfda976c6422d4de15b80ce71c132b", "slotType": "character.mask@1", "socket": "head", "nodes": [], "ops": []}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge.unreal-asset/1", "slug": "male-carnival-mask-3", "assetPath": "/HelixCharacterCreator/Characters/Clothes/Man/M_Carnival_Mask_3/M_Carnival_Mask_3", "exportMorphTargets": true}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge-character-addon-profile/1", "title": "Eye Hole Mask 2", "slug": "male-eye-hole-mask-2", "hostItemId": "b45e4a1e-0000-4000-8000-000000000001", "hostSha256": "3fdb769bf0dbcc0e1b1b1ae8dd800d6ddedfda976c6422d4de15b80ce71c132b", "slotType": "character.mask@1", "socket": "head", "nodes": [], "ops": []}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge.unreal-asset/1", "slug": "male-eye-hole-mask-2", "assetPath": "/HelixCharacterCreator/Characters/Clothes/Man/M_Eye_Hole_Mask_2/M_Eye_Hole_Mask_2_skinning", "exportMorphTargets": true}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge-character-addon-profile/1", "title": "Eye Hole Mask", "slug": "male-eye-hole-mask", "hostItemId": "b45e4a1e-0000-4000-8000-000000000001", "hostSha256": "3fdb769bf0dbcc0e1b1b1ae8dd800d6ddedfda976c6422d4de15b80ce71c132b", "slotType": "character.mask@1", "socket": "head", "nodes": [], "ops": []}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge.unreal-asset/1", "slug": "male-eye-hole-mask", "assetPath": "/HelixCharacterCreator/Characters/Clothes/Man/M_Eye_Hole_Mask/M_Eye_Hole_Mask_skinning", "exportMorphTargets": true}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge-character-addon-profile/1", "title": "Marshmallow Mask", "slug": "male-marshmallow-mask", "hostItemId": "b45e4a1e-0000-4000-8000-000000000001", "hostSha256": "3fdb769bf0dbcc0e1b1b1ae8dd800d6ddedfda976c6422d4de15b80ce71c132b", "slotType": "character.mask@1", "socket": "head", "nodes": [], "ops": []}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge.unreal-asset/1", "slug": "male-marshmallow-mask", "assetPath": "/HelixCharacterCreator/Characters/Clothes/Man/Marshmallow_Mask/M_Marshmallow_Mask", "exportMorphTargets": true}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge-character-addon-profile/1", "title": "Nebula Executive Coat", "slug": "male-nebula-executive-coat", "hostItemId": "b45e4a1e-0000-4000-8000-000000000001", "hostSha256": "3fdb769bf0dbcc0e1b1b1ae8dd800d6ddedfda976c6422d4de15b80ce71c132b", "slotType": "character.top@1", "socket": "chest", "nodes": [], "ops": []}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge.unreal-asset/1", "slug": "male-nebula-executive-coat", "assetPath": "/HelixCharacterCreator/Characters/Clothes/Man/Nebula_Executive_Coat/SKM_M_Nebula_Executive_West_Coat", "exportMorphTargets": true}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge-character-addon-profile/1", "title": "NEO Arcane Heir Mantle", "slug": "male-neo-arcane-heir-mantle", "hostItemId": "b45e4a1e-0000-4000-8000-000000000001", "hostSha256": "3fdb769bf0dbcc0e1b1b1ae8dd800d6ddedfda976c6422d4de15b80ce71c132b", "slotType": "character.top@1", "socket": "chest", "nodes": [], "ops": []}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge.unreal-asset/1", "slug": "male-neo-arcane-heir-mantle", "assetPath": "/HelixCharacterCreator/Characters/Clothes/Man/NEO_Arcane_Heir_Mantle/SKM_M_NEO_Arcane_Heir_Mantle", "exportMorphTargets": true}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge-character-addon-profile/1", "title": "NEO Arcane Heir Tunic", "slug": "male-neo-arcane-heir-tunic", "hostItemId": "b45e4a1e-0000-4000-8000-000000000001", "hostSha256": "3fdb769bf0dbcc0e1b1b1ae8dd800d6ddedfda976c6422d4de15b80ce71c132b", "slotType": "character.top@1", "socket": "chest", "nodes": [], "ops": []}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge.unreal-asset/1", "slug": "male-neo-arcane-heir-tunic", "assetPath": "/HelixCharacterCreator/Characters/Clothes/Man/NEO_Arcane_Heir_Tunic/SKM_M_NEO_Arcane_Heir_Tunic", "exportMorphTargets": true}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge-character-addon-profile/1", "title": "Paper Bag Mask", "slug": "male-paper-bag-mask", "hostItemId": "b45e4a1e-0000-4000-8000-000000000001", "hostSha256": "3fdb769bf0dbcc0e1b1b1ae8dd800d6ddedfda976c6422d4de15b80ce71c132b", "slotType": "character.mask@1", "socket": "head", "nodes": [], "ops": []}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge.unreal-asset/1", "slug": "male-paper-bag-mask", "assetPath": "/HelixCharacterCreator/Characters/Clothes/Man/M_Paper_Bag_Mask/M_Paper_Bag_Mask", "exportMorphTargets": true}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge-character-addon-profile/1", "title": "Pig Mask", "slug": "male-pig-mask", "hostItemId": "b45e4a1e-0000-4000-8000-000000000001", "hostSha256": "3fdb769bf0dbcc0e1b1b1ae8dd800d6ddedfda976c6422d4de15b80ce71c132b", "slotType": "character.mask@1", "socket": "head", "nodes": [], "ops": []}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge.unreal-asset/1", "slug": "male-pig-mask", "assetPath": "/HelixCharacterCreator/Characters/Clothes/Man/M_Pig_Mask/M_Pig_Mask", "exportMorphTargets": true}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge-character-addon-profile/1", "title": "Puffer Jacket", "slug": "male-puffer-jacket", "hostItemId": "b45e4a1e-0000-4000-8000-000000000001", "hostSha256": "3fdb769bf0dbcc0e1b1b1ae8dd800d6ddedfda976c6422d4de15b80ce71c132b", "slotType": "character.top@1", "socket": "chest", "nodes": [], "ops": []}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge.unreal-asset/1", "slug": "male-puffer-jacket", "assetPath": "/HelixCharacterCreator/Characters/Clothes/Man/Puffer_Jacket/SKM_M_Puffer_Jacket", "exportMorphTargets": true}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge-character-addon-profile/1", "title": "Pullover Hoodie", "slug": "male-pullover-hoodie", "hostItemId": "b45e4a1e-0000-4000-8000-000000000001", "hostSha256": "3fdb769bf0dbcc0e1b1b1ae8dd800d6ddedfda976c6422d4de15b80ce71c132b", "slotType": "character.top@1", "socket": "chest", "nodes": [], "ops": []}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge.unreal-asset/1", "slug": "male-pullover-hoodie", "assetPath": "/HelixCharacterCreator/Characters/Clothes/Man/Pullover_Hoodie/SKM_M_Pullover_Hoodie", "exportMorphTargets": true}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge-character-addon-profile/1", "title": "Sinth Ascetic Jacket", "slug": "male-sinth-ascetic-jacket", "hostItemId": "b45e4a1e-0000-4000-8000-000000000001", "hostSha256": "3fdb769bf0dbcc0e1b1b1ae8dd800d6ddedfda976c6422d4de15b80ce71c132b", "slotType": "character.top@1", "socket": "chest", "nodes": [], "ops": []}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge.unreal-asset/1", "slug": "male-sinth-ascetic-jacket", "assetPath": "/HelixCharacterCreator/Characters/Clothes/Man/Sinth_Ascetic_Jacket/SKM_M_Sinth_Ascetic_Jacket", "exportMorphTargets": true}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge-character-addon-profile/1", "title": "Surgeon Mask", "slug": "male-surgeon-mask", "hostItemId": "b45e4a1e-0000-4000-8000-000000000001", "hostSha256": "3fdb769bf0dbcc0e1b1b1ae8dd800d6ddedfda976c6422d4de15b80ce71c132b", "slotType": "character.mask@1", "socket": "head", "nodes": [], "ops": []}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge.unreal-asset/1", "slug": "male-surgeon-mask", "assetPath": "/HelixCharacterCreator/Characters/Clothes/Man/M_Surgeon_Mask/M_Surgeon_Mask_skinning", "exportMorphTargets": true}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge-character-addon-profile/1", "title": "Tech Hoodie", "slug": "male-tech-hoodie", "hostItemId": "b45e4a1e-0000-4000-8000-000000000001", "hostSha256": "3fdb769bf0dbcc0e1b1b1ae8dd800d6ddedfda976c6422d4de15b80ce71c132b", "slotType": "character.top@1", "socket": "chest", "nodes": [], "ops": []}
@@ -0,0 +1 @@
1
+ {"schemaVersion": "helix.bridge.unreal-asset/1", "slug": "male-tech-hoodie", "assetPath": "/HelixCharacterCreator/Characters/Clothes/Man/Tech_Hoodie/SKM_M_Tech_Hoodie", "exportMorphTargets": true}
@@ -8,15 +8,28 @@ const test = require('node:test');
8
8
  const directory = resolve(__dirname, '..');
9
9
  const hostItemId = 'b45e4a1e-0000-4000-8000-000000000001';
10
10
 
11
+ // The fixed, one-representative-item-per-customizer-slot pilot set that
12
+ // `all.json` is a canonical batch of. This list is intentionally NOT derived
13
+ // from a directory scan: the batch phase adds many more `<item-slug>.json` /
14
+ // `<item-slug>.addon.json` recipe pairs to this same directory (one per
15
+ // wearable, not one per slot), and those must coexist without being folded
16
+ // into — or breaking the identity check against — this specific pilot batch.
17
+ const canonicalSlotFiles = [
18
+ 'base-male.json',
19
+ 'base-male-face.json',
20
+ 'back.json', 'bottom.json', 'eyewear.json', 'footwear.json', 'gloves.json',
21
+ 'hair.json', 'headwear.json', 'mask.json', 'neck.json', 'outfit.json', 'top.json',
22
+ ];
23
+ const canonicalSlots = [
24
+ 'back', 'bottom', 'eyewear', 'footwear', 'gloves', 'hair', 'headwear', 'mask', 'neck', 'outfit', 'top',
25
+ ];
26
+
11
27
  function read(file) {
12
28
  return JSON.parse(readFileSync(join(directory, file), 'utf8'));
13
29
  }
14
30
 
15
31
  test('keeps the canonical batch identical to its individual source profiles', () => {
16
- const files = readdirSync(directory)
17
- .filter((file) => file.endsWith('.json') && !['all.json', 'palette-pilot.json'].includes(file) && !file.endsWith('.addon.json'))
18
- .sort();
19
- const profiles = files.map(read);
32
+ const profiles = canonicalSlotFiles.map(read);
20
33
  const batch = read('all.json');
21
34
  assert.equal(batch.schemaVersion, 'helix.bridge.unreal-asset-set/1');
22
35
  assert.deepEqual(
@@ -26,11 +39,19 @@ test('keeps the canonical batch identical to its individual source profiles', ()
26
39
  assert.equal(new Set(batch.assets.map((asset) => asset.slug)).size, batch.assets.length);
27
40
  });
28
41
 
29
- test('pairs every wearable source with a runtime customizer add-on profile', () => {
42
+ test('every add-on profile in this directory pairs with a runtime customizer add-on profile of matching shape', () => {
43
+ // Covers every `.addon.json` file present — the fixed 11-slot pilot set AND
44
+ // any number of additional per-item batch-phase recipes — rather than a
45
+ // closed exact-list. A new wearable's recipe pair must satisfy the same
46
+ // schema contract as the originals; it does not need to be one of the 11
47
+ // canonical slot names, and there may be many per slot.
30
48
  const addonFiles = readdirSync(directory).filter((file) => file.endsWith('.addon.json')).sort();
31
- assert.deepEqual(addonFiles.map((file) => basename(file, '.addon.json')), [
32
- 'back', 'bottom', 'eyewear', 'footwear', 'gloves', 'hair', 'headwear', 'mask', 'neck', 'outfit', 'top',
33
- ]);
49
+ // The 11 canonical per-slot pilot add-ons must still exist under their
50
+ // original slot-named files.
51
+ for (const slot of canonicalSlots) {
52
+ assert.ok(addonFiles.includes(`${slot}.addon.json`), `missing canonical ${slot}.addon.json`);
53
+ }
54
+ const seenSlugs = new Set();
34
55
  for (const file of addonFiles) {
35
56
  const addon = read(file);
36
57
  const source = read(`${basename(file, '.addon.json')}.json`);
@@ -39,6 +60,8 @@ test('pairs every wearable source with a runtime customizer add-on profile', ()
39
60
  assert.equal(addon.hostItemId, hostItemId);
40
61
  assert.match(addon.slotType, /^character\.[a-z]+@1$/);
41
62
  assert.equal(addon.slotType.startsWith('Cosmetic.Slot.'), false);
63
+ assert.equal(seenSlugs.has(addon.slug), false, `duplicate add-on slug ${addon.slug} (${file})`);
64
+ seenSlugs.add(addon.slug);
42
65
  }
43
66
  });
44
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helix3/helix-cli",
3
- "version": "0.1.18-helix3.273",
3
+ "version": "0.1.18-helix3.275",
4
4
  "description": "helix — the HELIX creator CLI: scaffold, validate, and publish Instant Worlds",
5
5
  "main": "dist/lib.js",
6
6
  "types": "dist/lib.d.ts",