@forgeax/engine-rhi-webgpu 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 (45) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +124 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/__mocks__/gpu-device.d.ts +233 -0
  5. package/dist/__tests__/__mocks__/gpu-device.d.ts.map +1 -0
  6. package/dist/__tests__/dawn-real-gpu.dawn.test.d.ts +2 -0
  7. package/dist/__tests__/dawn-real-gpu.dawn.test.d.ts.map +1 -0
  8. package/dist/__tests__/queue-write-range.browser.test.d.ts +2 -0
  9. package/dist/__tests__/queue-write-range.browser.test.d.ts.map +1 -0
  10. package/dist/__tests__/rgba16float-live-probe.browser.test.d.ts +2 -0
  11. package/dist/__tests__/rgba16float-live-probe.browser.test.d.ts.map +1 -0
  12. package/dist/__tests__/rgba16float-live-probe.d.ts +12 -0
  13. package/dist/__tests__/rgba16float-live-probe.d.ts.map +1 -0
  14. package/dist/__tests__/rgba16float-live-probe.dawn.test.d.ts +2 -0
  15. package/dist/__tests__/rgba16float-live-probe.dawn.test.d.ts.map +1 -0
  16. package/dist/__tests__/rhi-webgpu.unit.test.d.ts +2 -0
  17. package/dist/__tests__/rhi-webgpu.unit.test.d.ts.map +1 -0
  18. package/dist/device.d.ts +62 -0
  19. package/dist/device.d.ts.map +1 -0
  20. package/dist/errors.d.ts +47 -0
  21. package/dist/errors.d.ts.map +1 -0
  22. package/dist/index.d.ts +183 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.mjs +1704 -0
  25. package/dist/index.mjs.map +1 -0
  26. package/dist/internal/__tests__/timestamp-query.unit.test.d.ts +2 -0
  27. package/dist/internal/__tests__/timestamp-query.unit.test.d.ts.map +1 -0
  28. package/dist/internal/error-translation.d.ts +16 -0
  29. package/dist/internal/error-translation.d.ts.map +1 -0
  30. package/dist/internal/timestamp-query.d.ts +39 -0
  31. package/dist/internal/timestamp-query.d.ts.map +1 -0
  32. package/package.json +58 -0
  33. package/src/__tests__/__mocks__/gpu-device.ts +564 -0
  34. package/src/__tests__/dawn-real-gpu.dawn.test.ts +1488 -0
  35. package/src/__tests__/queue-write-range.browser.test.ts +60 -0
  36. package/src/__tests__/rgba16float-live-probe.browser.test.ts +13 -0
  37. package/src/__tests__/rgba16float-live-probe.dawn.test.ts +15 -0
  38. package/src/__tests__/rgba16float-live-probe.ts +44 -0
  39. package/src/__tests__/rhi-webgpu.unit.test.ts +2409 -0
  40. package/src/device.ts +1974 -0
  41. package/src/errors.ts +145 -0
  42. package/src/index.ts +599 -0
  43. package/src/internal/__tests__/timestamp-query.unit.test.ts +88 -0
  44. package/src/internal/error-translation.ts +187 -0
  45. package/src/internal/timestamp-query.ts +167 -0
