@forgeax/engine-vfx 0.1.2

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 (108) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +214 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/authoring-descriptor.unit.test.d.ts +2 -0
  5. package/dist/__tests__/authoring-descriptor.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/channel-input.unit.test.d.ts +2 -0
  7. package/dist/__tests__/channel-input.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/channel-inspect.unit.test.d.ts +2 -0
  9. package/dist/__tests__/channel-inspect.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/code-source-v2.unit.test.d.ts +2 -0
  11. package/dist/__tests__/code-source-v2.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/data-interface-contract.unit.test.d.ts +2 -0
  13. package/dist/__tests__/data-interface-contract.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/effect-contract.unit.test.d.ts +2 -0
  15. package/dist/__tests__/effect-contract.unit.test.d.ts.map +1 -0
  16. package/dist/__tests__/gpu-program-simulation-culling-owner.test-d.d.ts +2 -0
  17. package/dist/__tests__/gpu-program-simulation-culling-owner.test-d.d.ts.map +1 -0
  18. package/dist/__tests__/gpu-reflection-vocabulary-owner.test-d.d.ts +2 -0
  19. package/dist/__tests__/gpu-reflection-vocabulary-owner.test-d.d.ts.map +1 -0
  20. package/dist/__tests__/gpu-runtime.integration.test.d.ts +2 -0
  21. package/dist/__tests__/gpu-runtime.integration.test.d.ts.map +1 -0
  22. package/dist/__tests__/inspect-snapshot.unit.test.d.ts +2 -0
  23. package/dist/__tests__/inspect-snapshot.unit.test.d.ts.map +1 -0
  24. package/dist/__tests__/instance-public-api.unit.test.d.ts +2 -0
  25. package/dist/__tests__/instance-public-api.unit.test.d.ts.map +1 -0
  26. package/dist/__tests__/instance.unit.test.d.ts +2 -0
  27. package/dist/__tests__/instance.unit.test.d.ts.map +1 -0
  28. package/dist/__tests__/loader-v1-rejection.unit.test.d.ts +2 -0
  29. package/dist/__tests__/loader-v1-rejection.unit.test.d.ts.map +1 -0
  30. package/dist/__tests__/player-reflection.unit.test.d.ts +2 -0
  31. package/dist/__tests__/player-reflection.unit.test.d.ts.map +1 -0
  32. package/dist/__tests__/player-type.test-d.d.ts +2 -0
  33. package/dist/__tests__/player-type.test-d.d.ts.map +1 -0
  34. package/dist/__tests__/renderer-source.unit.test.d.ts +2 -0
  35. package/dist/__tests__/renderer-source.unit.test.d.ts.map +1 -0
  36. package/dist/__tests__/replay-canonical.unit.test.d.ts +2 -0
  37. package/dist/__tests__/replay-canonical.unit.test.d.ts.map +1 -0
  38. package/dist/__tests__/runtime-dist-isolation.test.d.ts +2 -0
  39. package/dist/__tests__/runtime-dist-isolation.test.d.ts.map +1 -0
  40. package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts +2 -0
  41. package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts.map +1 -0
  42. package/dist/__tests__/stage-source.unit.test.d.ts +2 -0
  43. package/dist/__tests__/stage-source.unit.test.d.ts.map +1 -0
  44. package/dist/__tests__/vfx-data-interface-vocabulary-owner.test-d.d.ts +2 -0
  45. package/dist/__tests__/vfx-data-interface-vocabulary-owner.test-d.d.ts.map +1 -0
  46. package/dist/__tests__/vfx-performance-contract.unit.test.d.ts +2 -0
  47. package/dist/__tests__/vfx-performance-contract.unit.test.d.ts.map +1 -0
  48. package/dist/__tests__/vfx-value-type-owner.test-d.d.ts +2 -0
  49. package/dist/__tests__/vfx-value-type-owner.test-d.d.ts.map +1 -0
  50. package/dist/assets/particle-effect-decoder.d.ts +3 -0
  51. package/dist/assets/particle-effect-decoder.d.ts.map +1 -0
  52. package/dist/authoring-descriptor.d.ts +60 -0
  53. package/dist/authoring-descriptor.d.ts.map +1 -0
  54. package/dist/code-source.d.ts +140 -0
  55. package/dist/code-source.d.ts.map +1 -0
  56. package/dist/data-interface.d.ts +53 -0
  57. package/dist/data-interface.d.ts.map +1 -0
  58. package/dist/effect-contract.d.ts +57 -0
  59. package/dist/effect-contract.d.ts.map +1 -0
  60. package/dist/gpu-loader.d.ts +33 -0
  61. package/dist/gpu-loader.d.ts.map +1 -0
  62. package/dist/gpu-program.d.ts +80 -0
  63. package/dist/gpu-program.d.ts.map +1 -0
  64. package/dist/gpu-runtime.d.ts +174 -0
  65. package/dist/gpu-runtime.d.ts.map +1 -0
  66. package/dist/index.d.ts +21 -0
  67. package/dist/index.d.ts.map +1 -0
  68. package/dist/index.mjs +1986 -0
  69. package/dist/index.mjs.map +1 -0
  70. package/dist/instance.d.ts +86 -0
  71. package/dist/instance.d.ts.map +1 -0
  72. package/dist/player.d.ts +17 -0
  73. package/dist/player.d.ts.map +1 -0
  74. package/package.json +66 -0
  75. package/src/__tests__/authoring-descriptor.unit.test.ts +138 -0
  76. package/src/__tests__/channel-input.unit.test.ts +65 -0
  77. package/src/__tests__/channel-inspect.unit.test.ts +65 -0
  78. package/src/__tests__/code-source-v2.unit.test.ts +121 -0
  79. package/src/__tests__/data-interface-contract.unit.test.ts +92 -0
  80. package/src/__tests__/effect-contract.unit.test.ts +62 -0
  81. package/src/__tests__/gpu-program-simulation-culling-owner.test-d.ts +58 -0
  82. package/src/__tests__/gpu-reflection-vocabulary-owner.test-d.ts +23 -0
  83. package/src/__tests__/gpu-runtime.integration.test.ts +549 -0
  84. package/src/__tests__/inspect-snapshot.unit.test.ts +51 -0
  85. package/src/__tests__/instance-public-api.unit.test.ts +27 -0
  86. package/src/__tests__/instance.unit.test.ts +107 -0
  87. package/src/__tests__/loader-v1-rejection.unit.test.ts +125 -0
  88. package/src/__tests__/player-reflection.unit.test.ts +91 -0
  89. package/src/__tests__/player-type.test-d.ts +54 -0
  90. package/src/__tests__/renderer-source.unit.test.ts +58 -0
  91. package/src/__tests__/replay-canonical.unit.test.ts +68 -0
  92. package/src/__tests__/runtime-dist-isolation.test.ts +100 -0
  93. package/src/__tests__/scriptable-pack-consumer.test-d.ts +9 -0
  94. package/src/__tests__/stage-source.unit.test.ts +64 -0
  95. package/src/__tests__/vfx-data-interface-vocabulary-owner.test-d.ts +34 -0
  96. package/src/__tests__/vfx-performance-contract.unit.test.ts +19 -0
  97. package/src/__tests__/vfx-value-type-owner.test-d.ts +32 -0
  98. package/src/assets/particle-effect-decoder.ts +34 -0
  99. package/src/authoring-descriptor.ts +355 -0
  100. package/src/code-source.ts +827 -0
  101. package/src/data-interface.ts +181 -0
  102. package/src/effect-contract.ts +220 -0
  103. package/src/gpu-loader.ts +279 -0
  104. package/src/gpu-program.ts +101 -0
  105. package/src/gpu-runtime.ts +807 -0
  106. package/src/index.ts +110 -0
  107. package/src/instance.ts +379 -0
  108. package/src/player.ts +21 -0
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createVfxEffectContract } from '../effect-contract.js';
3
+ import { ParticleEffectInstance, type VfxInstanceParent } from '../instance.js';
4
+
5
+ type Values = {
6
+ readonly direction: readonly [number, number, number];
7
+ readonly speed: number;
8
+ };
9
+
10
+ const reflection = {
11
+ version: 1,
12
+ parameters: {
13
+ name: 'VfxParameters',
14
+ fields: [
15
+ { name: 'direction', type: 'vec3<f32>', offset: 0, size: 12, alignment: 16 },
16
+ { name: 'speed', type: 'f32', offset: 16, size: 4, alignment: 4 },
17
+ ],
18
+ size: 32,
19
+ alignment: 16,
20
+ },
21
+ custom: { name: 'VfxCustom', fields: [], size: 0, alignment: 1 },
22
+ fingerprint: 'sha256:instance-test',
23
+ } as const;
24
+
25
+ function createInstance(initialValues: Partial<Values> = {}) {
26
+ const contract = createVfxEffectContract<Values>(reflection);
27
+ const parent: VfxInstanceParent<Values> = {
28
+ fingerprint: reflection.fingerprint,
29
+ defaults: { direction: [1, 0, 0], speed: 2 },
30
+ };
31
+ return new ParticleEffectInstance(contract, { parent, initialValues });
32
+ }
33
+
34
+ describe('ParticleEffectInstance atomic semantics', () => {
35
+ it('keeps channel input in the same commit as the parameter generation', () => {
36
+ const instance = createInstance();
37
+ const submitted = (
38
+ instance as unknown as {
39
+ submit(input: {
40
+ channel: string;
41
+ payload: { position: readonly [number, number, number]; strength: number };
42
+ sequence: number;
43
+ }): { ok: boolean };
44
+ }
45
+ ).submit({ channel: 'impact', payload: { position: [0, 1, 0], strength: 1 }, sequence: 1 });
46
+
47
+ expect(submitted.ok).toBe(true);
48
+ const committed = instance.commit({ seed: 17, tick: 4 });
49
+ expect(committed.ok).toBe(true);
50
+ if (!committed.ok) return;
51
+ expect(committed.value.generation).toBe(0);
52
+ expect(committed.value.channelInputs).toHaveLength(1);
53
+ });
54
+
55
+ it('merges parent defaults before initial values without copying parent runtime state', () => {
56
+ const instance = createInstance({ speed: 3 });
57
+
58
+ expect(instance.values).toEqual({ direction: [1, 0, 0], speed: 3 });
59
+ expect(instance.generation).toBe(0);
60
+ });
61
+
62
+ it('coalesces same-tick patches into one generation and one canonical payload', () => {
63
+ const instance = createInstance();
64
+
65
+ expect(instance.patch({ speed: 4 }).ok).toBe(true);
66
+ expect(instance.patch({ direction: [0, 1, 0] }).ok).toBe(true);
67
+ expect(instance.generation).toBe(0);
68
+
69
+ const committed = instance.commit({ seed: 17, tick: 4 });
70
+ expect(committed.ok).toBe(true);
71
+ if (!committed.ok) return;
72
+ expect(committed.value.generation).toBe(1);
73
+ expect(committed.value.values).toEqual({ direction: [0, 1, 0], speed: 4 });
74
+ expect(committed.value.replayInput).toMatchObject({
75
+ seed: 17,
76
+ tick: 4,
77
+ generation: 1,
78
+ });
79
+ expect(committed.value.replayInput.payload).toEqual(committed.value.canonicalPayload);
80
+ });
81
+
82
+ it('rejects a bad patch without changing the last valid generation or values', () => {
83
+ const instance = createInstance();
84
+ const invalid = JSON.parse('{"direction":[0, 1]}');
85
+
86
+ const result = instance.patch(invalid);
87
+
88
+ expect(result.ok).toBe(false);
89
+ expect(instance.generation).toBe(0);
90
+ expect(instance.values).toEqual({ direction: [1, 0, 0], speed: 2 });
91
+ expect(instance.pendingPatchCount).toBe(0);
92
+ });
93
+
94
+ it('replays the same seed, tick, generation, and canonical input bytes', () => {
95
+ const first = createInstance({ speed: 5 });
96
+ const committed = first.commit({ seed: 9, tick: 12 });
97
+ expect(committed.ok).toBe(true);
98
+ if (!committed.ok) return;
99
+
100
+ const replay = createInstance({ speed: 5 }).replay(committed.value.replayInput);
101
+
102
+ expect(replay.ok).toBe(true);
103
+ if (!replay.ok) return;
104
+ expect(replay.value.replayInput).toEqual(committed.value.replayInput);
105
+ expect([...replay.value.canonicalPayload]).toEqual([...committed.value.canonicalPayload]);
106
+ });
107
+ });
@@ -0,0 +1,125 @@
1
+ import type { LoadContext } from '@forgeax/engine-types';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { particleEffectContribution } from '../assets/particle-effect-decoder.js';
4
+ import { vfxGpuEffectPackLoader } from '../index.js';
5
+
6
+ const EFFECT_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d45c';
7
+ function context(): LoadContext {
8
+ return {
9
+ fetchBinary: async () => {
10
+ throw new Error('asset-local loaders must not fetch legacy global artifacts');
11
+ },
12
+ resolveRef: async () => ({ ok: true, value: 0 }),
13
+ transcodeCaps: { bc: false, etc2: false, astc: false },
14
+ device: undefined,
15
+ };
16
+ }
17
+
18
+ function input(
19
+ payload: Record<string, unknown>,
20
+ artifacts: Record<
21
+ string,
22
+ { descriptor: { path: string; mediaType: string }; bytes: Uint8Array }
23
+ > = {},
24
+ ) {
25
+ return {
26
+ guid: EFFECT_GUID,
27
+ kind: 'particle-effect',
28
+ payload,
29
+ refs: [],
30
+ artifacts,
31
+ };
32
+ }
33
+
34
+ const validPayload = {
35
+ kind: 'particle-effect',
36
+ schemaVersion: 1,
37
+ emitters: [{ id: 'spark', capacity: 32 }],
38
+ };
39
+
40
+ describe('vfxGpuEffectPackLoader v2 boundary', () => {
41
+ it('exposes the cooked particle owner contribution', () => {
42
+ expect(particleEffectContribution.kind.kind).toBe('particle-effect');
43
+ expect(particleEffectContribution.consumer).toBe('VfxGpuRuntime');
44
+ });
45
+ it('keeps legacy artifact keys outside the v2 asset-local program contract', async () => {
46
+ const result = await vfxGpuEffectPackLoader.load(
47
+ input(validPayload, {
48
+ 'effect/program.json': {
49
+ descriptor: { path: 'program.json', mediaType: 'application/json' },
50
+ bytes: new TextEncoder().encode('{}'),
51
+ },
52
+ }),
53
+ context(),
54
+ );
55
+
56
+ expect(result.ok).toBe(false);
57
+ if (!result.ok) {
58
+ expect(result.error).toMatchObject({
59
+ code: 'vfx-asset-v2-invalid',
60
+ detail: { path: 'payload' },
61
+ });
62
+ }
63
+ });
64
+
65
+ it('rejects a summary-only v2 payload before reading a program artifact', async () => {
66
+ const result = await vfxGpuEffectPackLoader.load(
67
+ input({
68
+ kind: 'particle-effect',
69
+ schemaVersion: 2,
70
+ emitters: [],
71
+ programFingerprint: 'sha256:x',
72
+ }),
73
+ context(),
74
+ );
75
+
76
+ expect(result.ok).toBe(false);
77
+ if (!result.ok) expect(result.error.detail.path).toBe('payload');
78
+ });
79
+
80
+ it('rejects a v1 package-global artifact shape', async () => {
81
+ const result = await vfxGpuEffectPackLoader.load(
82
+ input(validPayload, {
83
+ 'effect/program.json': {
84
+ descriptor: { path: 'program.json', mediaType: 'application/json' },
85
+ bytes: new TextEncoder().encode('{}'),
86
+ },
87
+ }),
88
+ context(),
89
+ );
90
+
91
+ expect(result.ok).toBe(false);
92
+ if (!result.ok) {
93
+ expect(result.error.code).toBe('vfx-asset-v2-invalid');
94
+ expect(result.error.detail.path).toBe('payload');
95
+ }
96
+ });
97
+
98
+ it('rejects raw source fallback when the cooked program is absent', async () => {
99
+ const result = await vfxGpuEffectPackLoader.load(
100
+ input({ ...validPayload, source: { emitters: [] }, sourcePath: 'effect.vfx.json' }),
101
+ context(),
102
+ );
103
+
104
+ expect(result.ok).toBe(false);
105
+ if (!result.ok) {
106
+ expect(result.error.code).toBe('vfx-asset-v2-invalid');
107
+ expect(result.error.detail.path).toBe('payload');
108
+ }
109
+ });
110
+
111
+ it('keeps the particle owner on the asset-local cooked program contract', async () => {
112
+ const result = await vfxGpuEffectPackLoader.load(
113
+ input({
114
+ kind: 'particle-effect',
115
+ schemaVersion: 2,
116
+ emitters: [],
117
+ programFingerprint: 'sha256:x',
118
+ }),
119
+ context(),
120
+ );
121
+
122
+ expect(result.ok).toBe(false);
123
+ if (!result.ok) expect(result.error.code).toBe('vfx-asset-v2-invalid');
124
+ });
125
+ });
@@ -0,0 +1,91 @@
1
+ import { World } from '@forgeax/engine-ecs';
2
+ import { componentDefinition, componentSchema } from '@forgeax/engine-ecs/internal';
3
+ import type { Handle, ParticleEffectAsset } from '@forgeax/engine-types';
4
+ import { describe, expect, expectTypeOf, it } from 'vitest';
5
+ import { ParticleEffectPlayer, type ParticleEffectPlayerData } from '../index.js';
6
+
7
+ const asset: ParticleEffectAsset = {
8
+ kind: 'particle-effect',
9
+ schemaVersion: 2,
10
+ programFingerprint: 'sha256:test',
11
+ emitters: [],
12
+ program: { format: 'forgeax-vfx-program-2', fingerprint: 'sha256:test', emitters: [] },
13
+ };
14
+
15
+ describe('ParticleEffectPlayer ECS contract', () => {
16
+ it('exposes exactly the four author-intent fields with stable defaults', () => {
17
+ expect(Object.keys(componentSchema(ParticleEffectPlayer))).toEqual([
18
+ 'effect',
19
+ 'playing',
20
+ 'seed',
21
+ 'timeScale',
22
+ ]);
23
+ expect(componentSchema(ParticleEffectPlayer)).toEqual({
24
+ effect: 'shared<ParticleEffectAsset>',
25
+ playing: 'bool',
26
+ seed: 'u32',
27
+ timeScale: 'f32',
28
+ });
29
+ expect(componentDefinition(ParticleEffectPlayer).defaults).toEqual({
30
+ playing: true,
31
+ seed: 0,
32
+ timeScale: 1,
33
+ });
34
+ expect(ParticleEffectPlayer.fields.effect.type).toBe('shared<ParticleEffectAsset>');
35
+ expect(ParticleEffectPlayer.fields.playing.default).toBe(true);
36
+ expect(ParticleEffectPlayer.fields.seed.default).toBe(0);
37
+ expect(ParticleEffectPlayer.fields.timeScale.default).toBe(1);
38
+ });
39
+
40
+ it('round-trips a scene-compatible player payload without changing intent', () => {
41
+ const world = new World();
42
+ const effect = world.allocSharedRef('ParticleEffectAsset', asset);
43
+ const payload: ParticleEffectPlayerData = {
44
+ effect,
45
+ playing: false,
46
+ seed: 42,
47
+ timeScale: 0.5,
48
+ };
49
+ const roundTrip: unknown = JSON.parse(JSON.stringify(payload));
50
+
51
+ expect(roundTrip).toEqual(payload);
52
+ });
53
+
54
+ it('uses the shared asset identity in a real spawn and QueryRow loop', () => {
55
+ const world = new World();
56
+ const effect = world.allocSharedRef('ParticleEffectAsset', asset);
57
+ const entity = world
58
+ .spawn({
59
+ component: ParticleEffectPlayer,
60
+ data: { effect, playing: true, seed: 7, timeScale: 0.25 },
61
+ })
62
+ .unwrap();
63
+ const query = world.query({ read: [ParticleEffectPlayer] }).unwrap();
64
+ let observed = false;
65
+
66
+ for (const row of query) {
67
+ const player = row.get(ParticleEffectPlayer);
68
+ expectTypeOf(player.effect).toEqualTypeOf<Handle<'ParticleEffectAsset', 'shared'>>();
69
+ expectTypeOf(player.playing).toBeBoolean();
70
+ expectTypeOf(player.seed).toBeNumber();
71
+ expectTypeOf(player.timeScale).toBeNumber();
72
+ expect(player.effect).toBe(effect);
73
+ observed = true;
74
+ }
75
+
76
+ expect(entity).toBeDefined();
77
+ expect(observed).toBe(true);
78
+ });
79
+
80
+ it('keeps the shared-ref data shape assignable to the public handle contract', () => {
81
+ const world = new World();
82
+ const effect: Handle<'ParticleEffectAsset', 'shared'> = world.allocSharedRef(
83
+ 'ParticleEffectAsset',
84
+ asset,
85
+ );
86
+ expectTypeOf<ParticleEffectPlayerData['effect']>().toEqualTypeOf<
87
+ Handle<'ParticleEffectAsset', 'shared'>
88
+ >();
89
+ expect(effect).toBeGreaterThan(0);
90
+ });
91
+ });
@@ -0,0 +1,54 @@
1
+ import type { SchemaOf, ShapeOf } from '@forgeax/engine-ecs';
2
+ import type { Handle } from '@forgeax/engine-types';
3
+ import { describe, expectTypeOf, it } from 'vitest';
4
+ import {
5
+ createVfxEffectContract,
6
+ ParticleEffectInstance,
7
+ type ParticleEffectPlayer,
8
+ type VfxEffectReflection,
9
+ } from '../index.js';
10
+
11
+ type Values = {
12
+ readonly direction: readonly [number, number, number];
13
+ readonly speed: number;
14
+ };
15
+
16
+ const reflection: VfxEffectReflection = {
17
+ version: 1,
18
+ parameters: { name: 'VfxParameters', fields: [], size: 0, alignment: 1 },
19
+ custom: { name: 'VfxCustom', fields: [], size: 0, alignment: 1 },
20
+ fingerprint: 'sha256:player-type',
21
+ };
22
+
23
+ describe('ParticleEffectPlayer public type shape', () => {
24
+ it('infers typed instance patches from the public effect contract', () => {
25
+ const contract = createVfxEffectContract<Values>(reflection);
26
+ const instance = new ParticleEffectInstance(contract);
27
+ instance.patch({ speed: 2, direction: [0, 1, 0] });
28
+ // @ts-expect-error unknown authored field is rejected at the public SDK boundary
29
+ instance.patch({ missing: 1 });
30
+ // @ts-expect-error vector dimensions are part of the generated contract
31
+ instance.patch({ direction: [0, 1] });
32
+ expectTypeOf(instance).toMatchTypeOf<ParticleEffectInstance<Values>>();
33
+ });
34
+
35
+ it('contains only the author-intent schema fields', () => {
36
+ expectTypeOf<keyof SchemaOf<typeof ParticleEffectPlayer>>().toEqualTypeOf<
37
+ 'effect' | 'playing' | 'seed' | 'timeScale'
38
+ >();
39
+ expectTypeOf<
40
+ SchemaOf<typeof ParticleEffectPlayer>['effect']
41
+ >().toEqualTypeOf<'shared<ParticleEffectAsset>'>();
42
+ expectTypeOf<SchemaOf<typeof ParticleEffectPlayer>['playing']>().toEqualTypeOf<'bool'>();
43
+ expectTypeOf<SchemaOf<typeof ParticleEffectPlayer>['seed']>().toEqualTypeOf<'u32'>();
44
+ expectTypeOf<SchemaOf<typeof ParticleEffectPlayer>['timeScale']>().toEqualTypeOf<'f32'>();
45
+ });
46
+
47
+ it('derives the shared effect handle from the ECS schema', () => {
48
+ type Data = ShapeOf<SchemaOf<typeof ParticleEffectPlayer>>;
49
+ expectTypeOf<Data['effect']>().toEqualTypeOf<Handle<'ParticleEffectAsset', 'shared'>>();
50
+ expectTypeOf<Data['playing']>().toEqualTypeOf<boolean>();
51
+ expectTypeOf<Data['seed']>().toEqualTypeOf<number>();
52
+ expectTypeOf<Data['timeScale']>().toEqualTypeOf<number>();
53
+ });
54
+ });
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseParticleEffectSourceV2 } from '../code-source.js';
3
+
4
+ const rendererSource = (renderer: unknown) => ({
5
+ schemaVersion: 2,
6
+ emitters: [
7
+ {
8
+ id: 'showcase',
9
+ capacity: 64,
10
+ backend: { required: 'gpu' },
11
+ space: 'world',
12
+ bounds: { kind: 'sphere', center: [0, 0, 0], radius: 4 },
13
+ schedule: { rate: 4 },
14
+ program: { module: 'forgeax_vfx::default' },
15
+ renderers: [renderer],
16
+ },
17
+ ],
18
+ });
19
+
20
+ describe('Batch B renderer source contract', () => {
21
+ it.each([
22
+ ['billboard', { kind: 'billboard', material: 'vfx', sorting: 'emitter' }],
23
+ ['ribbon', { kind: 'ribbon', material: 'vfx', stripKey: 'alive-index', capacity: 32 }],
24
+ ['trail', { kind: 'trail', material: 'vfx', historyLength: 8, capacity: 32 }],
25
+ ['beam', { kind: 'beam', material: 'vfx', endpointField: 'velocity', capacity: 16 }],
26
+ ])('parses the independent %s topology', (_kind, renderer) => {
27
+ const parsed = parseParticleEffectSourceV2(rendererSource(renderer));
28
+
29
+ expect(parsed.ok).toBe(true);
30
+ if (parsed.ok) expect(parsed.value.emitters[0]?.renderers[0]).toMatchObject(renderer);
31
+ });
32
+
33
+ it('rejects zero topology capacity instead of silently disabling output', () => {
34
+ const parsed = parseParticleEffectSourceV2(
35
+ rendererSource({ kind: 'trail', material: 'vfx', historyLength: 8, capacity: 0 }),
36
+ );
37
+
38
+ expect(parsed).toMatchObject({
39
+ ok: false,
40
+ error: { code: 'vfx-source-renderer-invalid' },
41
+ });
42
+ });
43
+
44
+ it('keeps an explicitly disabled renderer distinguishable from an empty list', () => {
45
+ const parsed = parseParticleEffectSourceV2(
46
+ rendererSource({
47
+ kind: 'ribbon',
48
+ material: 'vfx',
49
+ stripKey: 'alive-index',
50
+ capacity: 32,
51
+ enabled: false,
52
+ }),
53
+ );
54
+
55
+ expect(parsed).toMatchObject({ ok: true });
56
+ if (parsed.ok) expect(parsed.value.emitters[0]?.renderers[0]).toMatchObject({ enabled: false });
57
+ });
58
+ });
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createVfxEffectContract } from '../effect-contract.js';
3
+ import { ParticleEffectInstance } from '../instance.js';
4
+
5
+ type Values = {
6
+ readonly zeta: number;
7
+ readonly alpha: readonly [number, number, number];
8
+ };
9
+
10
+ const reflection = {
11
+ version: 1,
12
+ parameters: {
13
+ name: 'VfxParameters',
14
+ fields: [
15
+ { name: 'zeta', type: 'f32', offset: 0, size: 4, alignment: 4 },
16
+ { name: 'alpha', type: 'vec3<f32>', offset: 16, size: 12, alignment: 16 },
17
+ ],
18
+ size: 32,
19
+ alignment: 16,
20
+ },
21
+ custom: { name: 'VfxCustom', fields: [], size: 0, alignment: 1 },
22
+ fingerprint: 'sha256:canonical-test',
23
+ } as const;
24
+
25
+ function makeInstance(values: Partial<Values> = {}) {
26
+ return new ParticleEffectInstance(createVfxEffectContract<Values>(reflection), {
27
+ initialValues: { zeta: 0.1, alpha: [0.2, 0.3, 0.4], ...values },
28
+ });
29
+ }
30
+
31
+ describe('VFX canonical replay payload', () => {
32
+ it('uses stable field order and f32-normalized scalar/vector values', () => {
33
+ const first = makeInstance({ zeta: 0.1 });
34
+ const second = makeInstance({ zeta: Math.fround(0.1) });
35
+
36
+ const left = first.commit({ seed: 5, tick: 8 });
37
+ const right = second.commit({ seed: 5, tick: 8 });
38
+
39
+ expect(left.ok).toBe(true);
40
+ expect(right.ok).toBe(true);
41
+ if (!left.ok || !right.ok) return;
42
+ expect([...left.value.canonicalPayload]).toEqual([...right.value.canonicalPayload]);
43
+ expect(new TextDecoder().decode(left.value.canonicalPayload)).toContain('alpha');
44
+ expect(new TextDecoder().decode(left.value.canonicalPayload).indexOf('alpha')).toBeLessThan(
45
+ new TextDecoder().decode(left.value.canonicalPayload).indexOf('zeta'),
46
+ );
47
+ });
48
+
49
+ it('encodes empty patches and multiple patches as one deterministic generation', () => {
50
+ const first = makeInstance();
51
+ const second = makeInstance();
52
+ expect(first.patch({}).ok).toBe(true);
53
+ expect(first.patch({ zeta: 2 }).ok).toBe(true);
54
+ expect(second.patch({ zeta: 2 }).ok).toBe(true);
55
+ expect(second.patch({}).ok).toBe(true);
56
+
57
+ const left = first.commit({ seed: 11, tick: 13 });
58
+ const right = second.commit({ seed: 11, tick: 13 });
59
+
60
+ expect(left.ok).toBe(true);
61
+ expect(right.ok).toBe(true);
62
+ if (!left.ok || !right.ok) return;
63
+ expect(left.value.generation).toBe(1);
64
+ expect(left.value.patchCount).toBe(2);
65
+ expect(left.value.replayInput).toEqual(right.value.replayInput);
66
+ expect([...left.value.parameterBlock]).toEqual([...right.value.parameterBlock]);
67
+ });
68
+ });
@@ -0,0 +1,100 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const vfxRoot = resolve(import.meta.dirname, '..', '..');
6
+ const compilerRoot = resolve(vfxRoot, '..', 'vfx-compiler');
7
+
8
+ function readJson(path: string): unknown {
9
+ return JSON.parse(readFileSync(path, 'utf8'));
10
+ }
11
+
12
+ function isRecord(value: unknown): value is Record<string, unknown> {
13
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
14
+ }
15
+
16
+ function record(value: unknown): Record<string, unknown> {
17
+ if (!isRecord(value)) throw new Error('expected a JSON object');
18
+ return value;
19
+ }
20
+
21
+ function sourceFiles(root: string): string[] {
22
+ const files: string[] = [];
23
+ for (const entry of readdirSync(root)) {
24
+ if (entry === '__tests__') continue;
25
+ const path = resolve(root, entry);
26
+ if (statSync(path).isDirectory()) {
27
+ files.push(...sourceFiles(path));
28
+ } else if (path.endsWith('.ts')) {
29
+ files.push(path);
30
+ }
31
+ }
32
+ return files;
33
+ }
34
+
35
+ function sourceText(root: string): string {
36
+ return sourceFiles(resolve(root, 'src'))
37
+ .map((path) => readFileSync(path, 'utf8'))
38
+ .join('\n');
39
+ }
40
+
41
+ function dependencyNames(manifest: unknown): readonly string[] {
42
+ const value = record(manifest);
43
+ const dependencies = value.dependencies;
44
+ return dependencies === undefined ? [] : Object.keys(record(dependencies));
45
+ }
46
+
47
+ const runtimeForbiddenDependencies = [
48
+ '@forgeax/engine-vfx-compiler',
49
+ '@forgeax/engine-shader-compiler',
50
+ '@forgeax/engine-naga',
51
+ '@forgeax/engine-render',
52
+ '@forgeax/engine-rhi',
53
+ ];
54
+
55
+ const compilerForbiddenDependencies = [
56
+ '@forgeax/engine-assets-runtime',
57
+ '@forgeax/engine-ecs',
58
+ '@forgeax/engine-render',
59
+ '@forgeax/engine-rhi',
60
+ '@forgeax/engine-naga',
61
+ ];
62
+
63
+ describe('VFX runtime/compiler physical boundary', () => {
64
+ it('keeps forbidden packages out of both production dependency manifests', () => {
65
+ const runtimeDependencies = dependencyNames(readJson(resolve(vfxRoot, 'package.json')));
66
+ const compilerDependencies = dependencyNames(readJson(resolve(compilerRoot, 'package.json')));
67
+
68
+ for (const dependency of runtimeForbiddenDependencies) {
69
+ expect(runtimeDependencies).not.toContain(dependency);
70
+ }
71
+ for (const dependency of compilerForbiddenDependencies) {
72
+ expect(compilerDependencies).not.toContain(dependency);
73
+ }
74
+ });
75
+
76
+ it('keeps compiler and device symbols out of the built runtime entry', () => {
77
+ const runtimeDist = readFileSync(resolve(vfxRoot, 'dist', 'index.mjs'), 'utf8');
78
+ const runtimeDeclarations = readFileSync(resolve(vfxRoot, 'dist', 'index.d.ts'), 'utf8');
79
+ const compilerDist = readFileSync(resolve(compilerRoot, 'dist', 'index.mjs'), 'utf8');
80
+
81
+ expect(runtimeDist).not.toMatch(/engine-vfx-compiler|shader-compiler|engine-naga/);
82
+ expect(runtimeDist).not.toMatch(
83
+ /\b(?:RenderFeature|Renderer|RenderGraph|GPUDevice|RhiDevice)\b/,
84
+ );
85
+ expect(runtimeDeclarations).not.toMatch(/engine-vfx-compiler|shader-compiler|engine-naga/);
86
+ expect(compilerDist).not.toMatch(/engine-ecs|engine-render|engine-rhi|engine-naga/);
87
+ expect(compilerDist).not.toMatch(
88
+ /\b(?:World|RenderFeature|Renderer|RenderGraph|GPUDevice|RhiDevice)\b/,
89
+ );
90
+ });
91
+
92
+ it('does not hide the boundary behind a source-level optional import', () => {
93
+ const runtimeSource = sourceText(vfxRoot);
94
+ const compilerSource = sourceText(compilerRoot);
95
+
96
+ expect(runtimeSource).not.toMatch(/engine-vfx-compiler|engine-shader-compiler|engine-naga/);
97
+ expect(compilerSource).not.toMatch(/engine-ecs|engine-render|engine-rhi/);
98
+ expect(runtimeSource).not.toMatch(/import\s*\(.*compiler|import\s*\(.*naga/s);
99
+ });
100
+ });
@@ -0,0 +1,9 @@
1
+ import type { Handle, ParticleEffectAsset } from '@forgeax/engine-types';
2
+ import { expectTypeOf, it } from 'vitest';
3
+
4
+ it('keeps the loaded particle program and World handle boundary typed', () => {
5
+ expectTypeOf<ParticleEffectAsset['program']>().toMatchTypeOf<ParticleEffectAsset['program']>();
6
+ expectTypeOf<Handle<'ParticleEffectAsset', 'shared'>>().toEqualTypeOf<
7
+ Handle<'ParticleEffectAsset', 'shared'>
8
+ >();
9
+ });
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseVfxStageDeclarations } from '../code-source.js';
3
+
4
+ describe('VFX stage source declarations', () => {
5
+ it('discovers a bounded particle stage with explicit resource access', () => {
6
+ const result = parseVfxStageDeclarations(
7
+ '// #vfx stage turbulence entry=vfx_turbulence domain=particle resources=particles:read-write,runtime:read dependsOn=update iterationBudget=4',
8
+ );
9
+
10
+ expect(result).toEqual({
11
+ ok: true,
12
+ value: [
13
+ {
14
+ id: 'turbulence',
15
+ entry: 'vfx_turbulence',
16
+ domain: 'particle',
17
+ resources: [
18
+ { name: 'particles', access: 'read-write' },
19
+ { name: 'runtime', access: 'read' },
20
+ ],
21
+ dependsOn: ['update'],
22
+ iterationBudget: 4,
23
+ },
24
+ ],
25
+ });
26
+ });
27
+
28
+ it.each([
29
+ ['domain', 'domain=render'],
30
+ ['resource', 'resources=privateBuffer:read'],
31
+ ['entry', 'entry=forgeax_vfx_update_main'],
32
+ ['budget', 'iterationBudget=0'],
33
+ ])('rejects an invalid stage %s with a recovery hint', (_name, replacement) => {
34
+ const result = parseVfxStageDeclarations(
35
+ `// #vfx stage turbulence entry=vfx_turbulence domain=particle resources=particles:read-write dependsOn=none iterationBudget=4`.replace(
36
+ replacement.startsWith('domain=')
37
+ ? 'domain=particle'
38
+ : replacement.startsWith('resources=')
39
+ ? 'resources=particles:read-write'
40
+ : replacement.startsWith('entry=')
41
+ ? 'entry=vfx_turbulence'
42
+ : 'iterationBudget=4',
43
+ replacement,
44
+ ),
45
+ );
46
+
47
+ expect(result.ok).toBe(false);
48
+ if (!result.ok) {
49
+ expect(result.error.code).toBe('vfx-source-stage-invalid');
50
+ expect(result.error.hint).toContain('recook');
51
+ expect(result.error.detail.path).toContain('turbulence');
52
+ }
53
+ });
54
+
55
+ it('rejects unknown directive fields and duplicate stage ids', () => {
56
+ const result = parseVfxStageDeclarations(`
57
+ // #vfx stage turbulence entry=vfx_turbulence domain=particle resources=particles:read-write dependsOn=none iterationBudget=4 extra=yes
58
+ // #vfx stage turbulence entry=vfx_turbulence domain=particle resources=particles:read-write dependsOn=none iterationBudget=4
59
+ `);
60
+
61
+ expect(result.ok).toBe(false);
62
+ if (!result.ok) expect(result.error.expected).toContain('supported stage declaration');
63
+ });
64
+ });