@forgeax/engine-naga 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.
@@ -0,0 +1,166 @@
1
+ // feat-20260629-multi-uv-set-support m4-w1: naga reflection uvSetCount unit test.
2
+ //
3
+ // Tests that naga emit_reflection returns uvSetCount derived from vertex
4
+ // @location declarations (D-4 convention: uv0 at location 2, uv1..7 at
5
+ // location 6..12, counting only vec2<f32> vertex input arguments).
6
+ //
7
+ // This test calls through the real @forgeax/engine-naga TS wrapper (parse
8
+ // -> validate -> emit_reflection), not mocked wasm. It starts RED because
9
+ // the current emit_reflection (pre m4-w2) does not include uvSetCount in
10
+ // the reflection JSON output. After m4-w2 (naga.rs modification) + m4-w4
11
+ // (wasm rebuild), this test should go GREEN.
12
+ //
13
+ // Wgsl snippets avoid trailing commas inside struct definitions (WGSL
14
+ // spec permits them but being conservative avoids naga parser edge cases).
15
+
16
+ import { describe, expect, it } from 'vitest';
17
+ import { emit_reflection, parse, validate } from '../index.js';
18
+
19
+ // --- wgsl test fixtures --------------------------------------------------
20
+
21
+ const WGSL_UV0_ONLY = `\
22
+ struct VsIn {
23
+ @location(0) pos: vec3<f32>,
24
+ @location(1) normal: vec3<f32>,
25
+ @location(2) uv: vec2<f32>,
26
+ @location(3) tangent: vec4<f32>
27
+ };
28
+ @vertex fn vs(in: VsIn) -> @builtin(position) vec4<f32> {
29
+ return vec4<f32>(0.0);
30
+ }
31
+ @fragment fn fs() -> @location(0) vec4<f32> {
32
+ return vec4<f32>(0.0);
33
+ }`;
34
+
35
+ const WGSL_UV0_UV1 = `\
36
+ struct VsIn {
37
+ @location(0) pos: vec3<f32>,
38
+ @location(1) normal: vec3<f32>,
39
+ @location(2) uv: vec2<f32>,
40
+ @location(3) tangent: vec4<f32>,
41
+ @location(6) uv1: vec2<f32>
42
+ };
43
+ @vertex fn vs(in: VsIn) -> @builtin(position) vec4<f32> {
44
+ return vec4<f32>(0.0);
45
+ }
46
+ @fragment fn fs() -> @location(0) vec4<f32> {
47
+ return vec4<f32>(0.0);
48
+ }`;
49
+
50
+ const WGSL_UV0_UV1_UV2 = `\
51
+ struct VsIn {
52
+ @location(0) pos: vec3<f32>,
53
+ @location(1) normal: vec3<f32>,
54
+ @location(2) uv: vec2<f32>,
55
+ @location(3) tangent: vec4<f32>,
56
+ @location(6) uv1: vec2<f32>,
57
+ @location(7) uv2: vec2<f32>
58
+ };
59
+ @vertex fn vs(in: VsIn) -> @builtin(position) vec4<f32> {
60
+ return vec4<f32>(0.0);
61
+ }
62
+ @fragment fn fs() -> @location(0) vec4<f32> {
63
+ return vec4<f32>(0.0);
64
+ }`;
65
+
66
+ const WGSL_UV0_SKIP_UV7 = `\
67
+ struct VsIn {
68
+ @location(0) pos: vec3<f32>,
69
+ @location(1) normal: vec3<f32>,
70
+ @location(2) uv: vec2<f32>,
71
+ @location(3) tangent: vec4<f32>,
72
+ @location(8) uv3: vec2<f32>
73
+ };
74
+ @vertex fn vs(in: VsIn) -> @builtin(position) vec4<f32> {
75
+ return vec4<f32>(0.0);
76
+ }
77
+ @fragment fn fs() -> @location(0) vec4<f32> {
78
+ return vec4<f32>(0.0);
79
+ }`;
80
+
81
+ // Also test a skin-aware WGSL with 0 extra UV
82
+ const WGSL_SKIN_UV0_ONLY = `\
83
+ struct VsIn {
84
+ @location(0) pos: vec3<f32>,
85
+ @location(1) normal: vec3<f32>,
86
+ @location(2) uv: vec2<f32>,
87
+ @location(3) tangent: vec4<f32>,
88
+ @location(4) skinIndex: vec4<u32>,
89
+ @location(5) skinWeight: vec4<f32>
90
+ };
91
+ @vertex fn vs(in: VsIn) -> @builtin(position) vec4<f32> {
92
+ return vec4<f32>(0.0);
93
+ }
94
+ @fragment fn fs() -> @location(0) vec4<f32> {
95
+ return vec4<f32>(0.0);
96
+ }`;
97
+
98
+ // --- test body -----------------------------------------------------------
99
+
100
+ describe('reflect-uv-set-count.test.ts', () => {
101
+ interface ReflectionOutput {
102
+ bindings: unknown;
103
+ uvSetCount: number;
104
+ material: {
105
+ members: unknown[];
106
+ resources: unknown[];
107
+ totalBytes: number;
108
+ };
109
+ }
110
+
111
+ async function reflectWgsl(wgsl: string): Promise<ReflectionOutput> {
112
+ const parsed = await parse(wgsl);
113
+ if (!parsed.ok) throw new Error(`parse failed: ${parsed.error.message}`);
114
+ const validated = await validate(parsed.value);
115
+ if (!validated.ok) throw new Error(`validate failed: ${validated.error.message}`);
116
+ const reflection = await emit_reflection(validated.value, '{}');
117
+ if (!reflection.ok) throw new Error(`emit_reflection failed: ${reflection.error.message}`);
118
+ const parsedObj = JSON.parse(reflection.value);
119
+ // After m4-w2 the reflection JSON format is { bindings: [...], uvSetCount: N }.
120
+ if (Array.isArray(parsedObj)) {
121
+ throw new Error(
122
+ 'Reflection output is still the old array format; m4-w2 naga.rs changes not yet applied or wasm not rebuilt.',
123
+ );
124
+ }
125
+ return parsedObj as ReflectionOutput;
126
+ }
127
+
128
+ describe('naga emit_reflection uvSetCount derivation (D-4: uv0@loc2, extra@loc6+)', () => {
129
+ it('uv0 only -> uvSetCount=1', async () => {
130
+ const r = await reflectWgsl(WGSL_UV0_ONLY);
131
+ expect(r.uvSetCount).toBe(1);
132
+ expect(Array.isArray(r.bindings)).toBe(true);
133
+ });
134
+
135
+ it('uv0 + uv1 (locations 2 and 6) -> uvSetCount=2', async () => {
136
+ const r = await reflectWgsl(WGSL_UV0_UV1);
137
+ expect(r.uvSetCount).toBe(2);
138
+ });
139
+
140
+ it('uv0 + uv1 + uv2 (locations 2, 6, 7) -> uvSetCount=3', async () => {
141
+ const r = await reflectWgsl(WGSL_UV0_UV1_UV2);
142
+ expect(r.uvSetCount).toBe(3);
143
+ });
144
+
145
+ it('uv0 + skip to uv3 at location 8 -> uvSetCount=4 (max(location>=6)-5=3 extra)', async () => {
146
+ const r = await reflectWgsl(WGSL_UV0_SKIP_UV7);
147
+ // Per D-4 jump convention: max(location>=6) = 8, so uvSetCount = 1 + (8-5) = 4.
148
+ // The shader declares uv3 at @location(8) which implies uv1(loc6) and
149
+ // uv2(loc7) also exist in the packing convention even if not physically
150
+ // present in VsIn — clamp-to-last handles the gap.
151
+ expect(r.uvSetCount).toBe(4);
152
+ });
153
+
154
+ it('skin shader with uv0 only -> uvSetCount=1 (skinIndex/skinWeight not counted as UV)', async () => {
155
+ const r = await reflectWgsl(WGSL_SKIN_UV0_ONLY);
156
+ expect(r.uvSetCount).toBe(1);
157
+ });
158
+ });
159
+
160
+ it('raw reflection exposes a separate material region alongside engine bindings', async () => {
161
+ const r = await reflectWgsl(WGSL_UV0_ONLY);
162
+ expect(r).toHaveProperty('material');
163
+ expect(r).toHaveProperty('bindings');
164
+ expect(r.material).not.toBe(r.bindings);
165
+ });
166
+ });
package/src/errors.ts ADDED
@@ -0,0 +1,208 @@
1
+ // @forgeax/engine-naga/errors — ShaderError + Result<T, E> + wrapShaderError helper.
2
+ //
3
+ // Form invariants (plan-strategy §D-P4 / requirements MVP-2.3 + AC-09):
4
+ // - ShaderErrorCode (closed 4-member union) is imported from @forgeax/engine-types as
5
+ // the SSOT. This package does **not** redefine the union — +0 breaking points
6
+ // to the error model (AC-09; charter proposition 5 consistent abstraction).
7
+ // - The ShaderError class shape + 4 factory helpers + Result<T, E> shape are
8
+ // byte-for-byte equivalent to @forgeax/engine-shader-compiler/src/errors.ts; that
9
+ // package will re-export from here in w7 after the import switch lands
10
+ // (charter proposition 5 + plan-strategy D-P4 byte-for-byte form recovery).
11
+ // - wrapShaderError is the JsError -> ShaderError adapter for the parse /
12
+ // validate / emit_reflection wrappers in index.ts: it tries to JSON.parse the
13
+ // wasm-side ParseErrorPayload (message / summary / line_num / line_pos)
14
+ // first, falls back to the prose message for validator / reflection failures.
15
+
16
+ /// <reference types="@webgpu/types" />
17
+
18
+ import type { ShaderErrorCode, ShaderErrorDetail } from '@forgeax/engine-types';
19
+
20
+ export type { ShaderErrorCode, ShaderErrorDetail };
21
+
22
+ interface ShaderErrorInit {
23
+ readonly code: ShaderErrorCode;
24
+ readonly expected: string;
25
+ readonly hint: string;
26
+ readonly message: string;
27
+ readonly lineNum?: number | undefined;
28
+ readonly linePos?: number | undefined;
29
+ readonly detail?: ShaderErrorDetail | undefined;
30
+ }
31
+
32
+ /**
33
+ * Structured shader error.
34
+ *
35
+ * **5 surface fields** (MVP-2.3 top-level surface, AI consumer path):
36
+ * - `.code` — member of the closed `ShaderErrorCode` union (4 variants)
37
+ * - `.message` — display text (the base `Error` field, populated by the constructor)
38
+ * - `.hint` — actionable recovery guidance (charter proposition 3: machine-readable hint over prose)
39
+ * - `.lineNum` / `.linePos` — error source location (mandatory for compile-failed; undefined on other paths)
40
+ *
41
+ * **3 internal fields**:
42
+ * - `.name` = `'ShaderError'` (debug tag)
43
+ * - `.expected` — description of the expected state (symmetric with RhiError)
44
+ * - `.detail` — path-specific extra info (e.g. all 6 fields of GPUCompilationMessage[])
45
+ *
46
+ * **Do not new directly** — construct via the 4 factories (compileFailed /
47
+ * initFailed / manifestMalformed / shaderNotFound) to avoid ad-hoc arguments
48
+ * bypassing union narrowing.
49
+ */
50
+ export class ShaderError extends Error {
51
+ override readonly name: 'ShaderError' = 'ShaderError';
52
+ readonly code: ShaderErrorCode;
53
+ readonly expected: string;
54
+ readonly hint: string;
55
+ readonly lineNum: number | undefined;
56
+ readonly linePos: number | undefined;
57
+ readonly detail: ShaderErrorDetail | undefined;
58
+
59
+ constructor(init: ShaderErrorInit) {
60
+ super(init.message);
61
+ this.code = init.code;
62
+ this.expected = init.expected;
63
+ this.hint = init.hint;
64
+ this.lineNum = init.lineNum;
65
+ this.linePos = init.linePos;
66
+ this.detail = init.detail;
67
+ }
68
+ }
69
+
70
+ // === 4 factory helpers (plan-strategy §D-P4 closed union with 4 members) ===========
71
+
72
+ /** `shader-compile-failed`: naga parse_str or Validator::validate failure. */
73
+ export function compileFailed(args: {
74
+ readonly message: string;
75
+ readonly hint: string;
76
+ readonly lineNum?: number | undefined;
77
+ readonly linePos?: number | undefined;
78
+ readonly compilerMessages?: readonly GPUCompilationMessage[] | undefined;
79
+ readonly reason?: string | undefined;
80
+ }): ShaderError {
81
+ return new ShaderError({
82
+ code: 'shader-compile-failed',
83
+ expected: 'WGSL source parses + validates against naga IR',
84
+ message: args.message,
85
+ hint: args.hint,
86
+ ...(args.lineNum !== undefined ? { lineNum: args.lineNum } : {}),
87
+ ...(args.linePos !== undefined ? { linePos: args.linePos } : {}),
88
+ ...(args.compilerMessages !== undefined
89
+ ? {
90
+ detail: {
91
+ code: 'shader-compile-failed',
92
+ compilerMessages: args.compilerMessages,
93
+ ...(args.reason !== undefined ? { reason: args.reason } : {}),
94
+ },
95
+ }
96
+ : {}),
97
+ });
98
+ }
99
+
100
+ /** `compiler-init-failed`: wasm loading or init() failure (cold start / missing wasm artifact). */
101
+ export function initFailed(args: {
102
+ readonly message: string;
103
+ readonly hint: string;
104
+ readonly reason?: string | undefined;
105
+ }): ShaderError {
106
+ return new ShaderError({
107
+ code: 'compiler-init-failed',
108
+ expected: '@forgeax/engine-wgpu-wasm ensureReady() resolves with naga raw bindings available',
109
+ message: args.message,
110
+ hint: args.hint,
111
+ detail: {
112
+ code: 'compiler-init-failed',
113
+ ...(args.reason !== undefined ? { reason: args.reason } : {}),
114
+ },
115
+ });
116
+ }
117
+
118
+ /** `manifest-malformed`: manifest.json schema validation failure (a required field is missing or JSON is not parseable). */
119
+ export function manifestMalformed(args: {
120
+ readonly message: string;
121
+ readonly hint: string;
122
+ readonly reason?: string | undefined;
123
+ }): ShaderError {
124
+ return new ShaderError({
125
+ code: 'manifest-malformed',
126
+ expected: 'manifest.json parses + every entry has {hash, wgsl, glsl, bindings}',
127
+ message: args.message,
128
+ hint: args.hint,
129
+ detail: {
130
+ code: 'manifest-malformed',
131
+ ...(args.reason !== undefined ? { reason: args.reason } : {}),
132
+ },
133
+ });
134
+ }
135
+
136
+ /** `shader-not-found`: ShaderRegistry.get(hash) hash miss. */
137
+ export function shaderNotFound(args: {
138
+ readonly hash: string;
139
+ readonly hint: string;
140
+ }): ShaderError {
141
+ return new ShaderError({
142
+ code: 'shader-not-found',
143
+ expected: `manifest.entries contains entry with hash '${args.hash}'`,
144
+ message: `ShaderRegistry: hash '${args.hash}' not present in manifest`,
145
+ hint: args.hint,
146
+ });
147
+ }
148
+
149
+ // === wrapShaderError: JsError -> ShaderError adapter ================================
150
+
151
+ /**
152
+ * Translate a thrown wasm-bindgen JsError to a structured ShaderError.
153
+ *
154
+ * The Rust side serializes `ParseErrorPayload { message, summary, line_num,
155
+ * line_pos }` to a JSON string and uses it as the JsError message. We attempt
156
+ * JSON.parse first; on success the lineNum/linePos are extracted as top-level
157
+ * surface fields (MVP-2.3). On failure (validator errors, reflection errors,
158
+ * non-JSON messages) we fall back to the prose message — hint is always
159
+ * populated so AI consumers always have an actionable recovery signal
160
+ * (charter proposition 4 explicit failure + proposition 3 machine-readable hint).
161
+ */
162
+ export function wrapShaderError(e: unknown, hint?: string): ShaderError {
163
+ if (e instanceof Error) {
164
+ try {
165
+ const payload = JSON.parse(e.message) as {
166
+ message?: string;
167
+ summary?: string;
168
+ line_num?: number | null;
169
+ line_pos?: number | null;
170
+ };
171
+ return compileFailed({
172
+ message: payload.summary ?? payload.message ?? e.message,
173
+ hint:
174
+ hint ??
175
+ 'fix the WGSL source at the indicated line/column; see ShaderError.detail.compilerMessages for full diagnostic frame',
176
+ ...(typeof payload.line_num === 'number' ? { lineNum: payload.line_num } : {}),
177
+ ...(typeof payload.line_pos === 'number' ? { linePos: payload.line_pos } : {}),
178
+ });
179
+ } catch {
180
+ return compileFailed({
181
+ message: e.message,
182
+ hint: hint ?? 'check WGSL syntax + validation rules; consult naga error output for details',
183
+ });
184
+ }
185
+ }
186
+ return compileFailed({
187
+ message: String(e),
188
+ hint:
189
+ hint ??
190
+ 'unknown error type from @forgeax/engine-wgpu-wasm; report as @forgeax/engine-naga bug',
191
+ });
192
+ }
193
+
194
+ // === Result<T, E> ====================================================================
195
+ //
196
+ // Result<T, E> + ok / err + ResultOk / ResultErr live in `@forgeax/engine-types`
197
+ // (tweak-20260612-result-into-types). They were duplicated here as a lite
198
+ // (plain-object, no `unwrap`) variant; consolidated upstream into the same
199
+ // shape used by rhi / ecs. The barrel here re-exports them so existing
200
+ // `import { err, ok, Result, ResultOk, ResultErr } from '@forgeax/engine-naga'`
201
+ // consumers stay unchanged.
202
+ export {
203
+ err,
204
+ ok,
205
+ type Result,
206
+ type ResultErr,
207
+ type ResultOk,
208
+ } from '@forgeax/engine-types';
package/src/index.ts ADDED
@@ -0,0 +1,230 @@
1
+ // @forgeax/engine-naga — TS-only thin shell over @forgeax/engine-wgpu-wasm raw naga bindings.
2
+ //
3
+ // Form invariants (locked by plan-strategy D-P3 / D-P4 + research F-4):
4
+ //
5
+ // - snake_case three-phase functions byte-for-byte aligned with naga upstream
6
+ // naming (this package replaces the legacy wasm-pack shim archived in
7
+ // feat-20260511-naga-rhi-wgpu-merge M5 — charter proposition 2 industry
8
+ // analogy + proposition 5 consistent abstraction).
9
+ // - Each public function awaits ensureReady() from @forgeax/engine-wgpu-wasm before
10
+ // calling the raw wasm-bindgen export — one wasm boundary crossing per page
11
+ // lifecycle, shared with @forgeax/engine-rhi-wgpu (research F-4 ensureReady SSOT).
12
+ // - Throws are caught at the wrapper boundary and translated to
13
+ // Result.err(ShaderError) — never throw for expected failures
14
+ // (AGENTS.md "Errors are structured" + charter proposition 4 explicit failure).
15
+ // - The opaque handle types ParsedModule / ValidatedModule are re-exported
16
+ // so downstream consumers (@forgeax/engine-shader-compiler) can hold the handle
17
+ // between phases without inspecting the underlying naga IR
18
+ // (plan-strategy §S-1 opaque handle invariant).
19
+
20
+ import { ensureReady } from '@forgeax/engine-wgpu-wasm';
21
+ import { err, ok, type Result, type ShaderError, wrapShaderError } from './errors.js';
22
+
23
+ export {
24
+ compileFailed,
25
+ err,
26
+ initFailed,
27
+ manifestMalformed,
28
+ ok,
29
+ type Result,
30
+ type ResultErr,
31
+ type ResultOk,
32
+ ShaderError,
33
+ type ShaderErrorCode,
34
+ type ShaderErrorDetail,
35
+ shaderNotFound,
36
+ } from './errors.js';
37
+
38
+ // === Opaque handle types ============================================================
39
+
40
+ /**
41
+ * Handle for the `parse` output. The underlying type is a wasm-bindgen exported
42
+ * struct: JS can only hold the handle — it cannot inspect naga IR fields
43
+ * directly (charter proposition 4 + opaque handle invariant). Pass through to
44
+ * `validate` to advance to phase 2.
45
+ *
46
+ * Surface type uses `unknown` to keep this layer math-free and opaque-handle
47
+ * pure (no direct dependency on @forgeax/engine-wgpu-wasm/pkg ABI types). Downstream
48
+ * consumers should not inspect the handle.
49
+ */
50
+ export type ParsedModule = unknown;
51
+
52
+ /**
53
+ * Handle for the `validate` output (Module + ModuleInfo); pass through to
54
+ * `emit_reflection` for the reflection JSON emit.
55
+ */
56
+ export type ValidatedModule = unknown;
57
+
58
+ /** Structured material facts emitted by the independent raw Naga reflection. */
59
+ export interface RawMaterialMemberFact {
60
+ readonly name: string;
61
+ readonly type: string;
62
+ readonly offset: number;
63
+ readonly size: number;
64
+ readonly alignment: number;
65
+ }
66
+
67
+ export interface RawMaterialResourceFact {
68
+ readonly name: string;
69
+ readonly kind: 'sampler' | 'texture' | 'storage-buffer';
70
+ readonly binding: number;
71
+ }
72
+
73
+ export interface RawMaterialReflectionFacts {
74
+ readonly members: readonly RawMaterialMemberFact[];
75
+ readonly resources: readonly RawMaterialResourceFact[];
76
+ readonly totalBytes: number;
77
+ }
78
+
79
+ // === Phase 1: parse =================================================================
80
+
81
+ /**
82
+ * WGSL source -> `ParsedModule`.
83
+ *
84
+ * On failure returns `Result.err(ShaderError code='shader-compile-failed')`
85
+ * whose `lineNum` / `linePos` carry the source position (from the wasm-side
86
+ * `ParseErrorPayload`). The hint defaults to actionable WGSL fix guidance.
87
+ *
88
+ * Wasm boundary: awaits ensureReady() on first call (shared singleton with
89
+ * @forgeax/engine-rhi-wgpu); subsequent calls take the cached path.
90
+ */
91
+ export async function parse(source: string): Promise<Result<ParsedModule, ShaderError>> {
92
+ let wasm: Awaited<ReturnType<typeof ensureReady>>;
93
+ try {
94
+ wasm = await ensureReady();
95
+ } catch (e) {
96
+ return err(
97
+ wrapShaderError(
98
+ e,
99
+ 'rerun bash packages/wgpu-wasm/build.sh and verify packages/wgpu-wasm/pkg contains a fresh .wasm',
100
+ ),
101
+ );
102
+ }
103
+ try {
104
+ const parsed = wasm.parse(source);
105
+ return ok(parsed as ParsedModule);
106
+ } catch (e) {
107
+ return err(wrapShaderError(e));
108
+ }
109
+ }
110
+
111
+ // === Phase 2: validate ==============================================================
112
+
113
+ /**
114
+ * `ParsedModule` -> `ValidatedModule` (Module + ModuleInfo).
115
+ *
116
+ * **Ownership transfer** — wasm-bindgen consumes the `parsed` handle. Do not
117
+ * reuse the handle after this call; passing a consumed handle is undefined
118
+ * behaviour on the wasm side (research Finding 6 ownership semantics).
119
+ *
120
+ * On failure returns `Result.err(ShaderError code='shader-compile-failed')`.
121
+ * Validator errors have no source position attached on the wasm side, so
122
+ * `lineNum` / `linePos` remain undefined.
123
+ */
124
+ export async function validate(
125
+ parsed: ParsedModule,
126
+ ): Promise<Result<ValidatedModule, ShaderError>> {
127
+ let wasm: Awaited<ReturnType<typeof ensureReady>>;
128
+ try {
129
+ wasm = await ensureReady();
130
+ } catch (e) {
131
+ return err(
132
+ wrapShaderError(
133
+ e,
134
+ 'rerun bash packages/wgpu-wasm/build.sh and verify packages/wgpu-wasm/pkg contains a fresh .wasm',
135
+ ),
136
+ );
137
+ }
138
+ try {
139
+ const validated = (wasm.validate as (p: unknown) => unknown)(parsed);
140
+ return ok(validated as ValidatedModule);
141
+ } catch (e) {
142
+ return err(wrapShaderError(e));
143
+ }
144
+ }
145
+
146
+ // === Composer passthrough ===========================================================
147
+
148
+ /**
149
+ * naga_oil Composer passthrough — `#import` + `#ifdef` composition over WGSL.
150
+ *
151
+ * Thin TS wrap over `@forgeax/engine-wgpu-wasm`'s raw `compose_shader` export
152
+ * (feat-20260512 M1 compose.rs). Three-argument surface:
153
+ *
154
+ * - `entry` — the entry-point WGSL source (may contain `#import` directives
155
+ * and `#ifdef` guards).
156
+ * - `imports` — `moduleId -> wgslSource` map; each value is a companion
157
+ * module whose header declares `#define_import_path <moduleId>` so the
158
+ * upstream composer can register it. The map is JSON.stringified at the
159
+ * wasm boundary.
160
+ * - `defines` — `name -> boolean` map driving `#ifdef` branch elimination
161
+ * (plan-strategy D-06: non-boolean values are rejected at the TS layer by
162
+ * the shader-compiler wrapper; this wrap takes booleans verbatim). Also
163
+ * JSON.stringified at the boundary.
164
+ *
165
+ * Return: the composed WGSL string (entry + inlined imports, `#ifdef` branches
166
+ * resolved).
167
+ *
168
+ * Errors: the raw wasm export throws `JsError` whose message carries a
169
+ * `shader-import-not-found: ...` or `shader-compile-failed: ...` prefix
170
+ * (feat-20260512 M1 compose.rs convention). This wrap does **not** translate
171
+ * the prefix into a structured `ShaderError`; that splitting happens one layer
172
+ * up at `@forgeax/engine-shader-compiler` (feat-20260512 M3), which is where
173
+ * the three-argument `compileShader(src, { imports, defines, id })` entry lives.
174
+ * Callers of this raw passthrough should `try / catch` the thrown error.
175
+ *
176
+ * Wasm boundary: awaits `ensureReady()` on first call (shared singleton with
177
+ * `@forgeax/engine-rhi-wgpu` + other naga phases); subsequent calls take the
178
+ * cached path.
179
+ */
180
+ export async function composeShader(
181
+ entry: string,
182
+ imports: Record<string, string>,
183
+ defines: Record<string, boolean>,
184
+ ): Promise<string> {
185
+ const wasm = await ensureReady();
186
+ const compose = (wasm as { compose_shader: (e: string, i: string, d: string) => string })
187
+ .compose_shader;
188
+ return compose(entry, JSON.stringify(imports), JSON.stringify(defines));
189
+ }
190
+
191
+ // === Phase 3: emit_reflection =======================================================
192
+
193
+ /**
194
+ * `ValidatedModule` + options JSON -> `BindGroupLayoutDescriptor[]` JSON string.
195
+ *
196
+ * `options_json` shape: `{ "dynamicOffsets": [{ "group": u32, "binding": u32 }, ...] }`.
197
+ * The naga IR does not express the dynamic-offset dimension (research Finding 2
198
+ * footnote), so it is injected via this JS-side options string. Pass an empty
199
+ * `{}` (or a JSON-encoded object without `dynamicOffsets`) for the no-dynamic-
200
+ * offset path.
201
+ *
202
+ * The validator's borrowed reference is **not** consumed — the same
203
+ * `ValidatedModule` handle can be reused for repeated emits with different
204
+ * options (e.g. for variant generation).
205
+ */
206
+ export async function emit_reflection(
207
+ validated: ValidatedModule,
208
+ options_json: string,
209
+ ): Promise<Result<string, ShaderError>> {
210
+ let wasm: Awaited<ReturnType<typeof ensureReady>>;
211
+ try {
212
+ wasm = await ensureReady();
213
+ } catch (e) {
214
+ return err(
215
+ wrapShaderError(
216
+ e,
217
+ 'rerun bash packages/wgpu-wasm/build.sh and verify packages/wgpu-wasm/pkg contains a fresh .wasm',
218
+ ),
219
+ );
220
+ }
221
+ try {
222
+ const reflectionJson = (wasm.emit_reflection as (v: unknown, o: string) => string)(
223
+ validated,
224
+ options_json,
225
+ );
226
+ return ok(reflectionJson);
227
+ } catch (e) {
228
+ return err(wrapShaderError(e));
229
+ }
230
+ }