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