@forgeax/engine-naga 0.0.0-dev.8d955ade1c79

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/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,384 @@
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 {
22
+ err,
23
+ initFailed,
24
+ manifestMalformed,
25
+ ok,
26
+ type Result,
27
+ ShaderError,
28
+ type ShaderError as ShaderErrorType,
29
+ wrapShaderError,
30
+ } from './errors.js';
31
+
32
+ export {
33
+ compileFailed,
34
+ err,
35
+ initFailed,
36
+ manifestMalformed,
37
+ ok,
38
+ type Result,
39
+ type ResultErr,
40
+ type ResultOk,
41
+ ShaderError,
42
+ type ShaderErrorCode,
43
+ type ShaderErrorDetail,
44
+ shaderNotFound,
45
+ } from './errors.js';
46
+
47
+ // === Opaque handle types ============================================================
48
+
49
+ /**
50
+ * Handle for the `parse` output. The underlying type is a wasm-bindgen exported
51
+ * struct: JS can only hold the handle — it cannot inspect naga IR fields
52
+ * directly (charter proposition 4 + opaque handle invariant). Pass through to
53
+ * `validate` to advance to phase 2.
54
+ *
55
+ * Surface type uses `unknown` to keep this layer math-free and opaque-handle
56
+ * pure (no direct dependency on @forgeax/engine-wgpu-wasm/pkg ABI types). Downstream
57
+ * consumers should not inspect the handle.
58
+ */
59
+ export type ParsedModule = unknown;
60
+
61
+ /**
62
+ * Handle for the `validate` output (Module + ModuleInfo); pass through to
63
+ * `emit_reflection` for the reflection JSON emit.
64
+ */
65
+ export type ValidatedModule = unknown;
66
+
67
+ export interface ShaderReflectionMember {
68
+ readonly name: string;
69
+ readonly type: string;
70
+ readonly offset: number;
71
+ readonly size: number;
72
+ readonly alignment: number;
73
+ }
74
+
75
+ export interface ShaderReflectionBoundGlobal {
76
+ readonly group: number;
77
+ readonly binding: number;
78
+ readonly addressSpace: string;
79
+ readonly resourceKind: string;
80
+ readonly visibility: number;
81
+ readonly name?: string;
82
+ readonly members?: readonly ShaderReflectionMember[];
83
+ readonly span?: number;
84
+ }
85
+
86
+ export interface ShaderReflection {
87
+ readonly schemaVersion: 'shader-reflection/2';
88
+ readonly boundGlobals: readonly ShaderReflectionBoundGlobal[];
89
+ readonly uvSetCount: number;
90
+ }
91
+
92
+ type ReflectionRecord = Record<string, unknown>;
93
+
94
+ function reflectionMalformed(reason: string): ShaderError {
95
+ return manifestMalformed({
96
+ message: `shader-reflection/2 is malformed: ${reason}`,
97
+ hint: 'rebuild the shader with the current Naga/WASM producer and preserve every bound-global fact',
98
+ reason,
99
+ });
100
+ }
101
+
102
+ function isRecord(value: unknown): value is ReflectionRecord {
103
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
104
+ }
105
+
106
+ function requiredNonNegativeInteger(record: ReflectionRecord, key: string): number {
107
+ const value = record[key];
108
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
109
+ throw reflectionMalformed(`boundGlobals entry requires non-negative integer '${key}'`);
110
+ }
111
+ return value;
112
+ }
113
+
114
+ function readMember(value: unknown, index: number): ShaderReflectionMember {
115
+ if (!isRecord(value)) throw reflectionMalformed(`member ${index} is not an object`);
116
+ const name = value.name;
117
+ const type = value.type;
118
+ if (
119
+ typeof name !== 'string' ||
120
+ name.length === 0 ||
121
+ typeof type !== 'string' ||
122
+ type.length === 0
123
+ ) {
124
+ throw reflectionMalformed(`member ${index} requires name and type`);
125
+ }
126
+ return {
127
+ name,
128
+ type,
129
+ offset: requiredNonNegativeInteger(value, 'offset'),
130
+ size: requiredNonNegativeInteger(value, 'size'),
131
+ alignment: requiredNonNegativeInteger(value, 'alignment'),
132
+ };
133
+ }
134
+
135
+ function readBoundGlobal(value: unknown, index: number): ShaderReflectionBoundGlobal {
136
+ if (!isRecord(value)) throw reflectionMalformed(`boundGlobals entry ${index} is not an object`);
137
+ const addressSpace = value.addressSpace;
138
+ const resourceKind = value.resourceKind;
139
+ if (typeof addressSpace !== 'string' || addressSpace.length === 0) {
140
+ throw reflectionMalformed(`boundGlobals entry ${index} requires addressSpace`);
141
+ }
142
+ if (typeof resourceKind !== 'string' || resourceKind.length === 0) {
143
+ throw reflectionMalformed(`boundGlobals entry ${index} requires resourceKind`);
144
+ }
145
+ const visibility = requiredNonNegativeInteger(value, 'visibility');
146
+ const membersValue = value.members;
147
+ const hasMembers = membersValue !== undefined;
148
+ if (hasMembers && !Array.isArray(membersValue)) {
149
+ throw reflectionMalformed(`boundGlobals entry ${index} members must be an array`);
150
+ }
151
+ const hasSpan = value.span !== undefined;
152
+ if (resourceKind === 'buffer' || resourceKind === 'storage-buffer') {
153
+ if (!hasMembers || !hasSpan) {
154
+ throw reflectionMalformed(`buffer boundGlobals entry ${index} requires members and span`);
155
+ }
156
+ } else if (hasMembers !== hasSpan) {
157
+ throw reflectionMalformed(`boundGlobals entry ${index} members and span must be paired`);
158
+ }
159
+ if (value.name !== undefined && typeof value.name !== 'string') {
160
+ throw reflectionMalformed(`boundGlobals entry ${index} diagnostic name must be a string`);
161
+ }
162
+ return {
163
+ group: requiredNonNegativeInteger(value, 'group'),
164
+ binding: requiredNonNegativeInteger(value, 'binding'),
165
+ addressSpace,
166
+ resourceKind,
167
+ visibility,
168
+ ...(value.name !== undefined ? { name: value.name as string } : {}),
169
+ ...(hasMembers
170
+ ? {
171
+ members: (membersValue as unknown[]).map((member, memberIndex) =>
172
+ readMember(member, memberIndex),
173
+ ),
174
+ span: requiredNonNegativeInteger(value, 'span'),
175
+ }
176
+ : {}),
177
+ };
178
+ }
179
+
180
+ /** Parse and validate the business-neutral shader-reflection/2 wire. */
181
+ export function parseReflectionWire(json: string): ShaderReflection {
182
+ let parsed: unknown;
183
+ try {
184
+ parsed = JSON.parse(json);
185
+ } catch (cause) {
186
+ throw reflectionMalformed(cause instanceof Error ? cause.message : 'JSON.parse failed');
187
+ }
188
+ if (!isRecord(parsed) || parsed.schemaVersion !== 'shader-reflection/2') {
189
+ throw reflectionMalformed("schemaVersion must equal 'shader-reflection/2'");
190
+ }
191
+ if ('material' in parsed) throw reflectionMalformed('legacy material projection is not accepted');
192
+ if (!Array.isArray(parsed.boundGlobals)) {
193
+ throw reflectionMalformed('boundGlobals must be an array');
194
+ }
195
+ if (
196
+ typeof parsed.uvSetCount !== 'number' ||
197
+ !Number.isSafeInteger(parsed.uvSetCount) ||
198
+ parsed.uvSetCount < 0
199
+ ) {
200
+ throw reflectionMalformed('uvSetCount must be a non-negative integer');
201
+ }
202
+ const boundGlobals = parsed.boundGlobals.map((global, index) => readBoundGlobal(global, index));
203
+ const coordinates = new Set<string>();
204
+ for (const global of boundGlobals) {
205
+ const coordinate = `${global.group}:${global.binding}`;
206
+ if (coordinates.has(coordinate))
207
+ throw reflectionMalformed(`duplicate bound-global coordinate ${coordinate}`);
208
+ coordinates.add(coordinate);
209
+ }
210
+ return { schemaVersion: 'shader-reflection/2', boundGlobals, uvSetCount: parsed.uvSetCount };
211
+ }
212
+
213
+ /** Result-form reader for callers that need an explicit unavailable/malformed branch. */
214
+ export function readReflectionWire(
215
+ json: string | undefined,
216
+ ): Result<ShaderReflection, ShaderErrorType> {
217
+ if (json === undefined) {
218
+ return err(
219
+ initFailed({
220
+ message: 'shader-reflection/2 is unavailable before the Naga producer emits a wire',
221
+ hint: 'run the validated compose -> reflect path and retain its raw reflection bytes',
222
+ reason: 'reflection wire unavailable',
223
+ }),
224
+ );
225
+ }
226
+ try {
227
+ return ok(parseReflectionWire(json));
228
+ } catch (error) {
229
+ return err(error instanceof ShaderError ? error : reflectionMalformed(String(error)));
230
+ }
231
+ }
232
+
233
+ // === Phase 1: parse =================================================================
234
+
235
+ /**
236
+ * WGSL source -> `ParsedModule`.
237
+ *
238
+ * On failure returns `Result.err(ShaderError code='shader-compile-failed')`
239
+ * whose `lineNum` / `linePos` carry the source position (from the wasm-side
240
+ * `ParseErrorPayload`). The hint defaults to actionable WGSL fix guidance.
241
+ *
242
+ * Wasm boundary: awaits ensureReady() on first call (shared singleton with
243
+ * @forgeax/engine-rhi-wgpu); subsequent calls take the cached path.
244
+ */
245
+ export async function parse(source: string): Promise<Result<ParsedModule, ShaderError>> {
246
+ let wasm: Awaited<ReturnType<typeof ensureReady>>;
247
+ try {
248
+ wasm = await ensureReady();
249
+ } catch (e) {
250
+ return err(
251
+ wrapShaderError(
252
+ e,
253
+ 'rerun bash packages/wgpu-wasm/build.sh and verify packages/wgpu-wasm/pkg contains a fresh .wasm',
254
+ ),
255
+ );
256
+ }
257
+ try {
258
+ const parsed = wasm.parse(source);
259
+ return ok(parsed as ParsedModule);
260
+ } catch (e) {
261
+ return err(wrapShaderError(e));
262
+ }
263
+ }
264
+
265
+ // === Phase 2: validate ==============================================================
266
+
267
+ /**
268
+ * `ParsedModule` -> `ValidatedModule` (Module + ModuleInfo).
269
+ *
270
+ * **Ownership transfer** — wasm-bindgen consumes the `parsed` handle. Do not
271
+ * reuse the handle after this call; passing a consumed handle is undefined
272
+ * behaviour on the wasm side (research Finding 6 ownership semantics).
273
+ *
274
+ * On failure returns `Result.err(ShaderError code='shader-compile-failed')`.
275
+ * Validator errors have no source position attached on the wasm side, so
276
+ * `lineNum` / `linePos` remain undefined.
277
+ */
278
+ export async function validate(
279
+ parsed: ParsedModule,
280
+ ): Promise<Result<ValidatedModule, ShaderError>> {
281
+ let wasm: Awaited<ReturnType<typeof ensureReady>>;
282
+ try {
283
+ wasm = await ensureReady();
284
+ } catch (e) {
285
+ return err(
286
+ wrapShaderError(
287
+ e,
288
+ 'rerun bash packages/wgpu-wasm/build.sh and verify packages/wgpu-wasm/pkg contains a fresh .wasm',
289
+ ),
290
+ );
291
+ }
292
+ try {
293
+ const validated = (wasm.validate as (p: unknown) => unknown)(parsed);
294
+ return ok(validated as ValidatedModule);
295
+ } catch (e) {
296
+ return err(wrapShaderError(e));
297
+ }
298
+ }
299
+
300
+ // === Composer passthrough ===========================================================
301
+
302
+ /**
303
+ * naga_oil Composer passthrough — `#import` + `#ifdef` composition over WGSL.
304
+ *
305
+ * Thin TS wrap over `@forgeax/engine-wgpu-wasm`'s raw `compose_shader` export
306
+ * (feat-20260512 M1 compose.rs). Three-argument surface:
307
+ *
308
+ * - `entry` — the entry-point WGSL source (may contain `#import` directives
309
+ * and `#ifdef` guards).
310
+ * - `imports` — `moduleId -> wgslSource` map; each value is a companion
311
+ * module whose header declares `#define_import_path <moduleId>` so the
312
+ * upstream composer can register it. The map is JSON.stringified at the
313
+ * wasm boundary.
314
+ * - `defines` — `name -> boolean` map driving `#ifdef` branch elimination
315
+ * (plan-strategy D-06: non-boolean values are rejected at the TS layer by
316
+ * the shader-compiler wrapper; this wrap takes booleans verbatim). Also
317
+ * JSON.stringified at the boundary.
318
+ *
319
+ * Return: the composed WGSL string (entry + inlined imports, `#ifdef` branches
320
+ * resolved).
321
+ *
322
+ * Errors: the raw wasm export throws `JsError` whose message carries a
323
+ * `shader-import-not-found: ...` or `shader-compile-failed: ...` prefix
324
+ * (feat-20260512 M1 compose.rs convention). This wrap does **not** translate
325
+ * the prefix into a structured `ShaderError`; that splitting happens one layer
326
+ * up at `@forgeax/engine-shader-compiler` (feat-20260512 M3), which is where
327
+ * the three-argument `compileShader(src, { imports, defines, id })` entry lives.
328
+ * Callers of this raw passthrough should `try / catch` the thrown error.
329
+ *
330
+ * Wasm boundary: awaits `ensureReady()` on first call (shared singleton with
331
+ * `@forgeax/engine-rhi-wgpu` + other naga phases); subsequent calls take the
332
+ * cached path.
333
+ */
334
+ export async function composeShader(
335
+ entry: string,
336
+ imports: Record<string, string>,
337
+ defines: Record<string, boolean>,
338
+ ): Promise<string> {
339
+ const wasm = await ensureReady();
340
+ const compose = (wasm as { compose_shader: (e: string, i: string, d: string) => string })
341
+ .compose_shader;
342
+ return compose(entry, JSON.stringify(imports), JSON.stringify(defines));
343
+ }
344
+
345
+ // === Phase 3: emit_reflection =======================================================
346
+
347
+ /**
348
+ * `ValidatedModule` + options JSON -> `BindGroupLayoutDescriptor[]` JSON string.
349
+ *
350
+ * `options_json` shape: `{ "dynamicOffsets": [{ "group": u32, "binding": u32 }, ...] }`.
351
+ * The naga IR does not express the dynamic-offset dimension (research Finding 2
352
+ * footnote), so it is injected via this JS-side options string. Pass an empty
353
+ * `{}` (or a JSON-encoded object without `dynamicOffsets`) for the no-dynamic-
354
+ * offset path.
355
+ *
356
+ * The validator's borrowed reference is **not** consumed — the same
357
+ * `ValidatedModule` handle can be reused for repeated emits with different
358
+ * options (e.g. for variant generation).
359
+ */
360
+ export async function emit_reflection(
361
+ validated: ValidatedModule,
362
+ options_json: string,
363
+ ): Promise<Result<string, ShaderError>> {
364
+ let wasm: Awaited<ReturnType<typeof ensureReady>>;
365
+ try {
366
+ wasm = await ensureReady();
367
+ } catch (e) {
368
+ return err(
369
+ wrapShaderError(
370
+ e,
371
+ 'rerun bash packages/wgpu-wasm/build.sh and verify packages/wgpu-wasm/pkg contains a fresh .wasm',
372
+ ),
373
+ );
374
+ }
375
+ try {
376
+ const reflectionJson = (wasm.emit_reflection as (v: unknown, o: string) => string)(
377
+ validated,
378
+ options_json,
379
+ );
380
+ return ok(reflectionJson);
381
+ } catch (e) {
382
+ return err(wrapShaderError(e));
383
+ }
384
+ }