@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.
- package/bridge-plugins/helix.bridge.unreal/bin/unreal-skeletal-mesh-extract.js +74 -10
- package/bridge-plugins/helix.bridge.unreal/test/exporter.test.js +87 -1
- package/bridge-plugins/helix.bridge.unreal/test/handler.test.js +75 -0
- package/bridge-plugins/helix.bridge.unreal/unreal/export_skeletal_mesh.py +64 -3
- package/bridge-recipes/unreal/helix-character-creator-male/male-carnival-mask-2.addon.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-carnival-mask-2.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-carnival-mask-3.addon.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-carnival-mask-3.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-eye-hole-mask-2.addon.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-eye-hole-mask-2.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-eye-hole-mask.addon.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-eye-hole-mask.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-marshmallow-mask.addon.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-marshmallow-mask.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-nebula-executive-coat.addon.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-nebula-executive-coat.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-neo-arcane-heir-mantle.addon.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-neo-arcane-heir-mantle.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-neo-arcane-heir-tunic.addon.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-neo-arcane-heir-tunic.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-paper-bag-mask.addon.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-paper-bag-mask.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-pig-mask.addon.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-pig-mask.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-puffer-jacket.addon.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-puffer-jacket.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-pullover-hoodie.addon.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-pullover-hoodie.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-sinth-ascetic-jacket.addon.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-sinth-ascetic-jacket.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-surgeon-mask.addon.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-surgeon-mask.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-tech-hoodie.addon.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/male-tech-hoodie.json +1 -0
- package/bridge-recipes/unreal/helix-character-creator-male/test/recipes.test.js +31 -8
- package/package.json +1 -1
- package/plugins/beamng-vehicle/bin/__pycache__/beamng_abs.cpython-312.pyc +0 -0
- package/plugins/beamng-vehicle/bin/__pycache__/beamng_expression.cpython-312.pyc +0 -0
- package/plugins/beamng-vehicle/bin/__pycache__/beamng_jbeam.cpython-312.pyc +0 -0
- 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
|
-
|
|
709
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
792
|
+
artifacts: succeeded.map((outcome, index) => ({
|
|
734
793
|
kind: 'format.fbx',
|
|
735
|
-
path: basename(
|
|
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
|
-
|
|
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
|
-
"
|
|
314
|
-
}
|
|
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}
|
package/bridge-recipes/unreal/helix-character-creator-male/male-nebula-executive-coat.addon.json
ADDED
|
@@ -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}
|
package/bridge-recipes/unreal/helix-character-creator-male/male-neo-arcane-heir-mantle.addon.json
ADDED
|
@@ -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}
|
package/bridge-recipes/unreal/helix-character-creator-male/male-neo-arcane-heir-tunic.addon.json
ADDED
|
@@ -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}
|
package/bridge-recipes/unreal/helix-character-creator-male/male-sinth-ascetic-jacket.addon.json
ADDED
|
@@ -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
|
|
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('
|
|
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
|
-
|
|
32
|
-
|
|
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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|