@forgeax/engine-pack 0.1.29 → 0.1.31

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/README.md +16 -0
  2. package/dist/build.mjs +65 -9
  3. package/dist/build.mjs.map +1 -1
  4. package/dist/catalog-projection.d.ts +3 -3
  5. package/dist/cli-asset.mjs +1 -1
  6. package/dist/cli-asset.mjs.map +1 -1
  7. package/dist/evidence/material-cook.d.ts +7 -1
  8. package/dist/evidence/material-cook.d.ts.map +1 -1
  9. package/dist/index.mjs +61 -5
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/material-cook.mjs +61 -5
  12. package/dist/material-cook.mjs.map +1 -1
  13. package/dist/pack-authoring-node.mjs +1 -1
  14. package/dist/pack-authoring-node.mjs.map +1 -1
  15. package/dist/pack-authoring.d.ts +8 -2
  16. package/dist/pack-authoring.d.ts.map +1 -1
  17. package/dist/pack-authoring.mjs.map +1 -1
  18. package/dist/runtime-publication.d.ts +2 -0
  19. package/dist/runtime-publication.d.ts.map +1 -1
  20. package/dist/scanner.mjs +1 -1
  21. package/dist/scanner.mjs.map +1 -1
  22. package/dist/scriptable-pack-node.d.ts.map +1 -1
  23. package/dist/scriptable-pack-node.mjs +1 -1
  24. package/dist/scriptable-pack-node.mjs.map +1 -1
  25. package/dist/scriptable-pack-worker.mjs +7 -4
  26. package/dist/scriptable-pack-worker.mjs.map +1 -1
  27. package/dist/scriptable-pack.d.ts +2 -1
  28. package/dist/scriptable-pack.d.ts.map +1 -1
  29. package/dist/scriptable-pack.mjs.map +1 -1
  30. package/package.json +2 -2
  31. package/src/__tests__/material-cook-schema.unit.test.ts +92 -1
  32. package/src/__tests__/runtime-publication.unit.test.ts +36 -0
  33. package/src/__tests__/scriptable-pack-cli.integration.test.ts +35 -1
  34. package/src/evidence/material-cook.ts +79 -5
  35. package/src/pack-authoring.ts +11 -2
  36. package/src/runtime-publication.ts +7 -3
  37. package/src/schema/material-cook.schema.json +107 -0
  38. package/src/scriptable-pack-node.ts +5 -1
  39. package/src/scriptable-pack-worker.ts +15 -4
  40. package/src/scriptable-pack.ts +2 -1
@@ -0,0 +1,36 @@
1
+ import { expect, it } from 'vitest';
2
+ import { createRuntimePackPublication } from '../runtime-publication.js';
3
+
4
+ it('preserves declared source keys in derived publication rows and their digest', () => {
5
+ const input = {
6
+ pack: {
7
+ assets: [
8
+ { guid: 'WALL-GUID', kind: 'volume', payload: {} },
9
+ { guid: 'CONTROL-GUID', kind: 'mesh', payload: {} },
10
+ ],
11
+ },
12
+ scopeId: 'lab',
13
+ sourcePath: 'wall.pack.json',
14
+ sourceRevision: 'source-1',
15
+ packageUrl: '/wall.pack.json',
16
+ };
17
+ const before = createRuntimePackPublication(input);
18
+ const after = createRuntimePackPublication({
19
+ ...input,
20
+ sourceKeys: new Map([['wall-guid', 'wall/main']]),
21
+ });
22
+ expect(after.publication.outputs.map(({ guid, sourceKey }) => [guid, sourceKey])).toEqual([
23
+ ['wall-guid', 'wall/main'],
24
+ ['control-guid', 'control-guid'],
25
+ ]);
26
+ expect(after.pack.digest).toBe(before.pack.digest);
27
+ expect(after.pack.outputSetDigest).not.toBe(before.pack.outputSetDigest);
28
+ expect(after.publication.receipt.outputSetDigest).toBe(after.pack.outputSetDigest);
29
+ // Explicit producer output rows remain authoritative when provided.
30
+ const explicit = createRuntimePackPublication({
31
+ ...input,
32
+ sourceKeys: new Map([['wall-guid', 'ignored']]),
33
+ outputs: after.publication.outputs,
34
+ });
35
+ expect(explicit.publication.outputs).toEqual(after.publication.outputs);
36
+ });
@@ -1,12 +1,46 @@
1
1
  // @perf-budget-skip: intentional ScriptablePack CLI integration gate.