@@ -0,0 +1,1488 @@
1
+ // dawn-real-gpu.test.ts -- vitest dawn project (AC-RSC-07 / L-P3) D-S3 4 error
2
+ // codes triggered against a real GPU adapter (dawn.node native binding).
3
+ //
4
+ // Trigger: root vitest.config.ts dawn project (`*.dawn.test.ts` glob).
5
+ // Setup file: ./vitest.setup-webgpu.ts injects globalThis.navigator.gpu.
6
+ //
7
+ // Why a separate file from `packages/engine/__tests__/webgpu-backend.dawn.test.ts`:
8
+ // the engine dawn test focuses on the hello-triangle smoke recording chain
9
+ // end-to-end. This file focuses on the 4 D-S3 RhiErrorCode triggers that
10
+ // mock-only tests cannot fully exercise (charter candidate proposition 6:
11
+ // mock vs real-GPU divergence; plan-decisions L-P3 / plan-strategy R-7).
12
+ //
13
+ // 4 D-S3 RhiErrorCode triggers (one describe block per code):
14
+ // (1) 'command-encoder-finished'
15
+ // encoder.finish() succeeds -> a second encoder.finish() returns
16
+ // Result.err({ code: 'command-encoder-finished' }) (the void-returning
17
+ // record APIs throw on finished encoders; finish() returns Result so
18
+ // the structured error is observable through the Result channel).
19
+ // (2) 'render-pass-not-ended'
20
+ // beginRenderPass() without calling pass.end() -> encoder.finish()
21
+ // returns Result.err({ code: 'render-pass-not-ended' }).
22
+ // (3) 'queue-submit-failed'
23
+ // submit a command buffer twice in a row; the second submit forwards
24
+ // to GPUQueue.submit which dawn rejects with a validation error wrapped
25
+ // to Result.err({ code: 'queue-submit-failed' }).
26
+ // (4) 'queue-write-buffer-out-of-bounds'
27
+ // writeBuffer(buf, 0, data) where data.byteLength > buf.size; the
28
+ // per-buffer bounds guard returns Result.err.
29
+ //
30
+ // Charter mapping:
31
+ // - proposition 4 (explicit failure): every code is observable via Result.err
32
+ // with .code / .expected / .hint; AI users grep test names per code.
33
+ // - candidate proposition 6 (mock vs real-GPU): mocks cannot exercise dawn's
34
+ // internal validation; this file is the truth check (plan-strategy R-7).
35
+
36
+ // M6 (feat-20260510-rhi-resource-creation / w42): all `_internal_getRawDevice`
37
+ // call sites are migrated off; the dawn tests now drive `rhi.requestAdapter()`
38
+ // + `adapter.requestDevice()` (the strict two-step path, charter proposition 5
39
+ // consistent abstraction). The single test that needs raw GPUDevice access
40
+ // (D-S3 #3 pushErrorScope/popErrorScope candidate proposition 6 truth check)
41
+ // captures the raw device by wrapping `adapter.requestDevice` before driving
42
+ // the forgeax `rhi` factory through that wrapped adapter (the same pattern
43
+ // `apps/hello/cube/scripts/smoke-dawn.mjs` uses; AC-08 grep gate keeps
44
+ // `_internal_getRawDevice` at 0 hits across packages/ + apps/).
45
+ import type { RhiDevice } from '@forgeax/engine-rhi';
46
+ import { describe, expect, it } from 'vitest';
47
+ import { rhi } from '../index';
48
+
49
+ /**
50
+ * Helper: walk the strict two-step `rhi.requestAdapter()` ->
51
+ * `adapter.requestDevice()` path and return the resulting `RhiDevice` (or
52
+ * `undefined` on a failure that the test should bail out on).
53
+ */
54
+ async function requestRhiDevice(): Promise<RhiDevice | undefined> {
55
+ const adapterResult = await rhi.requestAdapter();
56
+ expect(adapterResult.ok).toBe(true);
57
+ if (!adapterResult.ok) return undefined;
58
+ const deviceResult = await adapterResult.value.requestDevice();
59
+ expect(deviceResult.ok).toBe(true);
60
+ if (!deviceResult.ok) return undefined;
61
+ return deviceResult.value;
62
+ }
63
+
64
+ describe('dawn-real-gpu - queue.writeBuffer typed range', () => {
65
+ it('reads back exactly the selected typed-array elements', async () => {
66
+ const device = await requestRhiDevice();
67
+ if (device === undefined) return;
68
+ const targetResult = device.createBuffer({
69
+ label: 'dawn-write-range-target',
70
+ size: 32,
71
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
72
+ });
73
+ const readbackResult = device.createBuffer({
74
+ label: 'dawn-write-range-readback',
75
+ size: 32,
76
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
77
+ });
78
+ expect(targetResult.ok && readbackResult.ok).toBe(true);
79
+ if (!targetResult.ok || !readbackResult.ok) return;
80
+
81
+ const source = new Float32Array([10, 20, 30, 40, 50]);
82
+ const write = device.queue.writeBuffer(targetResult.value, 0, source, 1, 2);
83
+ expect(write.ok).toBe(true);
84
+ if (!write.ok) return;
85
+
86
+ const encoderResult = device.createCommandEncoder({ label: 'dawn-write-range-copy' });
87
+ expect(encoderResult.ok).toBe(true);
88
+ if (!encoderResult.ok) return;
89
+ encoderResult.value.copyBufferToBuffer(targetResult.value, 0, readbackResult.value, 0, 32);
90
+ const finish = encoderResult.value.finish();
91
+ expect(finish.ok).toBe(true);
92
+ if (!finish.ok) return;
93
+ expect(device.queue.submit([finish.value]).ok).toBe(true);
94
+ await device.queue.onSubmittedWorkDone();
95
+
96
+ const mapped = await readbackResult.value.mapAsync(GPUMapMode.READ);
97
+ expect(mapped.ok).toBe(true);
98
+ if (!mapped.ok) return;
99
+ const range = mapped.value.getMappedRange();
100
+ expect(range.ok).toBe(true);
101
+ if (!range.ok) return;
102
+ expect(Array.from(new Float32Array(range.value.slice(0)))).toEqual([20, 30, 0, 0, 0, 0, 0, 0]);
103
+ mapped.value.unmap();
104
+ });
105
+ });
106
+
107
+ function reportTimestampEvidence(evidence: Record<string, unknown>): void {
108
+ // biome-ignore lint/suspicious/noConsole: the dawn admission record is the test evidence output
109
+ console.log(JSON.stringify(evidence));
110
+ }
111
+
112
+ describe("dawn-real-gpu - 'command-encoder-finished' triggered by second finish() (D-S3 #1)", () => {
113
+ it('encoder.finish() returns ok; subsequent finish() returns command-encoder-finished', async () => {
114
+ const device = await requestRhiDevice();
115
+ if (device === undefined) return;
116
+
117
+ const encResult = device.createCommandEncoder({ label: 'dawn-finish-twice' });
118
+ expect(encResult.ok).toBe(true);
119
+ if (!encResult.ok) return;
120
+ const encoder = encResult.value;
121
+
122
+ const finishOnce = encoder.finish();
123
+ expect(finishOnce.ok).toBe(true);
124
+
125
+ const finishTwice = encoder.finish();
126
+ expect(finishTwice.ok).toBe(false);
127
+ if (!finishTwice.ok) {
128
+ expect(finishTwice.error.code).toBe('command-encoder-finished');
129
+ expect(finishTwice.error.expected.length).toBeGreaterThan(0);
130
+ expect(finishTwice.error.hint.length).toBeGreaterThan(0);
131
+ }
132
+ });
133
+
134
+ it('preserves 2d-array and 3d texture-view dimensions on native Dawn', async () => {
135
+ const device = await requestRhiDevice();
136
+ if (device === undefined) return;
137
+
138
+ const arrayTexture = device.createTexture({
139
+ label: 'dawn-array-view-source',
140
+ size: { width: 8, height: 8, depthOrArrayLayers: 4 },
141
+ dimension: '2d',
142
+ format: 'rgba8unorm',
143
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
144
+ });
145
+ expect(arrayTexture.ok).toBe(true);
146
+ if (!arrayTexture.ok) return;
147
+
148
+ const arrayView = device.createTextureView(arrayTexture.value, {
149
+ label: 'dawn-array-view',
150
+ dimension: '2d-array',
151
+ baseArrayLayer: 1,
152
+ arrayLayerCount: 2,
153
+ });
154
+ expect(arrayView.ok).toBe(true);
155
+
156
+ const volumeTexture = device.createTexture({
157
+ label: 'dawn-volume-view-source',
158
+ size: { width: 4, height: 4, depthOrArrayLayers: 4 },
159
+ dimension: '3d',
160
+ format: 'rgba8unorm',
161
+ usage: GPUTextureUsage.TEXTURE_BINDING,
162
+ });
163
+ expect(volumeTexture.ok).toBe(true);
164
+ if (!volumeTexture.ok) return;
165
+
166
+ const volumeView = device.createTextureView(volumeTexture.value, {
167
+ label: 'dawn-volume-view',
168
+ dimension: '3d',
169
+ });
170
+ expect(volumeView.ok).toBe(true);
171
+ });
172
+ });
173
+
174
+ describe("dawn-real-gpu - 'render-pass-not-ended' triggered by finish() with active pass (D-S3 #2)", () => {
175
+ it('encoder.beginRenderPass() then encoder.finish() without pass.end() returns render-pass-not-ended', async () => {
176
+ const device = await requestRhiDevice();
177
+ if (device === undefined) return;
178
+
179
+ const texResult = device.createTexture({
180
+ label: 'dawn-pass-not-ended-target',
181
+ size: { width: 32, height: 32, depthOrArrayLayers: 1 },
182
+ format: 'rgba8unorm',
183
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
184
+ });
185
+ expect(texResult.ok).toBe(true);
186
+ if (!texResult.ok) return;
187
+ const viewResult = device.createTextureView(texResult.value, {});
188
+ expect(viewResult.ok).toBe(true);
189
+ if (!viewResult.ok) return;
190
+ const view = viewResult.value;
191
+
192
+ const encResult = device.createCommandEncoder({ label: 'dawn-pass-not-ended' });
193
+ expect(encResult.ok).toBe(true);
194
+ if (!encResult.ok) return;
195
+ const encoder = encResult.value;
196
+
197
+ // Begin a pass and intentionally do NOT call pass.end() before finish().
198
+ void encoder.beginRenderPass({
199
+ colorAttachments: [
200
+ {
201
+ view: view as never,
202
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
203
+ loadOp: 'clear',
204
+ storeOp: 'store',
205
+ },
206
+ ],
207
+ } as never);
208
+
209
+ const finishResult = encoder.finish();
210
+ expect(finishResult.ok).toBe(false);
211
+ if (!finishResult.ok) {
212
+ expect(finishResult.error.code).toBe('render-pass-not-ended');
213
+ expect(finishResult.error.expected.length).toBeGreaterThan(0);
214
+ expect(finishResult.error.hint.length).toBeGreaterThan(0);
215
+ }
216
+ });
217
+ });
218
+
219
+ describe("dawn-real-gpu - 'queue-submit-failed' triggered by double-submit (D-S3 #3, candidate proposition 6 truth check)", () => {
220
+ it('queue.submit(cmdBuf) twice on real GPU surfaces a GPUValidationError; shim wrap path returns queue-submit-failed when caught synchronously, otherwise the validation error reaches popErrorScope and the candidate proposition 6 monitoring fires', async () => {
221
+ // M6 / w42: capture the raw GPUDevice by monkey-patching
222
+ // `navigator.gpu.requestAdapter` so the spec `adapter.requestDevice`
223
+ // returns through our hook (the same intercept pattern that
224
+ // `apps/hello/cube/scripts/smoke-dawn.mjs` uses). This avoids the M4-torn
225
+ // `_internal_getRawDevice` cross-package escape hatch while still
226
+ // surfacing the raw device for the candidate-proposition-6 truth check
227
+ // (pushErrorScope / popErrorScope are GPUDevice-only spec entries; not
228
+ // on the forgeax RHI surface).
229
+ const navWithGpu = globalThis as {
230
+ navigator?: { gpu?: { requestAdapter: (opts?: unknown) => Promise<unknown> } };
231
+ };
232
+ const ambient = navWithGpu.navigator?.gpu;
233
+ if (ambient === undefined || ambient === null) return;
234
+ const originalAmbientRequestAdapter = ambient.requestAdapter.bind(ambient);
235
+ let capturedRawDevice: GPUDevice | undefined;
236
+ ambient.requestAdapter = async (opts?: unknown): Promise<unknown> => {
237
+ const rawAdapter = (await originalAmbientRequestAdapter(opts)) as {
238
+ requestDevice: (desc?: unknown) => Promise<unknown>;
239
+ } | null;
240
+ if (rawAdapter === null) return rawAdapter;
241
+ const originalRequestDevice = rawAdapter.requestDevice.bind(rawAdapter);
242
+ rawAdapter.requestDevice = async (desc?: unknown): Promise<unknown> => {
243
+ const dev = (await originalRequestDevice(desc)) as GPUDevice;
244
+ if (capturedRawDevice === undefined) capturedRawDevice = dev;
245
+ return dev;
246
+ };
247
+ return rawAdapter;
248
+ };
249
+ let device: RhiDevice | undefined;
250
+ try {
251
+ device = await requestRhiDevice();
252
+ } finally {
253
+ ambient.requestAdapter = originalAmbientRequestAdapter;
254
+ }
255
+ if (device === undefined) return;
256
+ expect(capturedRawDevice).toBeDefined();
257
+ if (capturedRawDevice === undefined) return;
258
+ const rawDevice = capturedRawDevice;
259
+
260
+ const texResult = device.createTexture({
261
+ label: 'dawn-submit-failed-target',
262
+ size: { width: 32, height: 32, depthOrArrayLayers: 1 },
263
+ format: 'rgba8unorm',
264
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
265
+ });
266
+ expect(texResult.ok).toBe(true);
267
+ if (!texResult.ok) return;
268
+ const viewResult = device.createTextureView(texResult.value, {});
269
+ expect(viewResult.ok).toBe(true);
270
+ if (!viewResult.ok) return;
271
+ const view = viewResult.value;
272
+
273
+ const encResult = device.createCommandEncoder({ label: 'dawn-submit-failed' });
274
+ expect(encResult.ok).toBe(true);
275
+ if (!encResult.ok) return;
276
+ const encoder = encResult.value;
277
+ const pass = encoder.beginRenderPass({
278
+ colorAttachments: [
279
+ {
280
+ view: view as never,
281
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
282
+ loadOp: 'clear',
283
+ storeOp: 'store',
284
+ },
285
+ ],
286
+ } as never);
287
+ pass.end();
288
+ const finishResult = encoder.finish();
289
+ expect(finishResult.ok).toBe(true);
290
+ if (!finishResult.ok) return;
291
+ const cmdBuf = finishResult.value;
292
+
293
+ // First submit consumes the command buffer (WebGPU spec: a CommandBuffer
294
+ // can be submitted at most once).
295
+ const submit1 = device.queue.submit([cmdBuf]);
296
+ expect(submit1.ok).toBe(true);
297
+
298
+ // Wrap the failing submit in an error scope. Dawn raises a validation
299
+ // error on the second submit; popErrorScope() resolves with a
300
+ // GPUValidationError. Either the shim's try/catch wrap returns
301
+ // Result.err({code:'queue-submit-failed'}) synchronously, or the async
302
+ // validation surfaces via popErrorScope. Both paths assert real
303
+ // validation reached the test (charter proposition 4 explicit failure;
304
+ // no silent pass).
305
+ rawDevice.pushErrorScope('validation');
306
+ const submit2 = device.queue.submit([cmdBuf]);
307
+ const validationError = await rawDevice.popErrorScope();
308
+
309
+ if (!submit2.ok) {
310
+ // Synchronous catch path inside the shim wrapped the throw.
311
+ expect(submit2.error.code).toBe('queue-submit-failed');
312
+ expect(submit2.error.expected.length).toBeGreaterThan(0);
313
+ expect(submit2.error.hint.length).toBeGreaterThan(0);
314
+ } else {
315
+ // Async validation path: dawn surfaced the error through popErrorScope.
316
+ // Asserting non-null here is the candidate proposition 6 truth check:
317
+ // if dawn ever stops raising here, the test fails (silent-pass
318
+ // monitoring per plan-strategy R-7).
319
+ expect(validationError).not.toBeNull();
320
+ expect(validationError?.message ?? '').toMatch(/submitted more than once|invalid|destroyed/i);
321
+ }
322
+ });
323
+ });
324
+
325
+ describe("dawn-real-gpu - 'queue-write-buffer-out-of-bounds' triggered by oversized writeBuffer (D-S3 #4)", () => {
326
+ it('writeBuffer where offset + data.byteLength > buffer.size returns queue-write-buffer-out-of-bounds', async () => {
327
+ const device = await requestRhiDevice();
328
+ if (device === undefined) return;
329
+
330
+ const bufResult = device.createBuffer({
331
+ label: 'dawn-oob-buffer',
332
+ size: 16,
333
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.UNIFORM,
334
+ });
335
+ expect(bufResult.ok).toBe(true);
336
+ if (!bufResult.ok) return;
337
+ const buffer = bufResult.value;
338
+
339
+ // 32 bytes data into a 16-byte buffer at offset 0 -> bounds violation.
340
+ const data = new Uint8Array(32);
341
+ const out = device.queue.writeBuffer(buffer, 0, data);
342
+ expect(out.ok).toBe(false);
343
+ if (!out.ok) {
344
+ expect(out.error.code).toBe('queue-write-buffer-out-of-bounds');
345
+ // Hint must include the concrete numbers for AI-user routing
346
+ // (charter proposition 4 + queue-real-path.test.ts contract parity).
347
+ expect(out.error.hint).toContain('got 0');
348
+ expect(out.error.hint).toContain('got 32');
349
+ expect(out.error.hint).toContain('got 16');
350
+ }
351
+ });
352
+ });
353
+
354
+ // w05 — createTextureView dawn real-GPU red phase.
355
+ //
356
+ // Goal: assert the `device.createTextureView(tex, desc)` real path returns a
357
+ // usable handle on the happy path AND maps a cross-resource format violation
358
+ // to Result.err({ code: 'webgpu-runtime-error' }) on dawn (real validation).
359
+ //
360
+ // Currently red: RhiDevice.createTextureView is not on the interface (TS2339).
361
+ // Turns green after w06 ships interface + shim.
362
+ //
363
+ // Anchors: requirements §IN-1 / §AC-07(c) / §IN-9; research §1.1 cross-resource
364
+ // gate + §7 dawn-node real path; plan-strategy §4.2 dawn + K-10.
365
+ describe('dawn-real-gpu - createTextureView happy path returns a TextureView handle (w05)', () => {
366
+ it('device.createTextureView({format,dimension}) on a matching source texture returns ok and the handle is usable as a render-pass attachment view', async () => {
367
+ const device = await requestRhiDevice();
368
+ if (device === undefined) return;
369
+
370
+ const texResult = device.createTexture({
371
+ label: 'dawn-view-source',
372
+ size: { width: 32, height: 32, depthOrArrayLayers: 1 },
373
+ format: 'rgba8unorm',
374
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
375
+ });
376
+ expect(texResult.ok).toBe(true);
377
+ if (!texResult.ok) return;
378
+
379
+ const viewResult = device.createTextureView(texResult.value, {
380
+ label: 'dawn-view',
381
+ format: 'rgba8unorm',
382
+ dimension: '2d',
383
+ });
384
+ expect(viewResult.ok).toBe(true);
385
+ if (!viewResult.ok) return;
386
+
387
+ // The view handle must be consumable by a render pass attachment slot.
388
+ const encResult = device.createCommandEncoder({ label: 'dawn-view-encoder' });
389
+ expect(encResult.ok).toBe(true);
390
+ if (!encResult.ok) return;
391
+ const encoder = encResult.value;
392
+ const pass = encoder.beginRenderPass({
393
+ colorAttachments: [
394
+ {
395
+ view: viewResult.value as never,
396
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
397
+ loadOp: 'clear',
398
+ storeOp: 'store',
399
+ },
400
+ ],
401
+ } as never);
402
+ pass.end();
403
+ const finishResult = encoder.finish();
404
+ expect(finishResult.ok).toBe(true);
405
+ });
406
+ });
407
+
408
+ // w08 — createComputePipeline dawn real-GPU red phase. Asserts the real
409
+ // `device.createComputePipeline({layout:'auto', compute:{module, entryPoint}})`
410
+ // path returns ok with a usable pipeline handle on dawn-node. Currently red:
411
+ // RhiDevice.createComputePipeline does not exist on the interface (TS2339).
412
+ // Turns green after w09 ships interface + shim.
413
+ //
414
+ // Anchors: requirements §IN-1 / §AC-01; research §1.2 device timeline +
415
+ // §7 dawn-node real path; plan-strategy §4.3 + K-10.
416
+ // w11 — createQuerySet dawn real-GPU red phase. Asserts that
417
+ // (a) device.createQuerySet({type:'occlusion', count}) returns ok and the
418
+ // handle is consumable as RenderPassDescriptor.occlusionQuerySet.
419
+ // (b) device.createQuerySet({type:'timestamp', count}) returns
420
+ // 'feature-not-enabled' when the dawn adapter does not surface the
421
+ // 'timestamp-query' feature (charter proposition 4 + research §1.3).
422
+ // Currently red: createQuerySet not on RhiDevice (TS2339). Turns green
423
+ // after w12 ships interface + shim.
424
+ describe('dawn-real-gpu - createQuerySet occlusion happy path (w11)', () => {
425
+ it("device.createQuerySet({type:'occlusion'}) returns ok on dawn", async () => {
426
+ const device = await requestRhiDevice();
427
+ if (device === undefined) return;
428
+
429
+ const qsResult = device.createQuerySet({
430
+ label: 'dawn-qs-occ',
431
+ type: 'occlusion',
432
+ count: 4,
433
+ });
434
+ expect(qsResult.ok).toBe(true);
435
+ });
436
+ });
437
+
438
+ describe('dawn-real-gpu - createQuerySet timestamp gate (w11)', () => {
439
+ it("device.createQuerySet({type:'timestamp'}) handles timestamp-query feature presence/absence per spec", async () => {
440
+ const device = await requestRhiDevice();
441
+ if (device === undefined) return;
442
+
443
+ const qsResult = device.createQuerySet({
444
+ label: 'dawn-qs-ts',
445
+ type: 'timestamp',
446
+ count: 4,
447
+ });
448
+ if (!device.caps.timestampQuery) {
449
+ // gate: shim returns 'feature-not-enabled' ahead of forwarding.
450
+ expect(qsResult.ok).toBe(false);
451
+ if (!qsResult.ok) {
452
+ expect(qsResult.error.code).toBe('feature-not-enabled');
453
+ }
454
+ } else {
455
+ expect(qsResult.ok).toBe(true);
456
+ }
457
+ });
458
+ });
459
+
460
+ describe('dawn-real-gpu - createComputePipeline happy path (w08)', () => {
461
+ it('an indirect compute dispatch writes the storage buffer and survives GPU readback', async () => {
462
+ const { rhi: rhiInst, createShaderModule: createShaderMod } = await import('../index');
463
+ // M6 fix-up [w51]: spec-aligned strict two-step path; legacy
464
+ // `rhi.requestDevice` retired (AGENTS.md break-point list 2026-05-10 #2).
465
+ const ar = await rhiInst.requestAdapter();
466
+ expect(ar.ok).toBe(true);
467
+ if (!ar.ok) return;
468
+ const dr = await ar.value.requestDevice();
469
+ expect(dr.ok).toBe(true);
470
+ if (!dr.ok) return;
471
+ const device = dr.value;
472
+
473
+ const shaderResult = await createShaderMod(device, {
474
+ code: `
475
+ @group(0) @binding(0) var<storage, read_write> values: array<u32>;
476
+ @compute @workgroup_size(1)
477
+ fn cs_main(@builtin(global_invocation_id) id: vec3<u32>) {
478
+ values[id.x] = id.x + 10u;
479
+ }
480
+ `,
481
+ });
482
+ expect(shaderResult.ok).toBe(true);
483
+ if (!shaderResult.ok) return;
484
+
485
+ const layoutResult = device.createBindGroupLayout({
486
+ label: 'dawn-cs-layout',
487
+ entries: [
488
+ {
489
+ binding: 0,
490
+ visibility: GPUShaderStage.COMPUTE,
491
+ buffer: { type: 'storage' },
492
+ },
493
+ ],
494
+ });
495
+ expect(layoutResult.ok).toBe(true);
496
+ if (!layoutResult.ok) return;
497
+ const pipelineLayoutResult = device.createPipelineLayout({
498
+ label: 'dawn-cs-pipeline-layout',
499
+ bindGroupLayouts: [layoutResult.value],
500
+ });
501
+ expect(pipelineLayoutResult.ok).toBe(true);
502
+ if (!pipelineLayoutResult.ok) return;
503
+
504
+ const pipelineResult = device.createComputePipeline({
505
+ label: 'dawn-cs',
506
+ layout: pipelineLayoutResult.value,
507
+ compute: { module: shaderResult.value, entryPoint: 'cs_main' },
508
+ });
509
+ expect(pipelineResult.ok).toBe(true);
510
+ if (!pipelineResult.ok) return;
511
+
512
+ const valuesResult = device.createBuffer({
513
+ label: 'dawn-cs-values',
514
+ size: 16,
515
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
516
+ });
517
+ const indirectResult = device.createBuffer({
518
+ label: 'dawn-cs-indirect',
519
+ size: 12,
520
+ usage: GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST,
521
+ });
522
+ const readbackResult = device.createBuffer({
523
+ label: 'dawn-cs-readback',
524
+ size: 16,
525
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
526
+ });
527
+ expect(valuesResult.ok && indirectResult.ok && readbackResult.ok).toBe(true);
528
+ if (!valuesResult.ok || !indirectResult.ok || !readbackResult.ok) return;
529
+ expect(device.queue.writeBuffer(indirectResult.value, 0, new Uint32Array([4, 1, 1])).ok).toBe(
530
+ true,
531
+ );
532
+ const bindingsResult = device.createBindGroup({
533
+ label: 'dawn-cs-bindings',
534
+ layout: layoutResult.value,
535
+ entries: [
536
+ {
537
+ binding: 0,
538
+ resource: { kind: 'buffer', value: { buffer: valuesResult.value } },
539
+ },
540
+ ],
541
+ });
542
+ expect(bindingsResult.ok).toBe(true);
543
+ if (!bindingsResult.ok) return;
544
+
545
+ const encResult = device.createCommandEncoder({ label: 'dawn-cs-encoder' });
546
+ expect(encResult.ok).toBe(true);
547
+ if (!encResult.ok) return;
548
+ const encoder = encResult.value;
549
+ const pass = encoder.beginComputePass();
550
+ pass.setPipeline(pipelineResult.value);
551
+ pass.setBindGroup(0, bindingsResult.value);
552
+ pass.dispatchWorkgroupsIndirect(indirectResult.value, 0);
553
+ pass.end();
554
+ encoder.copyBufferToBuffer(valuesResult.value, 0, readbackResult.value, 0, 16);
555
+ const finishResult = encoder.finish();
556
+ expect(finishResult.ok).toBe(true);
557
+ if (!finishResult.ok) return;
558
+ expect(device.queue.submit([finishResult.value]).ok).toBe(true);
559
+ await device.queue.onSubmittedWorkDone();
560
+ const mapped = await readbackResult.value.mapAsync(GPUMapMode.READ);
561
+ expect(mapped.ok).toBe(true);
562
+ if (!mapped.ok) return;
563
+ const range = mapped.value.getMappedRange();
564
+ expect(range.ok).toBe(true);
565
+ if (!range.ok) return;
566
+ expect(Array.from(new Uint32Array(range.value.slice(0)))).toEqual([10, 11, 12, 13]);
567
+ mapped.value.unmap();
568
+ });
569
+ });
570
+
571
+ describe("dawn-real-gpu - createTextureView format outside source.format ∪ source.viewFormats returns 'webgpu-runtime-error' (w05)", () => {
572
+ it('format mismatch surfaces a real GPUValidationError; the shim wraps it as webgpu-runtime-error', async () => {
573
+ const device = await requestRhiDevice();
574
+ if (device === undefined) return;
575
+
576
+ const texResult = device.createTexture({
577
+ label: 'dawn-view-format-mismatch-source',
578
+ size: { width: 16, height: 16, depthOrArrayLayers: 1 },
579
+ format: 'rgba8unorm',
580
+ usage: GPUTextureUsage.TEXTURE_BINDING,
581
+ });
582
+ expect(texResult.ok).toBe(true);
583
+ if (!texResult.ok) return;
584
+
585
+ const viewResult = device.createTextureView(texResult.value, {
586
+ // bgra8unorm is neither the source format nor in source.viewFormats.
587
+ format: 'bgra8unorm',
588
+ dimension: '2d',
589
+ });
590
+ expect(viewResult.ok).toBe(false);
591
+ if (!viewResult.ok) {
592
+ expect(viewResult.error.code).toBe('webgpu-runtime-error');
593
+ expect(viewResult.error.expected.length).toBeGreaterThan(0);
594
+ expect(viewResult.error.hint.length).toBeGreaterThan(0);
595
+ }
596
+ });
597
+ });
598
+
599
+ // w20 - RhiCanvasContext dawn-real-GPU red phase. Asserts:
600
+ // (a) configure with format = 'rgba8unorm-srgb' (NOT in supported context
601
+ // formats) returns webgpu-runtime-error.
602
+ // (b) configure with format = 'bgra8unorm' on a real OffscreenCanvas
603
+ // succeeds; getCurrentTexture returns ok.
604
+ // (c) getCurrentTexture before configure returns webgpu-runtime-error
605
+ // (spec InvalidStateError mapping).
606
+ //
607
+ // Anchors: requirements §IN-4 / §AC-04 / §AC-07 / boundary case row 7;
608
+ // research §3.1 4 methods + §3.2 7 fields + §3.3 4 method algorithms;
609
+ // plan-strategy §2 K-4 + §6 M3 + K-10.
610
+ import { acquireCanvasContext } from '../index';
611
+
612
+ describe('w20 dawn-real-gpu - RhiCanvasContext.configure format gate (research §3.2 supported context formats)', () => {
613
+ it('format outside {bgra8unorm, rgba8unorm, rgba16float} fires webgpu-runtime-error', async () => {
614
+ const device = await requestRhiDevice();
615
+ if (device === undefined) return;
616
+
617
+ // dawn-node does not provide a canvas; we synthesize a stub that fulfils
618
+ // the GPUCanvasContext shape sufficiently for the format-gate path. The
619
+ // shim's format check happens before the underlying configure() is
620
+ // invoked, so the stub need not implement actual configure semantics.
621
+ const stub = {
622
+ configure(_d: GPUCanvasConfiguration) {},
623
+ unconfigure() {},
624
+ getConfiguration(): GPUCanvasConfiguration | null {
625
+ return null;
626
+ },
627
+ getCurrentTexture(): GPUTexture {
628
+ throw new Error('stub: getCurrentTexture should not be reached on format-gate path');
629
+ },
630
+ };
631
+ const mockCanvas = { getContext: () => stub };
632
+ const ctxResult = acquireCanvasContext(mockCanvas as unknown as HTMLCanvasElement);
633
+ if (!ctxResult.ok) return;
634
+ const out = ctxResult.value.configure({
635
+ device,
636
+ format: 'rgba8unorm-srgb',
637
+ usage: 0x10,
638
+ });
639
+ expect(out.ok).toBe(false);
640
+ if (!out.ok) {
641
+ expect(out.error.code).toBe('webgpu-runtime-error');
642
+ expect(out.error.expected).toBe('one of bgra8unorm/rgba8unorm/rgba16float');
643
+ }
644
+ });
645
+ });
646
+
647
+ // w22 - RPE beginOcclusionQuery / endOcclusionQuery placeholder retirement
648
+ // dawn-real-GPU red phase. Asserts:
649
+ // (a) beginOcclusionQuery while RPDesc.occlusionQuerySet is null ->
650
+ // webgpu-runtime-error with the contracted .hint literal.
651
+ // (b) nested begin (begin while another begin is active) ->
652
+ // webgpu-runtime-error with the contracted .expected literal.
653
+ // (c) end without active begin -> render-pass-not-ended (existing code).
654
+ // (d) full occlusion query round-trip on dawn (Pattern C) succeeds.
655
+ //
656
+ // F-3 ai-user-review absorption: literal grep on .expected / .hint string
657
+ // contents (charter proposition 4 explicit failure).
658
+ //
659
+ // Anchors: requirements §IN-3 / §AC-03 / §AC-12 / boundary case row 4-5;
660
+ // research §2.1 + §2.2 + §7.2 + §9; plan-strategy §2 K-2 + §6 M3 +
661
+ // K-10.
662
+ describe('w22 dawn-real-gpu - beginOcclusionQuery without occlusionQuerySet returns webgpu-runtime-error (F-3 hint literal)', () => {
663
+ it('begin when RPDesc.occlusionQuerySet null fires webgpu-runtime-error with contracted hint literal', async () => {
664
+ const device = await requestRhiDevice();
665
+ if (device === undefined) return;
666
+
667
+ const texResult = device.createTexture({
668
+ label: 'dawn-occ-no-qs-target',
669
+ size: { width: 32, height: 32, depthOrArrayLayers: 1 },
670
+ format: 'rgba8unorm',
671
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
672
+ });
673
+ if (!texResult.ok) return;
674
+ const viewResult = device.createTextureView(texResult.value, {});
675
+ if (!viewResult.ok) return;
676
+
677
+ const encResult = device.createCommandEncoder({ label: 'dawn-occ-no-qs' });
678
+ if (!encResult.ok) return;
679
+ const encoder = encResult.value;
680
+ const pass = encoder.beginRenderPass({
681
+ colorAttachments: [
682
+ {
683
+ view: viewResult.value as never,
684
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
685
+ loadOp: 'clear',
686
+ storeOp: 'store',
687
+ },
688
+ ],
689
+ } as never);
690
+
691
+ const out = pass.beginOcclusionQuery(0);
692
+ expect(out.ok).toBe(false);
693
+ if (!out.ok) {
694
+ expect(out.error.code).toBe('webgpu-runtime-error');
695
+ // F-3 literal hint assertion.
696
+ expect(out.error.hint).toBe(
697
+ 'pass occlusionQuerySet in RenderPassDescriptor before beginOcclusionQuery',
698
+ );
699
+ }
700
+ pass.end();
701
+ void encoder.finish();
702
+ });
703
+ });
704
+
705
+ describe('w22 dawn-real-gpu - nested beginOcclusionQuery returns webgpu-runtime-error (K-2 + F-3 expected literal)', () => {
706
+ it('begin while another begin is active fires webgpu-runtime-error with the contracted .expected literal', async () => {
707
+ const device = await requestRhiDevice();
708
+ if (device === undefined) return;
709
+
710
+ const qsResult = device.createQuerySet({ type: 'occlusion', count: 4 });
711
+ if (!qsResult.ok) return;
712
+ const querySet = qsResult.value;
713
+
714
+ const texResult = device.createTexture({
715
+ label: 'dawn-occ-nested-target',
716
+ size: { width: 32, height: 32, depthOrArrayLayers: 1 },
717
+ format: 'rgba8unorm',
718
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
719
+ });
720
+ if (!texResult.ok) return;
721
+ const viewResult = device.createTextureView(texResult.value, {});
722
+ if (!viewResult.ok) return;
723
+
724
+ const encResult = device.createCommandEncoder({ label: 'dawn-occ-nested' });
725
+ if (!encResult.ok) return;
726
+ const encoder = encResult.value;
727
+ const pass = encoder.beginRenderPass({
728
+ colorAttachments: [
729
+ {
730
+ view: viewResult.value as never,
731
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
732
+ loadOp: 'clear',
733
+ storeOp: 'store',
734
+ },
735
+ ],
736
+ occlusionQuerySet: querySet,
737
+ } as never);
738
+
739
+ const begin1 = pass.beginOcclusionQuery(0);
740
+ expect(begin1.ok).toBe(true);
741
+ const begin2 = pass.beginOcclusionQuery(1);
742
+ expect(begin2.ok).toBe(false);
743
+ if (!begin2.ok) {
744
+ expect(begin2.error.code).toBe('webgpu-runtime-error');
745
+ // F-3 literal expected assertion.
746
+ expect(begin2.error.expected).toBe(
747
+ '[[occlusion_query_active]] == false; pair beginOcclusionQuery / endOcclusionQuery',
748
+ );
749
+ }
750
+ pass.endOcclusionQuery();
751
+ pass.end();
752
+ void encoder.finish();
753
+ });
754
+ });
755
+
756
+ describe('w22 dawn-real-gpu - endOcclusionQuery without active begin returns render-pass-not-ended', () => {
757
+ it('end without active begin fires render-pass-not-ended', async () => {
758
+ const device = await requestRhiDevice();
759
+ if (device === undefined) return;
760
+
761
+ const qsResult = device.createQuerySet({ type: 'occlusion', count: 4 });
762
+ if (!qsResult.ok) return;
763
+ const querySet = qsResult.value;
764
+
765
+ const texResult = device.createTexture({
766
+ label: 'dawn-occ-end-no-begin-target',
767
+ size: { width: 32, height: 32, depthOrArrayLayers: 1 },
768
+ format: 'rgba8unorm',
769
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
770
+ });
771
+ if (!texResult.ok) return;
772
+ const viewResult = device.createTextureView(texResult.value, {});
773
+ if (!viewResult.ok) return;
774
+
775
+ const encResult = device.createCommandEncoder({ label: 'dawn-occ-end-no-begin' });
776
+ if (!encResult.ok) return;
777
+ const encoder = encResult.value;
778
+ const pass = encoder.beginRenderPass({
779
+ colorAttachments: [
780
+ {
781
+ view: viewResult.value as never,
782
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
783
+ loadOp: 'clear',
784
+ storeOp: 'store',
785
+ },
786
+ ],
787
+ occlusionQuerySet: querySet,
788
+ } as never);
789
+
790
+ const out = pass.endOcclusionQuery();
791
+ expect(out.ok).toBe(false);
792
+ if (!out.ok) {
793
+ expect(out.error.code).toBe('render-pass-not-ended');
794
+ }
795
+ pass.end();
796
+ void encoder.finish();
797
+ });
798
+ });
799
+
800
+ describe('w22 dawn-real-gpu - occlusion query full round-trip (Pattern C, research §7.2)', () => {
801
+ it('begin/draw/end occlusion query in a real pass succeeds end-to-end', async () => {
802
+ const device = await requestRhiDevice();
803
+ if (device === undefined) return;
804
+
805
+ const qsResult = device.createQuerySet({
806
+ label: 'dawn-occ-roundtrip',
807
+ type: 'occlusion',
808
+ count: 4,
809
+ });
810
+ if (!qsResult.ok) return;
811
+ const querySet = qsResult.value;
812
+
813
+ const texResult = device.createTexture({
814
+ label: 'dawn-occ-rt-target',
815
+ size: { width: 32, height: 32, depthOrArrayLayers: 1 },
816
+ format: 'rgba8unorm',
817
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
818
+ });
819
+ if (!texResult.ok) return;
820
+ const viewResult = device.createTextureView(texResult.value, {});
821
+ if (!viewResult.ok) return;
822
+
823
+ const encResult = device.createCommandEncoder({ label: 'dawn-occ-roundtrip' });
824
+ if (!encResult.ok) return;
825
+ const encoder = encResult.value;
826
+ const pass = encoder.beginRenderPass({
827
+ colorAttachments: [
828
+ {
829
+ view: viewResult.value as never,
830
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
831
+ loadOp: 'clear',
832
+ storeOp: 'store',
833
+ },
834
+ ],
835
+ occlusionQuerySet: querySet,
836
+ } as never);
837
+
838
+ const begin = pass.beginOcclusionQuery(0);
839
+ expect(begin.ok).toBe(true);
840
+ const end = pass.endOcclusionQuery();
841
+ expect(end.ok).toBe(true);
842
+ pass.end();
843
+ const finishResult = encoder.finish();
844
+ expect(finishResult.ok).toBe(true);
845
+ });
846
+ });
847
+
848
+ // w24 - resolveQuerySet placeholder retirement dawn-real-GPU red phase.
849
+ // Asserts:
850
+ // (a) destinationOffset % 256 != 0 -> webgpu-runtime-error with .expected
851
+ // literal 'destinationOffset % 256 == 0 (spec normative)'.
852
+ // (b) destination.usage missing QUERY_RESOLVE -> webgpu-runtime-error with
853
+ // .expected literal 'destination.usage must contain QUERY_RESOLVE'.
854
+ // (c) firstQuery >= count / firstQuery + queryCount > count.
855
+ // (d) resolveQuerySet happy path returns ok.
856
+ //
857
+ // F-3 ai-user-review absorption: literal grep on .expected string contents
858
+ // (charter proposition 4 explicit failure: K-2 merges all alignment / usage /
859
+ // bounds violations under webgpu-runtime-error; .expected must distinguish).
860
+ //
861
+ // Anchors: requirements §IN-3 / §AC-03 / §AC-12; research §2.3 + §7.2 + §9;
862
+ // plan-strategy §2 K-2 + §6 M3 + K-10.
863
+ describe('w24 dawn-real-gpu - resolveQuerySet destinationOffset alignment maps to webgpu-runtime-error (K-2 + F-3 expected literal)', () => {
864
+ it('destinationOffset = 8 (NOT a multiple of 256) returns webgpu-runtime-error with .expected literal', async () => {
865
+ const device = await requestRhiDevice();
866
+ if (device === undefined) return;
867
+
868
+ const qsResult = device.createQuerySet({ type: 'occlusion', count: 4 });
869
+ if (!qsResult.ok) return;
870
+
871
+ const dstResult = device.createBuffer({
872
+ label: 'dawn-resolve-dst',
873
+ size: 256,
874
+ usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
875
+ });
876
+ if (!dstResult.ok) return;
877
+
878
+ const encResult = device.createCommandEncoder({ label: 'dawn-resolve-align' });
879
+ if (!encResult.ok) return;
880
+ const encoder = encResult.value;
881
+
882
+ const out = encoder.resolveQuerySet(qsResult.value, 0, 4, dstResult.value, 8);
883
+ expect(out.ok).toBe(false);
884
+ if (!out.ok) {
885
+ expect(out.error.code).toBe('webgpu-runtime-error');
886
+ expect(out.error.expected).toBe('destinationOffset % 256 == 0 (spec normative)');
887
+ }
888
+ void encoder.finish();
889
+ });
890
+ });
891
+
892
+ describe('w24 dawn-real-gpu - resolveQuerySet destination.usage missing QUERY_RESOLVE maps to webgpu-runtime-error (F-3)', () => {
893
+ it('destination buffer without QUERY_RESOLVE usage flag returns webgpu-runtime-error with .expected literal', async () => {
894
+ const device = await requestRhiDevice();
895
+ if (device === undefined) return;
896
+
897
+ const qsResult = device.createQuerySet({ type: 'occlusion', count: 4 });
898
+ if (!qsResult.ok) return;
899
+
900
+ const dstResult = device.createBuffer({
901
+ label: 'dawn-resolve-dst-no-qr',
902
+ size: 256,
903
+ usage: GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
904
+ });
905
+ if (!dstResult.ok) return;
906
+
907
+ const encResult = device.createCommandEncoder({ label: 'dawn-resolve-no-qr' });
908
+ if (!encResult.ok) return;
909
+ const encoder = encResult.value;
910
+
911
+ const out = encoder.resolveQuerySet(qsResult.value, 0, 4, dstResult.value, 0);
912
+ expect(out.ok).toBe(false);
913
+ if (!out.ok) {
914
+ expect(out.error.code).toBe('webgpu-runtime-error');
915
+ expect(out.error.expected).toBe('destination.usage must contain QUERY_RESOLVE');
916
+ }
917
+ void encoder.finish();
918
+ });
919
+ });
920
+
921
+ describe('w24 dawn-real-gpu - resolveQuerySet firstQuery / queryCount range bounds', () => {
922
+ it('firstQuery + queryCount > querySet.count returns webgpu-runtime-error with .expected literal (F-3)', async () => {
923
+ const device = await requestRhiDevice();
924
+ if (device === undefined) return;
925
+
926
+ const qsResult = device.createQuerySet({ type: 'occlusion', count: 4 });
927
+ if (!qsResult.ok) return;
928
+
929
+ const dstResult = device.createBuffer({
930
+ label: 'dawn-resolve-dst-oob',
931
+ size: 256,
932
+ usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
933
+ });
934
+ if (!dstResult.ok) return;
935
+
936
+ const encResult = device.createCommandEncoder({ label: 'dawn-resolve-oob' });
937
+ if (!encResult.ok) return;
938
+ const encoder = encResult.value;
939
+
940
+ const out = encoder.resolveQuerySet(qsResult.value, 2, 3, dstResult.value, 0);
941
+ expect(out.ok).toBe(false);
942
+ if (!out.ok) {
943
+ expect(out.error.code).toBe('webgpu-runtime-error');
944
+ expect(out.error.expected).toBe('firstQuery + queryCount <= querySet.count');
945
+ }
946
+ void encoder.finish();
947
+ });
948
+ });
949
+
950
+ describe('w24 dawn-real-gpu - resolveQuerySet happy path returns ok (real round-trip succeeds)', () => {
951
+ it('resolveQuerySet on a 256-byte aligned dst with QUERY_RESOLVE usage succeeds', async () => {
952
+ const device = await requestRhiDevice();
953
+ if (device === undefined) return;
954
+
955
+ const qsResult = device.createQuerySet({ type: 'occlusion', count: 4 });
956
+ if (!qsResult.ok) return;
957
+
958
+ const dstResult = device.createBuffer({
959
+ label: 'dawn-resolve-dst-ok',
960
+ size: 256,
961
+ usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
962
+ });
963
+ if (!dstResult.ok) return;
964
+
965
+ const encResult = device.createCommandEncoder({ label: 'dawn-resolve-ok' });
966
+ if (!encResult.ok) return;
967
+ const encoder = encResult.value;
968
+
969
+ const out = encoder.resolveQuerySet(qsResult.value, 0, 4, dstResult.value, 0);
970
+ expect(out.ok).toBe(true);
971
+ const finishResult = encoder.finish();
972
+ expect(finishResult.ok).toBe(true);
973
+ });
974
+ });
975
+
976
+ // w31 (M5) — mappedAtCreation shim passthrough Pattern B (research §7.2 / AC-05 (b)).
977
+ //
978
+ // Verifies the BufferDescriptor.mappedAtCreation field actually reaches the
979
+ // raw GPUBuffer. The forgeax BufferDescriptor.mappedAtCreation field has been
980
+ // declared since the shader-mvp closure (packages/rhi/src/index.ts:149) but
981
+ // research §8.2 + OQ-7 / D-R3 flagged a suspected silent passthrough drop in
982
+ // the shim. This dawn-real-gpu test is the truth check: when the field reaches
983
+ // the raw GPU correctly, the buffer enters the mapped state and getMappedRange
984
+ // returns a non-empty ArrayBuffer that we can write to and unmap.
985
+ //
986
+ // Method: cast the forgeax Buffer handle to the raw GPUBuffer (the shim stores
987
+ // the raw object in BUFFER_RAW_MAP and the brand IS the raw object as a cast
988
+ // in the WebGPU path). We exercise the spec mappedAtCreation init idiom:
989
+ // createBuffer({size:16, usage:STORAGE, mappedAtCreation:true}) -> 16 bytes
990
+ // pre-mapped -> write four u32 values -> unmap -> readback round-trip via a
991
+ // COPY_DST + MAP_READ buffer to verify the data persisted post-unmap.
992
+ //
993
+ // Charter: proposition 4 explicit failure (mappedAtCreation must produce real
994
+ // GPU effect, not silently no-op); plan-strategy K-7 + risk mitigation P-1.
995
+ describe('w31 (M5) — mappedAtCreation shim passthrough (Pattern B init path)', () => {
996
+ it('createBuffer({mappedAtCreation:true}) yields a buffer in mapped state with 16-byte ArrayBuffer; data persists after unmap and round-trips via COPY', async () => {
997
+ const device = await requestRhiDevice();
998
+ if (device === undefined) return;
999
+
1000
+ const initBufResult = device.createBuffer({
1001
+ label: 'w31-mapped-at-creation-init',
1002
+ size: 16,
1003
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
1004
+ mappedAtCreation: true,
1005
+ });
1006
+ expect(initBufResult.ok).toBe(true);
1007
+ if (!initBufResult.ok) return;
1008
+ const initBuf = initBufResult.value;
1009
+
1010
+ // mappedAtCreation success means mapState === 'mapped'; the shim ships the
1011
+ // descriptor field through to the raw GPUBuffer (BUFFER_KEYS mirror) and
1012
+ // the forgeax Buffer wrapper exposes mapState as a getter (M5 / w35).
1013
+ expect(initBuf.mapState).toBe('mapped');
1014
+
1015
+ // mappedAtCreation:true puts the Buffer into mapState='mapped' synchronously;
1016
+ // cast to MappedBuffer brand to access getMappedRange / unmap method form
1017
+ // (D-P2 #6: mapAsync resolves a MappedBuffer; mappedAtCreation is the
1018
+ // synchronous variant where the Buffer is already mapped without an explicit
1019
+ // mapAsync round-trip).
1020
+ const initBufMapped = initBuf as unknown as import('@forgeax/engine-rhi').MappedBuffer;
1021
+ // getMappedRange must yield a non-zero-byte-length ArrayBuffer matching size.
1022
+ const range = initBufMapped.getMappedRange();
1023
+ expect(range.ok).toBe(true);
1024
+ if (!range.ok) return;
1025
+ expect(range.value.byteLength).toBe(16);
1026
+ new Uint32Array(range.value).set([1, 2, 3, 4]);
1027
+ initBufMapped.unmap();
1028
+ expect(initBuf.mapState).toBe('unmapped');
1029
+
1030
+ // Round-trip: copy STORAGE buffer to a MAP_READ buffer and verify the
1031
+ // initial values persist. This is the strongest evidence that
1032
+ // mappedAtCreation passthrough is real (not declaration-only).
1033
+ const readBufResult = device.createBuffer({
1034
+ label: 'w31-mapped-at-creation-readback',
1035
+ size: 16,
1036
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
1037
+ });
1038
+ expect(readBufResult.ok).toBe(true);
1039
+ if (!readBufResult.ok) return;
1040
+ const readBuf = readBufResult.value;
1041
+
1042
+ const encResult = device.createCommandEncoder({ label: 'w31-init-readback' });
1043
+ expect(encResult.ok).toBe(true);
1044
+ if (!encResult.ok) return;
1045
+ const encoder = encResult.value;
1046
+ encoder.copyBufferToBuffer(initBufResult.value, 0, readBufResult.value, 0, 16);
1047
+ const finishResult = encoder.finish();
1048
+ expect(finishResult.ok).toBe(true);
1049
+ if (!finishResult.ok) return;
1050
+ const submitResult = device.queue.submit([finishResult.value]);
1051
+ expect(submitResult.ok).toBe(true);
1052
+ await device.queue.onSubmittedWorkDone();
1053
+
1054
+ const m2 = await readBuf.mapAsync(GPUMapMode.READ);
1055
+ expect(m2.ok).toBe(true);
1056
+ if (!m2.ok) return;
1057
+ const range2 = m2.value.getMappedRange();
1058
+ expect(range2.ok).toBe(true);
1059
+ if (!range2.ok) return;
1060
+ expect(Array.from(new Uint32Array(range2.value.slice(0)))).toEqual([1, 2, 3, 4]);
1061
+ m2.value.unmap();
1062
+ });
1063
+ });
1064
+
1065
+ // ---------------------------------------------------------------------------
1066
+ // w34 (M5) - dawn-real-gpu Pattern A round-trip + F-8 three-row real-path.
1067
+ // ---------------------------------------------------------------------------
1068
+ //
1069
+ // research §7.2 Pattern A: mapAsync(WRITE) -> write -> unmap -> submit copy
1070
+ // -> onSubmittedWorkDone -> mapAsync(READ) -> readback. This is the AC-05 (a)
1071
+ // reference idiom + the spec ordering constraint #2 demonstration (mapAsync
1072
+ // before onSubmittedWorkDone).
1073
+ //
1074
+ // F-8 contract on dawn (research §4.2 step 1 / 9): the shim must reject
1075
+ // already-mapped re-mapAsync and mode-usage mismatch with
1076
+ // 'webgpu-runtime-error'. Detached ArrayBuffer access (F-8 row 2) is checked
1077
+ // via getMappedRange after unmap.
1078
+ //
1079
+ // These cases are RED until w35 ships the impl.
1080
+ describe('w34 (M5) - dawn-real-gpu Pattern A round-trip (mapAsync + onSubmittedWorkDone ordering)', () => {
1081
+ it('mapAsync(WRITE)->write->unmap->submit->onSubmittedWorkDone->mapAsync(READ)->readback returns the written u32 sequence', async () => {
1082
+ const device = await requestRhiDevice();
1083
+ if (device === undefined) return;
1084
+ // M6 / w42: w36/w37 ship `RhiQueue.onSubmittedWorkDone` so the ordering
1085
+ // wait now goes through the forgeax RHI surface directly (no raw device
1086
+ // hatch needed).
1087
+
1088
+ const writeBufResult = device.createBuffer({
1089
+ label: 'w34-write-buf',
1090
+ size: 16,
1091
+ usage: GPUBufferUsage.MAP_WRITE | GPUBufferUsage.COPY_SRC,
1092
+ });
1093
+ expect(writeBufResult.ok).toBe(true);
1094
+ if (!writeBufResult.ok) return;
1095
+ const writeBuf = writeBufResult.value;
1096
+
1097
+ const m1 = await writeBuf.mapAsync(GPUMapMode.WRITE);
1098
+ expect(m1.ok).toBe(true);
1099
+ if (!m1.ok) return;
1100
+ const range1 = m1.value.getMappedRange();
1101
+ expect(range1.ok).toBe(true);
1102
+ if (!range1.ok) return;
1103
+ new Uint32Array(range1.value).set([1, 2, 3, 4]);
1104
+ m1.value.unmap();
1105
+
1106
+ const readBufResult = device.createBuffer({
1107
+ label: 'w34-read-buf',
1108
+ size: 16,
1109
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
1110
+ });
1111
+ expect(readBufResult.ok).toBe(true);
1112
+ if (!readBufResult.ok) return;
1113
+ const readBuf = readBufResult.value;
1114
+
1115
+ const encResult = device.createCommandEncoder({ label: 'w34-enc' });
1116
+ expect(encResult.ok).toBe(true);
1117
+ if (!encResult.ok) return;
1118
+ const enc = encResult.value;
1119
+ enc.copyBufferToBuffer(writeBuf, 0, readBuf, 0, 16);
1120
+ const finishResult = enc.finish();
1121
+ expect(finishResult.ok).toBe(true);
1122
+ if (!finishResult.ok) return;
1123
+ const submitResult = device.queue.submit([finishResult.value]);
1124
+ expect(submitResult.ok).toBe(true);
1125
+ await device.queue.onSubmittedWorkDone();
1126
+
1127
+ const m2 = await readBuf.mapAsync(GPUMapMode.READ);
1128
+ expect(m2.ok).toBe(true);
1129
+ if (!m2.ok) return;
1130
+ const range2 = m2.value.getMappedRange();
1131
+ expect(range2.ok).toBe(true);
1132
+ if (!range2.ok) return;
1133
+ expect(Array.from(new Uint32Array(range2.value.slice(0)))).toEqual([1, 2, 3, 4]);
1134
+ m2.value.unmap();
1135
+ });
1136
+ });
1137
+
1138
+ describe('w34 (M5) - dawn-real-gpu F-8 row 1 real-path (already-mapped re-mapAsync)', () => {
1139
+ it('mapAsync on a mappedAtCreation:true buffer returns webgpu-runtime-error with mapState .expected literal', async () => {
1140
+ const device = await requestRhiDevice();
1141
+ if (device === undefined) return;
1142
+
1143
+ const bufResult = device.createBuffer({
1144
+ label: 'w34-already-mapped',
1145
+ size: 16,
1146
+ usage: GPUBufferUsage.MAP_WRITE,
1147
+ mappedAtCreation: true,
1148
+ });
1149
+ expect(bufResult.ok).toBe(true);
1150
+ if (!bufResult.ok) return;
1151
+ const out = await bufResult.value.mapAsync(GPUMapMode.WRITE);
1152
+ expect(out.ok).toBe(false);
1153
+ if (!out.ok) {
1154
+ expect(out.error.code).toBe('webgpu-runtime-error');
1155
+ expect(out.error.expected).toContain('mapState');
1156
+ expect(out.error.hint).toContain('unmap');
1157
+ }
1158
+ });
1159
+ });
1160
+
1161
+ describe('w34 (M5) - dawn-real-gpu F-8 row 3 real-path (mode-usage mismatch)', () => {
1162
+ it('mapAsync(READ) on a buffer without MAP_READ returns webgpu-runtime-error with the mode-usage .expected literal', async () => {
1163
+ const device = await requestRhiDevice();
1164
+ if (device === undefined) return;
1165
+
1166
+ const bufResult = device.createBuffer({
1167
+ label: 'w34-mode-usage-mismatch',
1168
+ size: 16,
1169
+ usage: GPUBufferUsage.COPY_DST,
1170
+ });
1171
+ expect(bufResult.ok).toBe(true);
1172
+ if (!bufResult.ok) return;
1173
+ const out = await bufResult.value.mapAsync(GPUMapMode.READ);
1174
+ expect(out.ok).toBe(false);
1175
+ if (!out.ok) {
1176
+ expect(out.error.code).toBe('webgpu-runtime-error');
1177
+ expect(out.error.expected).toContain('READ requires buffer.usage to contain MAP_READ');
1178
+ expect(out.error.hint).toContain('GPUBufferUsage.MAP_READ');
1179
+ }
1180
+ });
1181
+ });
1182
+
1183
+ describe('w34 (M5) - dawn-real-gpu F-8 row 2 real-path (detach guard via getMappedRange after unmap)', () => {
1184
+ it('getMappedRange after unmap returns webgpu-runtime-error with the mapped .expected literal', async () => {
1185
+ const device = await requestRhiDevice();
1186
+ if (device === undefined) return;
1187
+
1188
+ const bufResult = device.createBuffer({
1189
+ label: 'w34-detach',
1190
+ size: 16,
1191
+ usage: GPUBufferUsage.MAP_WRITE | GPUBufferUsage.COPY_SRC,
1192
+ });
1193
+ expect(bufResult.ok).toBe(true);
1194
+ if (!bufResult.ok) return;
1195
+ const buf = bufResult.value;
1196
+ const m1 = await buf.mapAsync(GPUMapMode.WRITE);
1197
+ expect(m1.ok).toBe(true);
1198
+ if (!m1.ok) return;
1199
+ const r1 = m1.value.getMappedRange();
1200
+ expect(r1.ok).toBe(true);
1201
+ m1.value.unmap();
1202
+ // After unmap the MappedBuffer brand is detached; calling getMappedRange
1203
+ // again returns Result.err with code 'webgpu-runtime-error' + expected
1204
+ // literal 'mapped' (F-8 row 2 detach guard).
1205
+ const r2 = m1.value.getMappedRange();
1206
+ expect(r2.ok).toBe(false);
1207
+ if (!r2.ok) {
1208
+ expect(r2.error.code).toBe('webgpu-runtime-error');
1209
+ expect(r2.error.expected).toContain('mapped');
1210
+ expect(r2.error.hint).toContain('mapAsync');
1211
+ }
1212
+ });
1213
+ });
1214
+
1215
+ // ---------------------------------------------------------------------------
1216
+ // w36 (M5) - dawn-real-gpu RhiQueue.writeTexture + onSubmittedWorkDone.
1217
+ // ---------------------------------------------------------------------------
1218
+ //
1219
+ // research §5.1 / §5.2 / §5.3 + Pattern A: onSubmittedWorkDone has no reject
1220
+ // path; it is the standard read-back idiom companion to mapAsync. dawn-node
1221
+ // is the truth check (charter candidate proposition 6: mock vs real-GPU).
1222
+ describe('w36 (M5) - dawn-real-gpu RhiQueue.onSubmittedWorkDone returns Promise<void>', () => {
1223
+ it('queue.onSubmittedWorkDone resolves after queue.submit completes (FIFO ordering constraint #1)', async () => {
1224
+ const device = await requestRhiDevice();
1225
+ if (device === undefined) return;
1226
+
1227
+ const encResult = device.createCommandEncoder({ label: 'w36-enc' });
1228
+ expect(encResult.ok).toBe(true);
1229
+ if (!encResult.ok) return;
1230
+ const finishResult = encResult.value.finish();
1231
+ expect(finishResult.ok).toBe(true);
1232
+ if (!finishResult.ok) return;
1233
+ const submitResult = device.queue.submit([finishResult.value]);
1234
+ expect(submitResult.ok).toBe(true);
1235
+
1236
+ const v = await device.queue.onSubmittedWorkDone();
1237
+ expect(v).toBeUndefined();
1238
+ });
1239
+ });
1240
+
1241
+ // ---------------------------------------------------------------------------
1242
+ // w38 (M5 / K-3) - dawn-real-gpu compute-pass timestampWrites.
1243
+ // ---------------------------------------------------------------------------
1244
+ //
1245
+ // research §2.4 + dawn ComputePassDescriptor timestampWrites reference:
1246
+ // the real compute pass owns the beginning/end timestamp writes. The legacy
1247
+ // command-encoder writeTimestamp path is not used because current Dawn rejects
1248
+ // it even when timestamp-query is advertised.
1249
+ describe('w38 (M5 / K-3) - dawn-real-gpu compute-pass timestampWrites gate', () => {
1250
+ it('reports timestamp-query refusal without treating a capability-disabled path as success', async () => {
1251
+ const device = await requestRhiDevice();
1252
+ if (device === undefined) return;
1253
+
1254
+ if (device.caps.timestampQuery) {
1255
+ return;
1256
+ }
1257
+ const qsResult = device.createQuerySet({ type: 'timestamp', count: 1 });
1258
+ expect(qsResult.ok).toBe(false);
1259
+ if (!qsResult.ok) {
1260
+ expect(qsResult.error.code).toBe('feature-not-enabled');
1261
+ expect(qsResult.error.expected).toContain('timestampQuery');
1262
+ expect(qsResult.error.hint).toContain('timestamp-query');
1263
+ reportTimestampEvidence({
1264
+ carrier: 'dawn-node',
1265
+ selector: 'standard',
1266
+ source: 'packages/rhi-webgpu/src/__tests__/dawn-real-gpu.dawn.test.ts',
1267
+ acceptedGpu: 0,
1268
+ refusalCode: 'timestamp-query-unsupported',
1269
+ expected: qsResult.error.expected,
1270
+ hint: qsResult.error.hint,
1271
+ });
1272
+ }
1273
+ });
1274
+ });
1275
+
1276
+ describe('w3 dawn-real-gpu timestamp admission path', () => {
1277
+ it('runs compute-pass timestampWrites -> resolve -> submit -> readback or records a structured refusal', async () => {
1278
+ const selector = 'standard' as const;
1279
+ const source = 'packages/rhi-webgpu/src/__tests__/dawn-real-gpu.dawn.test.ts';
1280
+ const adapterResult = await rhi.requestAdapter();
1281
+ expect(adapterResult.ok).toBe(true);
1282
+ if (!adapterResult.ok) return;
1283
+
1284
+ if (!adapterResult.value.features.has('timestamp-query')) {
1285
+ reportTimestampEvidence({
1286
+ carrier: 'dawn-node',
1287
+ selector,
1288
+ source,
1289
+ acceptedGpu: 0,
1290
+ refusalCode: 'timestamp-query-unsupported',
1291
+ expected: "adapter.features.has('timestamp-query')",
1292
+ hint: 'request a real Dawn device with the timestamp-query feature enabled',
1293
+ });
1294
+ return;
1295
+ }
1296
+
1297
+ const deviceResult = await adapterResult.value.requestDevice({
1298
+ requiredFeatures: ['timestamp-query'],
1299
+ });
1300
+ expect(deviceResult.ok).toBe(true);
1301
+ if (!deviceResult.ok) return;
1302
+ const device = deviceResult.value;
1303
+
1304
+ const querySetResult = device.createQuerySet({ type: 'timestamp', count: 2 });
1305
+ expect(querySetResult.ok).toBe(true);
1306
+ if (!querySetResult.ok) return;
1307
+
1308
+ const resolveResult = device.createBuffer({
1309
+ label: 'w3-timestamp-resolve',
1310
+ size: 256,
1311
+ usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
1312
+ });
1313
+ expect(resolveResult.ok).toBe(true);
1314
+ if (!resolveResult.ok) return;
1315
+
1316
+ const readbackResult = device.createBuffer({
1317
+ label: 'w3-timestamp-readback',
1318
+ size: 256,
1319
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
1320
+ });
1321
+ expect(readbackResult.ok).toBe(true);
1322
+ if (!readbackResult.ok) return;
1323
+
1324
+ const encoderResult = device.createCommandEncoder({ label: 'w3-timestamp-encoder' });
1325
+ expect(encoderResult.ok).toBe(true);
1326
+ if (!encoderResult.ok) return;
1327
+ const encoder = encoderResult.value;
1328
+
1329
+ try {
1330
+ const pass = encoder.beginComputePass({
1331
+ label: 'hdrp-cluster-membership',
1332
+ timestampWrites: {
1333
+ querySet: querySetResult.value,
1334
+ beginningOfPassWriteIndex: 0,
1335
+ endOfPassWriteIndex: 1,
1336
+ },
1337
+ });
1338
+ pass.end();
1339
+ } catch (error) {
1340
+ const refusal = error as { code?: string; expected?: string; hint?: string };
1341
+ reportTimestampEvidence({
1342
+ carrier: 'dawn-node',
1343
+ selector,
1344
+ source,
1345
+ acceptedGpu: 0,
1346
+ refusalCode: 'timestamp-write-unavailable',
1347
+ expected:
1348
+ refusal.expected ??
1349
+ 'GPUComputePassDescriptor.timestampWrites to be accepted by the real compute pass',
1350
+ hint: refusal.hint ?? String(error),
1351
+ });
1352
+ expect(refusal.code).toBe('webgpu-runtime-error');
1353
+ return;
1354
+ }
1355
+
1356
+ const resolveQueriesResult = encoder.resolveQuerySet(
1357
+ querySetResult.value,
1358
+ 0,
1359
+ 2,
1360
+ resolveResult.value,
1361
+ 0,
1362
+ );
1363
+ expect(resolveQueriesResult.ok).toBe(true);
1364
+ if (!resolveQueriesResult.ok) return;
1365
+ encoder.copyBufferToBuffer(resolveResult.value, 0, readbackResult.value, 0, 16);
1366
+
1367
+ const finishResult = encoder.finish();
1368
+ expect(finishResult.ok).toBe(true);
1369
+ if (!finishResult.ok) return;
1370
+ const submitResult = device.queue.submit([finishResult.value]);
1371
+ expect(submitResult.ok).toBe(true);
1372
+ if (!submitResult.ok) return;
1373
+ await device.queue.onSubmittedWorkDone();
1374
+
1375
+ const mappedResult = await readbackResult.value.mapAsync(GPUMapMode.READ);
1376
+ expect(mappedResult.ok).toBe(true);
1377
+ if (!mappedResult.ok) return;
1378
+ const rangeResult = mappedResult.value.getMappedRange(0, 16);
1379
+ expect(rangeResult.ok).toBe(true);
1380
+ if (!rangeResult.ok) return;
1381
+ const ticks = new BigUint64Array(rangeResult.value);
1382
+ const begin = ticks[0];
1383
+ const end = ticks[1];
1384
+ if (begin === undefined || end === undefined || end <= begin) {
1385
+ reportTimestampEvidence({
1386
+ carrier: 'dawn-node',
1387
+ selector,
1388
+ source,
1389
+ acceptedGpu: 0,
1390
+ refusalCode: 'timestamp-write-unavailable',
1391
+ expected: 'end > begin',
1392
+ hint: 'timestamp readback did not produce a positive GPU interval',
1393
+ observed: { begin: begin?.toString() ?? null, end: end?.toString() ?? null },
1394
+ });
1395
+ mappedResult.value.unmap();
1396
+ return;
1397
+ }
1398
+
1399
+ reportTimestampEvidence({
1400
+ carrier: 'dawn-node',
1401
+ selector,
1402
+ source,
1403
+ acceptedGpu: 1,
1404
+ ticks: { begin: begin.toString(), end: end.toString() },
1405
+ });
1406
+ expect(end).toBeGreaterThan(begin);
1407
+ mappedResult.value.unmap();
1408
+ });
1409
+ });
1410
+
1411
+ describe('w36 (M5) - dawn-real-gpu RhiQueue.writeTexture real-path', () => {
1412
+ it('queue.writeTexture writes pixels into a texture and returns ok', async () => {
1413
+ const device = await requestRhiDevice();
1414
+ if (device === undefined) return;
1415
+
1416
+ const texResult = device.createTexture({
1417
+ label: 'w36-write-tex',
1418
+ size: { width: 4, height: 4, depthOrArrayLayers: 1 },
1419
+ format: 'rgba8unorm',
1420
+ usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC,
1421
+ });
1422
+ expect(texResult.ok).toBe(true);
1423
+ if (!texResult.ok) return;
1424
+
1425
+ // bytesPerRow must be 256-aligned (forgeax K-2); rowsPerImage * bytesPerRow
1426
+ // sets the required linear data size. A 4x4 BGRA8 texture needs 256*4=1024
1427
+ // bytes minimum (with the 256-byte alignment overhead per row).
1428
+ const data = new Uint8Array(256 * 4);
1429
+ data.fill(0xff);
1430
+ const out = device.queue.writeTexture(
1431
+ { texture: texResult.value as never, mipLevel: 0, origin: [0, 0, 0] },
1432
+ data,
1433
+ { offset: 0, bytesPerRow: 256, rowsPerImage: 4 },
1434
+ { width: 4, height: 4, depthOrArrayLayers: 1 },
1435
+ );
1436
+ expect(out.ok).toBe(true);
1437
+ await device.queue.onSubmittedWorkDone();
1438
+ });
1439
+
1440
+ it('queue.writeTexture accepts non-256-aligned bytesPerRow (bug repro: 500x500 RGBA8, bytesPerRow=2000)', async () => {
1441
+ const device = await requestRhiDevice();
1442
+ if (device === undefined) return;
1443
+
1444
+ // 500x500 RGBA8 => bytesPerRow=2000, NOT a multiple of 256.
1445
+ // webgpu spec section 19.2 Note: unlike copyBufferToTexture(), there is
1446
+ // no alignment requirement on writeTexture dataLayout.bytesPerRow.
1447
+ const texResult = device.createTexture({
1448
+ label: 'w36-write-tex-non-256-align',
1449
+ size: { width: 500, height: 500, depthOrArrayLayers: 1 },
1450
+ format: 'rgba8unorm',
1451
+ usage: GPUTextureUsage.COPY_DST,
1452
+ });
1453
+ expect(texResult.ok).toBe(true);
1454
+ if (!texResult.ok) return;
1455
+
1456
+ const data = new Uint8Array(500 * 500 * 4);
1457
+ const out = device.queue.writeTexture(
1458
+ { texture: texResult.value as never, mipLevel: 0, origin: [0, 0, 0] },
1459
+ data,
1460
+ { offset: 0, bytesPerRow: 2000, rowsPerImage: 500 },
1461
+ { width: 500, height: 500, depthOrArrayLayers: 1 },
1462
+ );
1463
+ expect(out.ok).toBe(true);
1464
+
1465
+ // Verify the submission completes without a validation error.
1466
+ await device.queue.onSubmittedWorkDone();
1467
+
1468
+ // Also verify bytesPerRow=100 (non-256-aligned) on a 1x1 texture.
1469
+ const texSmall = device.createTexture({
1470
+ label: 'w36-write-tex-small-non-256-align',
1471
+ size: { width: 1, height: 1, depthOrArrayLayers: 1 },
1472
+ format: 'rgba8unorm',
1473
+ usage: GPUTextureUsage.COPY_DST,
1474
+ });
1475
+ expect(texSmall.ok).toBe(true);
1476
+ if (!texSmall.ok) return;
1477
+
1478
+ const dataSmall = new Uint8Array(100);
1479
+ const outSmall = device.queue.writeTexture(
1480
+ { texture: texSmall.value as never, mipLevel: 0, origin: [0, 0, 0] },
1481
+ dataSmall,
1482
+ { offset: 0, bytesPerRow: 100, rowsPerImage: 1 },
1483
+ { width: 1, height: 1, depthOrArrayLayers: 1 },
1484
+ );
1485
+ expect(outSmall.ok).toBe(true);
1486
+ await device.queue.onSubmittedWorkDone();
1487
+ });
1488
+ });