2
2
 
3
- import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
+ import { err } from '@forgeax/engine-types';
6
7
  import { describe, expect, it } from 'vitest';
7
8
  import { runCliAsset } from '../cli-asset.js';
9
+ import { loadScriptablePack } from '../scriptable-pack-node.js';
8
10
 
9
11
  describe('ScriptablePack CLI Meta inspection', () => {
12
+ it('executes TypeScript re-export closures using JavaScript specifiers and directory indexes', async () => {
13
+ const root = await mkdtemp(join(tmpdir(), 'forgeax-pack-ts-closure-'));
14
+ try {
15
+ await mkdir(join(root, 'nested'));
16
+ await writeFile(join(root, 'nested/index.ts'), 'export const count: number = 7;');
17
+ await writeFile(join(root, 'barrel.ts'), "export * from './nested';");
18
+ const source = join(root, 'shape.pack.ts');
19
+ await writeFile(
20
+ source,
21
+ `import { count } from './barrel.js';
22
+ export default { schemaVersion: '2.0.0',
23
+ packageId: new Uint8Array([1,159,250,151,139,57,122,210,132,204,39,50,104,89,26,180]),
24
+ build() { return { ok: true, value: { 'scene/main': { kind: 'scene', entities: [], mounts: [], count } } }; }
25
+ };`,
26
+ );
27
+ const loaded = await loadScriptablePack(source);
28
+ expect(loaded.ok).toBe(true);
29
+ if (!loaded.ok) throw loaded.error;
30
+ const built = await loaded.value.build({
31
+ packageId: loaded.value.packageId,
32
+ values: {},
33
+ readByGuid: async () => err('unused'),
34
+ });
35
+ expect(built).toMatchObject({
36
+ ok: true,
37
+ value: { 'scene/main': { kind: 'scene', count: 7 } },
38
+ });
39
+ } finally {
40
+ await rm(root, { recursive: true, force: true });
41
+ }
42
+ });
43
+
10
44
  it('loads the definition without invoking build', async () => {
11
45
  const root = await mkdtemp(join(tmpdir(), 'forgeax-scriptable-pack-'));
12
46
  try {
@@ -2,12 +2,20 @@ import type {
2
2
  MaterialAsset,
3
3
  MaterialParameter,
4
4
  MaterialPass,
5
+ MaterialProgramAbi,
6
+ MaterialProgramAddress,
5
7
  MaterialTextureReference,
6
8
  MaterialTextureValue,
7
9
  MaterialValue,
8
10
  Result,
9
11
  } from '@forgeax/engine-types';
10
- import { deriveStandardLayerPlan, err, MATERIAL_TEXTURE_SLOTS, ok } from '@forgeax/engine-types';
12
+ import {
13
+ deriveStandardLayerPlan,
14
+ err,
15
+ isMaterialProgramAbi,
16
+ MATERIAL_TEXTURE_SLOTS,
17
+ ok,
18
+ } from '@forgeax/engine-types';
11
19
  import { sha256 } from '@noble/hashes/sha2.js';
12
20
  import { bytesToHex } from '@noble/hashes/utils.js';
13
21
 
@@ -44,6 +52,12 @@ export interface MaterialCookProgram {
44
52
  readonly selections: readonly {
45
53
  readonly pass: string;
46
54
  readonly context: MaterialCookProgramContext;
55
+ /** Renderer submission address selected by this publication. */
56
+ readonly address?: MaterialProgramAddress;
57
+ /** Actual entry point paired with the selected address. */
58
+ readonly entry?: string;
59
+ /** Producer-reflected ABI facts for the selected artifact/entry. */
60
+ readonly abi?: MaterialProgramAbi;
47
61
  }[];
48
62
  }
49
63
 
@@ -483,6 +497,9 @@ export function validateCookedMaterialRecord(
483
497
  const programs: MaterialCookProgram[] = [];
484
498
  const programKeys = new Set<string>();
485
499
  const selections = new Set<string>();
500
+ const submissionSelections = new Map<string, Set<MaterialProgramAddress>>();
501
+ let modernPublication = false;
502
+ let legacySelectionField: string | undefined;
486
503
  const selectedPasses = new Set<string>();
487
504
  for (const [index, entry] of candidate.programs.entries()) {
488
505
  const field = `programs[${index}]`;
@@ -511,9 +528,11 @@ export function validateCookedMaterialRecord(
511
528
  if (!Array.isArray(program.selections) || program.selections.length === 0)
512
529
  return invalid(`${field}.selections`);
513
530
  const programSelections: MaterialCookProgram['selections'][number][] = [];
514
- for (const [selectionIndex, selection] of program.selections.entries()) {
531
+ for (const [selectionIndex, rawSelection] of program.selections.entries()) {
515
532
  const selectionField = `${field}.selections[${selectionIndex}]`;
516
- if (selection === null || typeof selection !== 'object' || !passNames.has(selection.pass))
533
+ if (rawSelection === null || typeof rawSelection !== 'object') return invalid(selectionField);
534
+ const selection = rawSelection as Record<string, unknown>;
535
+ if (typeof selection.pass !== 'string' || !passNames.has(selection.pass))
517
536
  return invalid(selectionField);
518
537
  const context = validateMaterialCookProgramContext(selection.context);
519
538
  if (!context.ok)
@@ -521,11 +540,55 @@ export function validateCookedMaterialRecord(
521
540
  `${selectionField}.${context.error.detail.field}`,
522
541
  context.error.detail.actual,
523
542
  );
524
- const key = JSON.stringify([selection.pass, materialProgramContextKey(context.value)]);
543
+ const address = selection.address === undefined ? 'direct' : selection.address;
544
+ if (address !== 'direct' && address !== 'scene-index')
545
+ return invalid(`${selectionField}.address`, selection.address);
546
+ const hasAddressFacts =
547
+ selection.address !== undefined ||
548
+ selection.entry !== undefined ||
549
+ selection.abi !== undefined;
550
+ modernPublication ||= hasAddressFacts;
551
+ if (!hasAddressFacts) legacySelectionField ??= selectionField;
552
+ const rawEntry = selection.entry;
553
+ const entry = typeof rawEntry === 'string' ? rawEntry : undefined;
554
+ if (hasAddressFacts && selection.address === undefined)
555
+ return invalid(
556
+ `${selectionField}.address`,
557
+ 'modern ABI selections require an explicit address',
558
+ );
559
+ if (hasAddressFacts && (entry === undefined || entry.length === 0))
560
+ return invalid(`${selectionField}.entry`, rawEntry);
561
+ if (hasAddressFacts && !isMaterialProgramAbi(selection.abi))
562
+ return invalid(`${selectionField}.abi`, selection.abi);
563
+ if (hasAddressFacts) {
564
+ const abi = selection.abi as MaterialProgramAbi;
565
+ const expectedEntry = address === 'direct' ? abi.directEntry : abi.sceneIndexEntry;
566
+ if (entry !== expectedEntry)
567
+ return invalid(`${selectionField}.entry`, 'entry does not match published ABI');
568
+ const submissionKey = JSON.stringify([
569
+ selection.pass,
570
+ materialProgramContextKey(context.value),
571
+ ]);
572
+ const addresses =
573
+ submissionSelections.get(submissionKey) ?? new Set<MaterialProgramAddress>();
574
+ addresses.add(address);
575
+ submissionSelections.set(submissionKey, addresses);
576
+ }
577
+ const key = JSON.stringify([
578
+ selection.pass,
579
+ materialProgramContextKey(context.value),
580
+ address,
581
+ ]);
525
582
  if (selections.has(key)) return invalid(selectionField, 'ambiguous Pass/context selection');
526
583
  selections.add(key);
527
584
  selectedPasses.add(selection.pass);
528
- programSelections.push({ pass: selection.pass, context: context.value });
585
+ programSelections.push({
586
+ pass: selection.pass,
587
+ context: context.value,
588
+ ...(selection.address === undefined ? {} : { address }),
589
+ ...(entry === undefined ? {} : { entry }),
590
+ ...(selection.abi === undefined ? {} : { abi: selection.abi as MaterialProgramAbi }),
591
+ });
529
592
  }
530
593
  programs.push({
531
594
  specializationKey: program.specializationKey,
@@ -533,6 +596,17 @@ export function validateCookedMaterialRecord(
533
596
  selections: programSelections,
534
597
  });
535
598
  }
599
+ if (modernPublication && legacySelectionField !== undefined) {
600
+ return invalid(
601
+ legacySelectionField,
602
+ 'modern material publications require address, entry, and ABI facts on every selection',
603
+ );
604
+ }
605
+ for (const [selectionKey, addresses] of submissionSelections) {
606
+ if (addresses.size !== 2) {
607
+ return invalid('programs.selections', `incomplete submission address pair: ${selectionKey}`);
608
+ }
609
+ }
536
610
  if ([...passNames].some((pass) => !selectedPasses.has(pass)))
537
611
  return invalid('programs.selections', 'unpublished Pass');
538
612
  const manifestDigest = createMaterialProgramSetDigest(programs, passes);
@@ -569,7 +569,9 @@ function validateParameterDefinitions(
569
569
  }
570
570
 
571
571
  export interface PackBuildReadContext {
572
- readByGuid<TAsset extends Asset = Asset>(guid: AssetGuid): Promise<Result<TAsset, unknown>>;
572
+ readByGuid<TAsset extends Asset | PackCookSource = Asset>(
573
+ guid: AssetGuid,
574
+ ): Promise<Result<TAsset, unknown>>;
573
575
  }
574
576
 
575
577
  export interface PackBuildContextWithoutParameters extends PackBuildReadContext {
@@ -588,7 +590,14 @@ export type PackBuildContext<TParameters extends readonly PackParameterDefinitio
588
590
  ? PackBuildContextWithParameters<TParameters>
589
591
  : PackBuildContextWithoutParameters;
590
592
 
591
- export type PackOutputMap = Readonly<Record<string, Asset>>;
593
+ /** Build-only custom source. The host derives identity and invokes the kind's NativeCooker. */
594
+ export interface PackCookSource {
595
+ readonly kind: string;
596
+ readonly execution: 'cooked';
597
+ readonly source: unknown;
598
+ }
599
+
600
+ export type PackOutputMap = Readonly<Record<string, Asset | PackCookSource>>;
592
601
  export type PackBuildResult<TError = unknown> =
593
602
  | Result<PackOutputMap, TError>
594
603
  | Promise<Result<PackOutputMap, TError>>;
@@ -48,6 +48,8 @@ export interface RuntimePackPublicationInput {
48
48
  readonly digest?: string;
49
49
  readonly generation?: number;
50
50
  readonly outputs?: readonly AssetPublicationOutput[];
51
+ /** Author/producer keys by normalized GUID when deriving output rows. */
52
+ readonly sourceKeys?: ReadonlyMap<string, string>;
51
53
  readonly externalEvidence?: readonly AssetPublicationExternalEvidence[];
52
54
  }
53
55
 
@@ -88,10 +90,10 @@ function normalizedAssets(pack: RuntimePackInput): readonly RuntimePackAsset[] {
88
90
  });
89
91
  }
90
92
 
91
- function outputFor(asset: RuntimePackAsset): AssetPublicationOutput {
93
+ function outputFor(asset: RuntimePackAsset, sourceKey?: string): AssetPublicationOutput {
92
94
  return {
93
95
  guid: asset.guid.toLowerCase(),
94
- sourceKey: asset.guid.toLowerCase(),
96
+ sourceKey: sourceKey ?? asset.guid.toLowerCase(),
95
97
  kind: asset.kind,
96
98
  digest: digest({
97
99
  guid: asset.guid.toLowerCase(),
@@ -145,7 +147,9 @@ export function createRuntimePackPublication(
145
147
  ),
146
148
  };
147
149
  const valueDigest = input.digest ?? digest(semantic);
148
- const outputs = input.outputs ?? assets.map(outputFor);
150
+ const outputs =
151
+ input.outputs ??
152
+ assets.map((asset) => outputFor(asset, input.sourceKeys?.get(asset.guid.toLowerCase())));
149
153
  const outputDigest = outputSetDigest(outputs);
150
154
  const generation =
151
155
  input.generation ?? publicationGeneration(input.sourceRevision, valueDigest, outputDigest);
@@ -273,6 +273,113 @@
273
273
  "type": "string",
274
274
  "minLength": 1
275
275
  },
276
+ "address": {
277
+ "enum": ["direct", "scene-index"]
278
+ },
279
+ "entry": {
280
+ "type": "string",
281
+ "minLength": 1
282
+ },
283
+ "abi": {
284
+ "type": "object",
285
+ "required": [
286
+ "directEntry",
287
+ "sceneIndexEntry",
288
+ "materialRow",
289
+ "resourceSlots",
290
+ "uvSets",
291
+ "vertexInputs",
292
+ "alphaMask",
293
+ "reflection",
294
+ "receiptIdentity",
295
+ "generation"
296
+ ],
297
+ "properties": {
298
+ "directEntry": { "type": "string", "minLength": 1 },
299
+ "sceneIndexEntry": { "type": "string", "minLength": 1 },
300
+ "materialRow": {
301
+ "type": "object",
302
+ "required": ["byteLength", "fields"],
303
+ "properties": {
304
+ "byteLength": { "type": "integer", "minimum": 16, "multipleOf": 16 },
305
+ "fields": { "type": "array", "items": { "type": "string" } }
306
+ },
307
+ "additionalProperties": false
308
+ },
309
+ "resourceSlots": {
310
+ "type": "array",
311
+ "items": {
312
+ "type": "object",
313
+ "required": ["name", "parameter", "kind", "group", "binding"],
314
+ "properties": {
315
+ "name": { "type": "string", "minLength": 1 },
316
+ "parameter": { "type": "string", "minLength": 1 },
317
+ "kind": { "enum": ["sampler", "texture", "storage-buffer"] },
318
+ "group": { "type": "integer", "minimum": 0 },
319
+ "binding": { "type": "integer", "minimum": 0 }
320
+ },
321
+ "additionalProperties": false
322
+ }
323
+ },
324
+ "uvSets": {
325
+ "type": "array",
326
+ "items": {
327
+ "type": "object",
328
+ "required": ["parameter", "set"],
329
+ "properties": {
330
+ "parameter": { "type": "string", "minLength": 1 },
331
+ "set": { "type": "integer", "minimum": 0 }
332
+ },
333
+ "additionalProperties": false
334
+ }
335
+ },
336
+ "vertexInputs": {
337
+ "type": "array",
338
+ "items": {
339
+ "type": "object",
340
+ "required": ["semantic", "location", "format"],
341
+ "properties": {
342
+ "semantic": { "type": "string", "minLength": 1 },
343
+ "location": { "type": "integer", "minimum": 0 },
344
+ "format": { "type": "string", "minLength": 1 }
345
+ },
346
+ "additionalProperties": false
347
+ }
348
+ },
349
+ "alphaMask": {
350
+ "type": "object",
351
+ "required": ["cutoff", "source"],
352
+ "properties": {
353
+ "cutoff": { "type": "string" },
354
+ "source": { "type": "string" }
355
+ },
356
+ "additionalProperties": false
357
+ },
358
+ "skinPaletteAddress": {
359
+ "type": "object",
360
+ "required": ["group", "binding", "stride"],
361
+ "properties": {
362
+ "group": { "type": "integer", "minimum": 0 },
363
+ "binding": { "type": "integer", "minimum": 0 },
364
+ "stride": { "type": "integer", "minimum": 1 }
365
+ },
366
+ "additionalProperties": false
367
+ },
368
+ "reflection": {
369
+ "type": "object",
370
+ "required": ["layoutIdentity", "resourceSlots", "vertexInputs"],
371
+ "properties": {
372
+ "layoutIdentity": { "type": "string", "minLength": 1 },
373
+ "resourceSlots": { "type": "array" },
374
+ "vertexInputs": { "type": "array" }
375
+ },
376
+ "additionalProperties": false
377
+ },
378
+ "receiptIdentity": { "type": "string", "minLength": 1 },
379
+ "generation": { "type": "integer", "minimum": 1 }
380
+ },
381
+ "additionalProperties": false
382
+ },
276
383
  "context": {
277
384
  "type": "object",
278
385
  "required": [
@@ -684,7 +684,11 @@ export async function loadScriptablePack(
684
684
  options: LoadScriptablePackOptions = {},
685
685
  ): Promise<Result<Readonly<AnyScriptablePackDefinition>, PackAuthoringError>> {
686
686
  const timeoutMs = options.timeoutMs ?? 15_000;
687
- const buildTimeoutMs = options.buildTimeoutMs ?? 5_000;
687
+ // Keep authored builds bounded, but give legitimate procedural packs the
688
+ // same cold-start budget already used for module initialization. The old
689
+ // five-second default rejected large production packs before they could
690
+ // publish a result.
691
+ const buildTimeoutMs = options.buildTimeoutMs ?? 15_000;
688
692
  const executor = options.executor ?? new WorkerScriptablePackExecutor();
689
693
  let disposeReason: 'complete' | 'timeout' | 'failure' | undefined;
690
694
  let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, extname, join, resolve } from 'node:path';
4
4
  import { fileURLToPath, pathToFileURL } from 'node:url';
5
5
  import { parentPort, workerData } from 'node:worker_threads';
@@ -66,15 +66,26 @@ const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs']
66
66
  function resolveRelativeImport(importer: string, specifier: string): string | undefined {
67
67
  if (!specifier.startsWith('.')) return undefined;
68
68
  const raw = resolve(dirname(importer), specifier);
69
+ const extension = extname(raw);
70
+ const stem = raw.slice(0, raw.length - extension.length);
71
+ // Authored NodeNext sources use runtime extensions even when their producer files are TypeScript.
72
+ const substitutions =
73
+ extension === '.js'
74
+ ? [`${stem}.ts`, `${stem}.tsx`]
75
+ : extension === '.mjs'
76
+ ? [`${stem}.mts`]
77
+ : extension === '.cjs'
78
+ ? [`${stem}.cts`]
79
+ : [];
69
80
  const candidates =
70
- extname(raw).length > 0
71
- ? [raw]
81
+ extension.length > 0
82
+ ? [...substitutions, raw]
72
83
  : [
73
84
  raw,
74
85
  ...SOURCE_EXTENSIONS.map((extension) => `${raw}${extension}`),
75
86
  resolve(raw, 'index.ts'),
76
87
  ];
77
- return candidates.find((candidate) => existsSync(candidate));
88
+ return candidates.find((candidate) => existsSync(candidate) && statSync(candidate).isFile());
78
89
  }
79
90
 
80
91
  function outputName(path: string): string {
@@ -1,4 +1,5 @@
1
1
  import type { Asset, AssetGuid, Result } from '@forgeax/engine-types';
2
+ import type { PackCookSource } from './pack-authoring.js';
2
3
 
3
4
  /**
4
5
  * Build-time Pack vocabulary shared by the source loader and output
@@ -82,7 +83,7 @@ export interface ScriptablePackSourceClosureEntry {
82
83
 
83
84
  /** AssetReader is intentionally a small, realm-neutral source boundary. */
84
85
  export interface AssetReader {
85
- readByGuid<TAsset extends Asset = Asset>(
86
+ readByGuid<TAsset extends Asset | PackCookSource = Asset>(
86
87
  guid: AssetGuid,
87
88
  ): Promise<Result<TAsset, ScriptablePackReadError>>;
88
89
  }