@forgeax/engine-rhi-debug 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.
Files changed (237) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +178 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/bootstrap-integration.test.d.ts +2 -0
  5. package/dist/__tests__/bootstrap-integration.test.d.ts.map +1 -0
  6. package/dist/__tests__/consumer-inventory.unit.test.d.ts +2 -0
  7. package/dist/__tests__/consumer-inventory.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/copy-src-usage-validity.dawn.test.d.ts +15 -0
  9. package/dist/__tests__/copy-src-usage-validity.dawn.test.d.ts.map +1 -0
  10. package/dist/__tests__/coverage-invariant.test-d.d.ts +2 -0
  11. package/dist/__tests__/coverage-invariant.test-d.d.ts.map +1 -0
  12. package/dist/__tests__/coverage-invariant.unit.test.d.ts +2 -0
  13. package/dist/__tests__/coverage-invariant.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/e2e.browser.test.d.ts +2 -0
  15. package/dist/__tests__/e2e.browser.test.d.ts.map +1 -0
  16. package/dist/__tests__/error-code-narrowing.test-d.d.ts +2 -0
  17. package/dist/__tests__/error-code-narrowing.test-d.d.ts.map +1 -0
  18. package/dist/__tests__/errors-v7.test-d.d.ts +2 -0
  19. package/dist/__tests__/errors-v7.test-d.d.ts.map +1 -0
  20. package/dist/__tests__/errors-v7.unit.test.d.ts +2 -0
  21. package/dist/__tests__/errors-v7.unit.test.d.ts.map +1 -0
  22. package/dist/__tests__/errors.unit.test.d.ts +2 -0
  23. package/dist/__tests__/errors.unit.test.d.ts.map +1 -0
  24. package/dist/__tests__/event-semantics-coverage.unit.test.d.ts +2 -0
  25. package/dist/__tests__/event-semantics-coverage.unit.test.d.ts.map +1 -0
  26. package/dist/__tests__/format-info.unit.test.d.ts +2 -0
  27. package/dist/__tests__/format-info.unit.test.d.ts.map +1 -0
  28. package/dist/__tests__/frame-model-parity.test-d.d.ts +2 -0
  29. package/dist/__tests__/frame-model-parity.test-d.d.ts.map +1 -0
  30. package/dist/__tests__/frame-model-parity.unit.test.d.ts +2 -0
  31. package/dist/__tests__/frame-model-parity.unit.test.d.ts.map +1 -0
  32. package/dist/__tests__/guard-gates.test.d.ts +2 -0
  33. package/dist/__tests__/guard-gates.test.d.ts.map +1 -0
  34. package/dist/__tests__/public-surface.integration.test.d.ts +2 -0
  35. package/dist/__tests__/public-surface.integration.test.d.ts.map +1 -0
  36. package/dist/__tests__/readback-fault-cleanup.unit.test.d.ts +2 -0
  37. package/dist/__tests__/readback-fault-cleanup.unit.test.d.ts.map +1 -0
  38. package/dist/__tests__/readback-format-matrix.dawn.test.d.ts +2 -0
  39. package/dist/__tests__/readback-format-matrix.dawn.test.d.ts.map +1 -0
  40. package/dist/__tests__/readback-format-matrix.unit.test.d.ts +2 -0
  41. package/dist/__tests__/readback-format-matrix.unit.test.d.ts.map +1 -0
  42. package/dist/__tests__/readback-matrix-fixture.d.ts +32 -0
  43. package/dist/__tests__/readback-matrix-fixture.d.ts.map +1 -0
  44. package/dist/__tests__/recorder-bootstrap.unit.test.d.ts +2 -0
  45. package/dist/__tests__/recorder-bootstrap.unit.test.d.ts.map +1 -0
  46. package/dist/__tests__/recorder-budget.unit.test.d.ts +2 -0
  47. package/dist/__tests__/recorder-budget.unit.test.d.ts.map +1 -0
  48. package/dist/__tests__/recorder-fail-fast.unit.test.d.ts +2 -0
  49. package/dist/__tests__/recorder-fail-fast.unit.test.d.ts.map +1 -0
  50. package/dist/__tests__/recorder-lifecycle.unit.test.d.ts +2 -0
  51. package/dist/__tests__/recorder-lifecycle.unit.test.d.ts.map +1 -0
  52. package/dist/__tests__/recorder-push-event.unit.test.d.ts +2 -0
  53. package/dist/__tests__/recorder-push-event.unit.test.d.ts.map +1 -0
  54. package/dist/__tests__/recorder-session.integration.test.d.ts +2 -0
  55. package/dist/__tests__/recorder-session.integration.test.d.ts.map +1 -0
  56. package/dist/__tests__/recorder-session.unit.test.d.ts +2 -0
  57. package/dist/__tests__/recorder-session.unit.test.d.ts.map +1 -0
  58. package/dist/__tests__/recorder-steady-frame.dawn.test.d.ts +2 -0
  59. package/dist/__tests__/recorder-steady-frame.dawn.test.d.ts.map +1 -0
  60. package/dist/__tests__/replay-fail-fast.unit.test.d.ts +2 -0
  61. package/dist/__tests__/replay-fail-fast.unit.test.d.ts.map +1 -0
  62. package/dist/__tests__/replay-session.dawn.test.d.ts +2 -0
  63. package/dist/__tests__/replay-session.dawn.test.d.ts.map +1 -0
  64. package/dist/__tests__/replay-session.test-d.d.ts +2 -0
  65. package/dist/__tests__/replay-session.test-d.d.ts.map +1 -0
  66. package/dist/__tests__/replay-session.unit.test.d.ts +2 -0
  67. package/dist/__tests__/replay-session.unit.test.d.ts.map +1 -0
  68. package/dist/__tests__/resolve-texture-descriptor.unit.test.d.ts +2 -0
  69. package/dist/__tests__/resolve-texture-descriptor.unit.test.d.ts.map +1 -0
  70. package/dist/__tests__/resource-lifecycle.unit.test.d.ts +2 -0
  71. package/dist/__tests__/resource-lifecycle.unit.test.d.ts.map +1 -0
  72. package/dist/__tests__/rhi-debug-fresh-replay.dawn.test.d.ts +2 -0
  73. package/dist/__tests__/rhi-debug-fresh-replay.dawn.test.d.ts.map +1 -0
  74. package/dist/__tests__/tape-index.unit.test.d.ts +2 -0
  75. package/dist/__tests__/tape-index.unit.test.d.ts.map +1 -0
  76. package/dist/__tests__/tape-v7-invalid.unit.test.d.ts +2 -0
  77. package/dist/__tests__/tape-v7-invalid.unit.test.d.ts.map +1 -0
  78. package/dist/__tests__/tape-v7-roundtrip.unit.test.d.ts +2 -0
  79. package/dist/__tests__/tape-v7-roundtrip.unit.test.d.ts.map +1 -0
  80. package/dist/__tests__/tape-v7-version.unit.test.d.ts +2 -0
  81. package/dist/__tests__/tape-v7-version.unit.test.d.ts.map +1 -0
  82. package/dist/__tests__/tree-shake.unit.test.d.ts +2 -0
  83. package/dist/__tests__/tree-shake.unit.test.d.ts.map +1 -0
  84. package/dist/__tests__/types.test.d.ts +2 -0
  85. package/dist/__tests__/types.test.d.ts.map +1 -0
  86. package/dist/browser.d.ts +45 -0
  87. package/dist/browser.d.ts.map +1 -0
  88. package/dist/browser.mjs +83 -0
  89. package/dist/browser.mjs.map +1 -0
  90. package/dist/errors.d.ts +85 -0
  91. package/dist/errors.d.ts.map +1 -0
  92. package/dist/frame-model.d.ts +158 -0
  93. package/dist/frame-model.d.ts.map +1 -0
  94. package/dist/index.d.ts +17 -0
  95. package/dist/index.d.ts.map +1 -0
  96. package/dist/index.mjs +10415 -0
  97. package/dist/index.mjs.map +1 -0
  98. package/dist/protocol/codec.d.ts +6 -0
  99. package/dist/protocol/codec.d.ts.map +1 -0
  100. package/dist/protocol/event-semantics.d.ts +15 -0
  101. package/dist/protocol/event-semantics.d.ts.map +1 -0
  102. package/dist/protocol/tape-index.d.ts +30 -0
  103. package/dist/protocol/tape-index.d.ts.map +1 -0
  104. package/dist/protocol/types.d.ts +44 -0
  105. package/dist/protocol/types.d.ts.map +1 -0
  106. package/dist/protocol/validation.d.ts +7 -0
  107. package/dist/protocol/validation.d.ts.map +1 -0
  108. package/dist/readback.d.ts +156 -0
  109. package/dist/readback.d.ts.map +1 -0
  110. package/dist/recorder/assemble.d.ts +11 -0
  111. package/dist/recorder/assemble.d.ts.map +1 -0
  112. package/dist/recorder/closure.d.ts +39 -0
  113. package/dist/recorder/closure.d.ts.map +1 -0
  114. package/dist/recorder/core.d.ts +296 -0
  115. package/dist/recorder/core.d.ts.map +1 -0
  116. package/dist/recorder/device.d.ts +4 -0
  117. package/dist/recorder/device.d.ts.map +1 -0
  118. package/dist/recorder/encoder.d.ts +5 -0
  119. package/dist/recorder/encoder.d.ts.map +1 -0
  120. package/dist/recorder/lifecycle.d.ts +20 -0
  121. package/dist/recorder/lifecycle.d.ts.map +1 -0
  122. package/dist/recorder/pass.d.ts +6 -0
  123. package/dist/recorder/pass.d.ts.map +1 -0
  124. package/dist/recorder/proxy.d.ts +19 -0
  125. package/dist/recorder/proxy.d.ts.map +1 -0
  126. package/dist/recorder/queue.d.ts +4 -0
  127. package/dist/recorder/queue.d.ts.map +1 -0
  128. package/dist/recorder/resource-registry.d.ts +15 -0
  129. package/dist/recorder/resource-registry.d.ts.map +1 -0
  130. package/dist/recorder/session.d.ts +25 -0
  131. package/dist/recorder/session.d.ts.map +1 -0
  132. package/dist/recorder/shader.d.ts +3 -0
  133. package/dist/recorder/shader.d.ts.map +1 -0
  134. package/dist/recorder/snapshot.d.ts +10 -0
  135. package/dist/recorder/snapshot.d.ts.map +1 -0
  136. package/dist/recorder/wrap.d.ts +4 -0
  137. package/dist/recorder/wrap.d.ts.map +1 -0
  138. package/dist/recorder.d.ts +7 -0
  139. package/dist/recorder.d.ts.map +1 -0
  140. package/dist/replay/device-request.d.ts +8 -0
  141. package/dist/replay/device-request.d.ts.map +1 -0
  142. package/dist/replay/execute-support.d.ts +33 -0
  143. package/dist/replay/execute-support.d.ts.map +1 -0
  144. package/dist/replay/execute.d.ts +18 -0
  145. package/dist/replay/execute.d.ts.map +1 -0
  146. package/dist/replay/readback.d.ts +32 -0
  147. package/dist/replay/readback.d.ts.map +1 -0
  148. package/dist/replay/resources.d.ts +57 -0
  149. package/dist/replay/resources.d.ts.map +1 -0
  150. package/dist/replay/session.d.ts +49 -0
  151. package/dist/replay/session.d.ts.map +1 -0
  152. package/dist/replay/texture-format.d.ts +21 -0
  153. package/dist/replay/texture-format.d.ts.map +1 -0
  154. package/dist/texel-decode.d.ts +35 -0
  155. package/dist/texel-decode.d.ts.map +1 -0
  156. package/dist/texel-layout.d.ts +76 -0
  157. package/dist/texel-layout.d.ts.map +1 -0
  158. package/dist/types.d.ts +722 -0
  159. package/dist/types.d.ts.map +1 -0
  160. package/package.json +72 -0
  161. package/src/__tests__/bootstrap-integration.test.ts +268 -0
  162. package/src/__tests__/consumer-inventory.unit.test.ts +154 -0
  163. package/src/__tests__/copy-src-usage-validity.dawn.test.ts +117 -0
  164. package/src/__tests__/coverage-invariant.test-d.ts +115 -0
  165. package/src/__tests__/coverage-invariant.unit.test.ts +45 -0
  166. package/src/__tests__/e2e.browser.test.ts +239 -0
  167. package/src/__tests__/error-code-narrowing.test-d.ts +32 -0
  168. package/src/__tests__/errors-v7.test-d.ts +19 -0
  169. package/src/__tests__/errors-v7.unit.test.ts +15 -0
  170. package/src/__tests__/errors.unit.test.ts +83 -0
  171. package/src/__tests__/event-semantics-coverage.unit.test.ts +22 -0
  172. package/src/__tests__/format-info.unit.test.ts +85 -0
  173. package/src/__tests__/frame-model-parity.test-d.ts +20 -0
  174. package/src/__tests__/frame-model-parity.unit.test.ts +285 -0
  175. package/src/__tests__/guard-gates.test.ts +317 -0
  176. package/src/__tests__/public-surface.integration.test.ts +38 -0
  177. package/src/__tests__/readback-fault-cleanup.unit.test.ts +66 -0
  178. package/src/__tests__/readback-format-matrix.dawn.test.ts +378 -0
  179. package/src/__tests__/readback-format-matrix.unit.test.ts +120 -0
  180. package/src/__tests__/readback-matrix-fixture.ts +20 -0
  181. package/src/__tests__/recorder-bootstrap.unit.test.ts +302 -0
  182. package/src/__tests__/recorder-budget.unit.test.ts +42 -0
  183. package/src/__tests__/recorder-fail-fast.unit.test.ts +434 -0
  184. package/src/__tests__/recorder-lifecycle.unit.test.ts +42 -0
  185. package/src/__tests__/recorder-push-event.unit.test.ts +573 -0
  186. package/src/__tests__/recorder-session.integration.test.ts +32 -0
  187. package/src/__tests__/recorder-session.unit.test.ts +63 -0
  188. package/src/__tests__/recorder-steady-frame.dawn.test.ts +52 -0
  189. package/src/__tests__/replay-fail-fast.unit.test.ts +155 -0
  190. package/src/__tests__/replay-session.dawn.test.ts +220 -0
  191. package/src/__tests__/replay-session.test-d.ts +27 -0
  192. package/src/__tests__/replay-session.unit.test.ts +171 -0
  193. package/src/__tests__/resolve-texture-descriptor.unit.test.ts +144 -0
  194. package/src/__tests__/resource-lifecycle.unit.test.ts +114 -0
  195. package/src/__tests__/rhi-debug-fresh-replay.dawn.test.ts +178 -0
  196. package/src/__tests__/tape-index.unit.test.ts +56 -0
  197. package/src/__tests__/tape-v7-invalid.unit.test.ts +45 -0
  198. package/src/__tests__/tape-v7-roundtrip.unit.test.ts +66 -0
  199. package/src/__tests__/tape-v7-version.unit.test.ts +13 -0
  200. package/src/__tests__/tree-shake.unit.test.ts +151 -0
  201. package/src/__tests__/types.test.ts +257 -0
  202. package/src/browser.ts +146 -0
  203. package/src/errors.ts +173 -0
  204. package/src/frame-model.ts +753 -0
  205. package/src/index.ts +92 -0
  206. package/src/pako.d.ts +8 -0
  207. package/src/protocol/codec.ts +220 -0
  208. package/src/protocol/event-semantics.ts +180 -0
  209. package/src/protocol/tape-index.ts +120 -0
  210. package/src/protocol/types.ts +60 -0
  211. package/src/protocol/validation.ts +173 -0
  212. package/src/readback.ts +804 -0
  213. package/src/recorder/assemble.ts +192 -0
  214. package/src/recorder/closure.ts +408 -0
  215. package/src/recorder/core.ts +587 -0
  216. package/src/recorder/device.ts +474 -0
  217. package/src/recorder/encoder.ts +251 -0
  218. package/src/recorder/lifecycle.ts +847 -0
  219. package/src/recorder/pass.ts +241 -0
  220. package/src/recorder/proxy.ts +59 -0
  221. package/src/recorder/queue.ts +109 -0
  222. package/src/recorder/resource-registry.ts +53 -0
  223. package/src/recorder/session.ts +152 -0
  224. package/src/recorder/shader.ts +47 -0
  225. package/src/recorder/snapshot.ts +38 -0
  226. package/src/recorder/wrap.ts +157 -0
  227. package/src/recorder.ts +15 -0
  228. package/src/replay/device-request.ts +38 -0
  229. package/src/replay/execute-support.ts +243 -0
  230. package/src/replay/execute.ts +870 -0
  231. package/src/replay/readback.ts +671 -0
  232. package/src/replay/resources.ts +154 -0
  233. package/src/replay/session.ts +509 -0
  234. package/src/replay/texture-format.ts +99 -0
  235. package/src/texel-decode.ts +289 -0
  236. package/src/texel-layout.ts +324 -0
  237. package/src/types.ts +888 -0
@@ -0,0 +1,52 @@
1
+ /// <reference types="@webgpu/types" />
2
+
3
+ import { describe, expect, it } from 'vitest';
4
+ import { decodeTape } from '../protocol/codec';
5
+ import { attachRecorder, type RecordableBackend } from '../recorder/session';
6
+
7
+ async function dawnBackend(): Promise<RecordableBackend> {
8
+ return (await import('@forgeax/engine-rhi-webgpu')) as unknown as RecordableBackend;
9
+ }
10
+
11
+ describe('RecorderSession steady-frame Dawn contract', () => {
12
+ it('captures a resource created and uploaded before the requested frame', async () => {
13
+ const backend = await dawnBackend();
14
+ const attached = attachRecorder(backend);
15
+ expect(attached.ok).toBe(true);
16
+ if (!attached.ok) return;
17
+
18
+ const adapter = await attached.value.backend.rhi.requestAdapter();
19
+ expect(adapter.ok).toBe(true);
20
+ if (!adapter.ok) return;
21
+ const device = await adapter.value.requestDevice();
22
+ expect(device.ok).toBe(true);
23
+ if (!device.ok) return;
24
+ const buffer = device.value.createBuffer({ size: 16, usage: 0x28 });
25
+ expect(buffer.ok).toBe(true);
26
+ if (!buffer.ok) return;
27
+ const write = device.value.queue.writeBuffer(buffer.value, 0, new Uint8Array(16));
28
+ expect(write.ok).toBe(true);
29
+
30
+ const capture = attached.value.captureFrame();
31
+ expect((await attached.value.frameBoundary()).ok).toBe(true);
32
+ const encoder = device.value.createCommandEncoder({});
33
+ expect(encoder.ok).toBe(true);
34
+ if (!encoder.ok) return;
35
+ encoder.value.clearBuffer(buffer.value, 0, 16);
36
+ const command = encoder.value.finish();
37
+ expect(command.ok).toBe(true);
38
+ if (!command.ok) return;
39
+ const submit = device.value.queue.submit([command.value]);
40
+ expect(submit.ok).toBe(true);
41
+ expect((await attached.value.frameBoundary()).ok).toBe(true);
42
+
43
+ const result = await capture;
44
+ expect(result.ok).toBe(true);
45
+ if (!result.ok) return;
46
+ const decoded = decodeTape(result.value.bytes);
47
+ if (!decoded.ok)
48
+ throw new Error(`${decoded.error.code}: ${JSON.stringify(decoded.error.detail)}`);
49
+ expect(decoded.value.bootstrap.some((resource) => resource.kind === 'buffer')).toBe(true);
50
+ expect(decoded.value.events.some((event) => event.kind === 'clearBuffer')).toBe(true);
51
+ });
52
+ });
@@ -0,0 +1,155 @@
1
+ import { type RhiDevice, RhiError, type RhiQueue } from '@forgeax/engine-rhi';
2
+ import { createShaderModule, rhi } from '@forgeax/engine-rhi-null';
3
+ import { err } from '@forgeax/engine-types';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import type { Tape } from '../protocol/types';
6
+ import type { ReplayBackend } from '../replay/session';
7
+ import { openReplay } from '../replay/session';
8
+
9
+ function baseEvents(): Tape['events'] {
10
+ return [
11
+ { kind: 'createBuffer', handleId: 'buf:1', desc: { size: 16, usage: 8 } },
12
+ {
13
+ kind: 'writeBuffer',
14
+ handleId: 'buf:1',
15
+ bufferOffset: 0,
16
+ dataHash: 'sha256:payload',
17
+ size: 4,
18
+ },
19
+ { kind: 'createCommandEncoder', cmdHandleId: 'encoder:1', desc: {} },
20
+ { kind: 'beginComputePass', cmdHandleId: 'encoder:1', passHandleId: 'pass:1', desc: {} },
21
+ { kind: 'dispatchWorkgroups', passHandleId: 'pass:1', x: 1, y: 1, z: 1 },
22
+ { kind: 'endComputePass', passHandleId: 'pass:1' },
23
+ { kind: 'finish', cmdHandleId: 'encoder:1' },
24
+ { kind: 'submit', cmdHandleIds: ['encoder:1'] },
25
+ ];
26
+ }
27
+
28
+ function tape(events: Tape['events'] = baseEvents()): Tape {
29
+ return {
30
+ header: { formatVersion: 7, rhiCaps: {}, eventCount: events.length, blobCount: 1 },
31
+ bootstrap: [],
32
+ events,
33
+ blobs: [{ hash: 'sha256:payload', bytes: new Uint8Array([1, 2, 3, 4]), compression: 'none' }],
34
+ };
35
+ }
36
+
37
+ async function device(): Promise<RhiDevice> {
38
+ const adapter = await rhi.requestAdapter();
39
+ if (!adapter.ok) throw new Error(adapter.error.hint);
40
+ const result = await adapter.value.requestDevice();
41
+ if (!result.ok) throw new Error(result.error.hint);
42
+ return result.value;
43
+ }
44
+
45
+ function failure(code: 'webgpu-runtime-error' | 'shader-compile-failed'): RhiError {
46
+ return new RhiError({
47
+ code,
48
+ expected: 'the injected RHI operation succeeds',
49
+ hint: 'fault fixture',
50
+ });
51
+ }
52
+
53
+ function withDeviceOverrides(
54
+ base: RhiDevice,
55
+ overrides: Partial<Pick<RhiDevice, 'createBuffer' | 'createCommandEncoder'>> & {
56
+ readonly queue?: RhiQueue;
57
+ },
58
+ ): RhiDevice {
59
+ const result = Object.create(base) as RhiDevice;
60
+ for (const [key, value] of Object.entries(overrides)) {
61
+ Object.defineProperty(result, key, { configurable: true, value });
62
+ }
63
+ return result;
64
+ }
65
+
66
+ describe('ReplaySession fail-closed executor', () => {
67
+ it('reports create failure with location and does not execute later work', async () => {
68
+ const base = await device();
69
+ const laterCreate = vi.fn(() => base.createBuffer({ size: 16, usage: 8 }));
70
+ const failing = withDeviceOverrides(base, {
71
+ createBuffer: vi.fn(() => err(failure('webgpu-runtime-error'))),
72
+ });
73
+ const backend: ReplayBackend = { device: failing, createShaderModule };
74
+ const result = await openReplay(
75
+ tape([
76
+ { kind: 'createBuffer', handleId: 'buf:1', desc: { size: 16, usage: 8 } },
77
+ { kind: 'createBuffer', handleId: 'buf:2', desc: { size: 16, usage: 8 } },
78
+ ...baseEvents().slice(2),
79
+ ]),
80
+ backend,
81
+ );
82
+ expect(result.ok).toBe(true);
83
+ if (!result.ok) return;
84
+ const inspection = await result.value.inspectWork(0);
85
+ expect(inspection.ok).toBe(false);
86
+ if (!inspection.ok) {
87
+ expect(inspection.error.code).toBe('replay-event-failed');
88
+ if (inspection.error.code === 'replay-event-failed') {
89
+ expect(inspection.error.detail?.eventIndex).toBe(0);
90
+ expect(inspection.error.detail?.kind).toBe('createBuffer');
91
+ expect(inspection.error.detail?.stage).toBe('create');
92
+ }
93
+ }
94
+ expect(laterCreate).not.toHaveBeenCalled();
95
+ });
96
+
97
+ it('turns shader factory failure into the first terminal event error', async () => {
98
+ const backend: ReplayBackend = {
99
+ device: await device(),
100
+ createShaderModule: async () => err(failure('shader-compile-failed')),
101
+ };
102
+ const result = await openReplay(
103
+ tape([
104
+ { kind: 'createShaderModule', handleId: 'shader:1', wgslCode: '@compute fn main() {}' },
105
+ ...baseEvents(),
106
+ ]),
107
+ backend,
108
+ );
109
+ expect(result.ok).toBe(true);
110
+ if (!result.ok) return;
111
+ const inspection = await result.value.inspectWork(0);
112
+ expect(inspection.ok).toBe(false);
113
+ if (!inspection.ok && inspection.error.code === 'replay-event-failed') {
114
+ expect(inspection.error.detail?.eventIndex).toBe(0);
115
+ expect(inspection.error.detail?.kind).toBe('createShaderModule');
116
+ expect(inspection.error.detail?.stage).toBe('create');
117
+ expect(inspection.error.detail?.cause).toContain('shader-compile-failed');
118
+ }
119
+ });
120
+
121
+ it('stops at write failures before dispatch', async () => {
122
+ const base = await device();
123
+ const queue = Object.create(base.queue) as RhiQueue;
124
+ Object.defineProperty(queue, 'writeBuffer', {
125
+ configurable: true,
126
+ value: vi.fn(() => err(failure('webgpu-runtime-error'))),
127
+ });
128
+ const backend: ReplayBackend = {
129
+ device: withDeviceOverrides(base, { queue }),
130
+ createShaderModule,
131
+ };
132
+ const result = await openReplay(tape(), backend);
133
+ expect(result.ok).toBe(true);
134
+ if (!result.ok) return;
135
+ const inspection = await result.value.inspectWork(0);
136
+ expect(inspection.ok).toBe(false);
137
+ if (!inspection.ok && inspection.error.code === 'replay-event-failed') {
138
+ expect(inspection.error.detail?.eventIndex).toBe(1);
139
+ expect(inspection.error.detail?.kind).toBe('writeBuffer');
140
+ expect(inspection.error.detail?.stage).toBe('write');
141
+ }
142
+ });
143
+
144
+ it('rejects stale generation reads after a new inspection reset', async () => {
145
+ const result = await openReplay(tape(), { device: await device(), createShaderModule });
146
+ expect(result.ok).toBe(true);
147
+ if (!result.ok) return;
148
+ const first = await result.value.inspectWork(0);
149
+ expect(first.ok).toBe(true);
150
+ const generation = result.value.generation;
151
+ expect(generation).toBeGreaterThan(0);
152
+ expect((await result.value.dispose()).ok).toBe(true);
153
+ expect((await result.value.dispose()).ok).toBe(true);
154
+ });
155
+ });
@@ -0,0 +1,220 @@
1
+ /// <reference types="@webgpu/types" />
2
+
3
+ import type { RhiInstance } from '@forgeax/engine-rhi';
4
+ import { RhiError } from '@forgeax/engine-rhi';
5
+ import { err } from '@forgeax/engine-types';
6
+ import { describe, expect, it } from 'vitest';
7
+ import type { BootstrapResource, Tape } from '../protocol/types';
8
+ import { openReplay, type ReplayBackend } from '../replay/session';
9
+
10
+ interface DawnPack {
11
+ readonly rhi: RhiInstance;
12
+ readonly createShaderModule: ReplayBackend['createShaderModule'];
13
+ }
14
+
15
+ const SKIP_DAWN = process.env.FORGEAX_SKIP_DAWN === '1';
16
+ const WIDTH = 32;
17
+ const HEIGHT = 32;
18
+
19
+ const VERTEX_SHADER = `
20
+ @vertex
21
+ fn main(@builtin(vertex_index) vertexIndex: u32) -> @builtin(position) vec4<f32> {
22
+ var positions = array<vec2<f32>, 3>(vec2<f32>(0.0, 0.7), vec2<f32>(-0.7, -0.7), vec2<f32>(0.7, -0.7));
23
+ return vec4<f32>(positions[vertexIndex], 0.0, 1.0);
24
+ }`;
25
+
26
+ const FRAGMENT_SHADER = `
27
+ @fragment
28
+ fn main() -> @location(0) vec4<f32> {
29
+ return vec4<f32>(1.0, 0.0, 0.0, 1.0);
30
+ }`;
31
+
32
+ async function loadDawn(): Promise<DawnPack> {
33
+ return (await import('@forgeax/engine-rhi-webgpu')) as unknown as DawnPack;
34
+ }
35
+
36
+ async function freshDevice(pack: DawnPack) {
37
+ const adapter = await pack.rhi.requestAdapter();
38
+ expect(adapter.ok).toBe(true);
39
+ if (!adapter.ok) throw new Error(`Dawn admission failed: ${adapter.error.code}`);
40
+ const device = await adapter.value.requestDevice();
41
+ expect(device.ok).toBe(true);
42
+ if (!device.ok) throw new Error(`Dawn device admission failed: ${device.error.code}`);
43
+ return device.value;
44
+ }
45
+
46
+ function bootstrap(
47
+ handleId: string,
48
+ kind: BootstrapResource['kind'],
49
+ create: Record<string, unknown>,
50
+ initialData: BootstrapResource['initialData'] = [],
51
+ ): BootstrapResource {
52
+ return { handleId, kind, create, initialData };
53
+ }
54
+
55
+ function triangleTape(): Tape {
56
+ const bootstrapResources: BootstrapResource[] = [
57
+ bootstrap(
58
+ 'texture:rt',
59
+ 'texture',
60
+ {
61
+ kind: 'createTexture',
62
+ handleId: 'texture:rt',
63
+ desc: {
64
+ size: { width: WIDTH, height: HEIGHT, depthOrArrayLayers: 1 },
65
+ format: 'rgba8unorm',
66
+ dimension: '2d',
67
+ mipLevelCount: 1,
68
+ sampleCount: 1,
69
+ usage: 0x13,
70
+ },
71
+ },
72
+ [{ hash: 'texture-seed', byteOffset: 0, byteLength: WIDTH * HEIGHT * 4 }],
73
+ ),
74
+ bootstrap('texture-view:rt', 'texture-view', {
75
+ kind: 'createTextureView',
76
+ sourceHandleId: 'texture:rt',
77
+ resultHandleId: 'texture-view:rt',
78
+ desc: {},
79
+ }),
80
+ bootstrap('shader:vertex', 'shader-module', {
81
+ kind: 'createShaderModule',
82
+ handleId: 'shader:vertex',
83
+ wgslCode: VERTEX_SHADER,
84
+ }),
85
+ bootstrap('shader:fragment', 'shader-module', {
86
+ kind: 'createShaderModule',
87
+ handleId: 'shader:fragment',
88
+ wgslCode: FRAGMENT_SHADER,
89
+ }),
90
+ bootstrap('layout:empty', 'binding', {
91
+ kind: 'createBindGroupLayout',
92
+ handleId: 'layout:empty',
93
+ desc: { entries: [] },
94
+ }),
95
+ bootstrap('pipeline-layout:empty', 'binding', {
96
+ kind: 'createPipelineLayout',
97
+ handleId: 'pipeline-layout:empty',
98
+ bglHandleIds: ['layout:empty'],
99
+ }),
100
+ bootstrap('pipeline:triangle', 'pipeline', {
101
+ kind: 'createRenderPipeline',
102
+ handleId: 'pipeline:triangle',
103
+ desc: {
104
+ vertex: { entryPoint: 'main', buffers: [] },
105
+ fragment: { entryPoint: 'main', targets: [{ format: 'rgba8unorm' }] },
106
+ primitive: { topology: 'triangle-list' },
107
+ },
108
+ layoutHandleId: 'pipeline-layout:empty',
109
+ vertexShaderModuleHandleId: 'shader:vertex',
110
+ fragmentShaderModuleHandleId: 'shader:fragment',
111
+ }),
112
+ ];
113
+ const events = [
114
+ { kind: 'createCommandEncoder', cmdHandleId: 'encoder:frame', desc: {} },
115
+ {
116
+ kind: 'beginRenderPass',
117
+ cmdHandleId: 'encoder:frame',
118
+ passHandleId: 'pass:frame',
119
+ desc: {
120
+ colorAttachments: [
121
+ { view: null, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'load', storeOp: 'store' },
122
+ ],
123
+ },
124
+ colorAttachmentViewHandleIds: ['texture-view:rt'],
125
+ colorAttachmentResolveTargetHandleIds: [undefined],
126
+ depthStencilViewHandleId: undefined,
127
+ },
128
+ { kind: 'setPipeline', passHandleId: 'pass:frame', pipelineHandleId: 'pipeline:triangle' },
129
+ {
130
+ kind: 'draw',
131
+ passHandleId: 'pass:frame',
132
+ vertexCount: 3,
133
+ instanceCount: 1,
134
+ firstVertex: 0,
135
+ firstInstance: 0,
136
+ },
137
+ { kind: 'endRenderPass', passHandleId: 'pass:frame' },
138
+ { kind: 'finish', cmdHandleId: 'encoder:frame' },
139
+ { kind: 'submit', cmdHandleIds: ['encoder:frame'] },
140
+ { kind: 'frameMark', frameIdx: 0 },
141
+ ] as unknown as Tape['events'];
142
+ return {
143
+ header: { formatVersion: 7, rhiCaps: {}, eventCount: events.length, blobCount: 1 },
144
+ bootstrap: bootstrapResources,
145
+ events,
146
+ blobs: [
147
+ {
148
+ hash: 'texture-seed',
149
+ bytes: new Uint8Array(WIDTH * HEIGHT * 4).fill(23),
150
+ compression: 'none',
151
+ },
152
+ ],
153
+ };
154
+ }
155
+
156
+ describe.skipIf(SKIP_DAWN)('ReplaySession Dawn contract', () => {
157
+ it('replays a triangle and returns attachment bytes through inspectWork', async () => {
158
+ const pack = await loadDawn();
159
+ const tape = triangleTape();
160
+ const first = await openReplay(tape, {
161
+ device: await freshDevice(pack),
162
+ createShaderModule: pack.createShaderModule,
163
+ });
164
+ expect(first.ok).toBe(true);
165
+ if (!first.ok) throw new Error(first.error.hint);
166
+ const baseline = await first.value.inspectWork(0, ['pixels']);
167
+ expect(
168
+ baseline.ok,
169
+ baseline.ok ? undefined : `${baseline.error.code}: ${JSON.stringify(baseline.error.detail)}`,
170
+ ).toBe(true);
171
+ if (!baseline.ok) throw new Error(baseline.error.hint);
172
+ expect(baseline.value.attachment?.bytes.byteLength).toBe(WIDTH * HEIGHT * 4);
173
+ expect(baseline.value.attachment?.bytes.slice(0, 4)).toEqual(new Uint8Array([23, 23, 23, 23]));
174
+ expect(baseline.value.attachment?.provenance.resourceId).toBe('texture-view:rt');
175
+ expect(baseline.value.attachment?.provenance.selectedWorkIndex).toBe(0);
176
+ expect(baseline.value.attachment?.provenance.subresource).toBeNull();
177
+
178
+ const second = await openReplay(tape, {
179
+ device: await freshDevice(pack),
180
+ createShaderModule: pack.createShaderModule,
181
+ });
182
+ expect(second.ok).toBe(true);
183
+ if (!second.ok) throw new Error(second.error.hint);
184
+ const replay = await second.value.inspectWork(0, ['pixels']);
185
+ expect(replay.ok).toBe(true);
186
+ if (!replay.ok) throw new Error(replay.error.hint);
187
+ expect(replay.value.attachment?.bytes).toEqual(baseline.value.attachment?.bytes);
188
+ expect((await first.value.dispose()).ok).toBe(true);
189
+ expect((await second.value.dispose()).ok).toBe(true);
190
+ }, 60_000);
191
+
192
+ it('reports the first shader factory failure on a real Dawn device', async () => {
193
+ const pack = await loadDawn();
194
+ const device = await freshDevice(pack);
195
+ const failingBackend: ReplayBackend = {
196
+ device,
197
+ createShaderModule: async () =>
198
+ err(
199
+ new RhiError({
200
+ code: 'shader-compile-failed',
201
+ expected: 'shader compilation succeeds',
202
+ hint: 'test factory failure',
203
+ }),
204
+ ),
205
+ };
206
+ const replay = await openReplay(triangleTape(), failingBackend);
207
+ expect(replay.ok).toBe(true);
208
+ if (!replay.ok) throw new Error(replay.error.hint);
209
+ const inspection = await replay.value.inspectWork(0);
210
+ expect(inspection.ok).toBe(false);
211
+ if (!inspection.ok) {
212
+ expect(inspection.error.code).toBe('replay-event-failed');
213
+ if (inspection.error.code === 'replay-event-failed') {
214
+ expect(inspection.error.detail?.kind).toBe('createShaderModule');
215
+ expect(inspection.error.detail?.stage).toBe('create');
216
+ }
217
+ }
218
+ expect((await replay.value.dispose()).ok).toBe(true);
219
+ }, 60_000);
220
+ });
@@ -0,0 +1,27 @@
1
+ import type { RhiDevice } from '@forgeax/engine-rhi';
2
+ import { expectTypeOf } from 'vitest';
3
+ import type { ReplayBackend, ReplaySession, WorkInspection } from '../replay/session';
4
+
5
+ declare const device: RhiDevice;
6
+ declare const session: ReplaySession;
7
+
8
+ const backend: ReplayBackend = {
9
+ device,
10
+ createShaderModule: async () => {
11
+ throw new Error('fixture');
12
+ },
13
+ };
14
+
15
+ expectTypeOf(backend.createShaderModule).toBeFunction();
16
+ expectTypeOf(session.inspectWork(0)).resolves.toHaveProperty('ok');
17
+ expectTypeOf(session.dispose()).resolves.toHaveProperty('ok');
18
+ expectTypeOf<WorkInspection['pipeline']>().not.toBeNever();
19
+ expectTypeOf<WorkInspection['bindings']>().toMatchTypeOf<readonly unknown[] | undefined>();
20
+ expectTypeOf<WorkInspection['vertexBuffers']>().toMatchTypeOf<readonly unknown[] | undefined>();
21
+ expectTypeOf<WorkInspection['indexBuffer']>().not.toBeNever();
22
+ expectTypeOf<WorkInspection['shaders']>().toMatchTypeOf<readonly unknown[] | undefined>();
23
+ expectTypeOf<WorkInspection['resourceIds']>().toEqualTypeOf<readonly string[] | undefined>();
24
+
25
+ // @ts-expect-error ReplayBackend must not be constructible without shader creation.
26
+ const missingFactory: ReplayBackend = { device };
27
+ void missingFactory;
@@ -0,0 +1,171 @@
1
+ import { createShaderModule, rhi } from '@forgeax/engine-rhi-null';
2
+ import { describe, expect, it } from 'vitest';
3
+ import type { Tape } from '../protocol/types';
4
+ import type { ReplayBackend } from '../replay/session';
5
+ import { openReplay } from '../replay/session';
6
+
7
+ function makeWorkTape(): Tape {
8
+ return {
9
+ header: { formatVersion: 7, rhiCaps: {}, eventCount: 7, blobCount: 0 },
10
+ bootstrap: [],
11
+ events: [
12
+ {
13
+ kind: 'createCommandEncoder',
14
+ cmdHandleId: 'encoder:1',
15
+ desc: {},
16
+ },
17
+ {
18
+ kind: 'beginComputePass',
19
+ cmdHandleId: 'encoder:1',
20
+ passHandleId: 'pass:1',
21
+ desc: {},
22
+ },
23
+ {
24
+ kind: 'dispatchWorkgroups',
25
+ passHandleId: 'pass:1',
26
+ x: 1,
27
+ y: 1,
28
+ z: 1,
29
+ },
30
+ { kind: 'endComputePass', passHandleId: 'pass:1' },
31
+ { kind: 'finish', cmdHandleId: 'encoder:1' },
32
+ { kind: 'submit', cmdHandleIds: ['encoder:1'] },
33
+ { kind: 'frameMark', frameIdx: 0 },
34
+ ],
35
+ blobs: [],
36
+ };
37
+ }
38
+
39
+ async function makeBackend(): Promise<ReplayBackend> {
40
+ const adapter = await rhi.requestAdapter();
41
+ if (!adapter.ok) throw new Error(adapter.error.hint);
42
+ const device = await adapter.value.requestDevice();
43
+ if (!device.ok) throw new Error(device.error.hint);
44
+ return { device: device.value, createShaderModule };
45
+ }
46
+
47
+ describe('ReplaySession', () => {
48
+ it('replays a depth-only render pipeline without requiring a fragment shader', async () => {
49
+ const tape: Tape = {
50
+ header: { formatVersion: 7, rhiCaps: {}, eventCount: 7, blobCount: 0 },
51
+ bootstrap: [
52
+ {
53
+ handleId: 'shader:vertex',
54
+ kind: 'shader-module',
55
+ create: {
56
+ kind: 'createShaderModule',
57
+ handleId: 'shader:vertex',
58
+ wgslCode: '@vertex fn main() -> @builtin(position) vec4<f32> { return vec4<f32>(); }',
59
+ },
60
+ initialData: [],
61
+ },
62
+ {
63
+ handleId: 'layout:empty',
64
+ kind: 'binding',
65
+ create: {
66
+ kind: 'createBindGroupLayout',
67
+ handleId: 'layout:empty',
68
+ desc: { entries: [] },
69
+ },
70
+ initialData: [],
71
+ },
72
+ {
73
+ handleId: 'pipeline-layout:empty',
74
+ kind: 'binding',
75
+ create: {
76
+ kind: 'createPipelineLayout',
77
+ handleId: 'pipeline-layout:empty',
78
+ bglHandleIds: ['layout:empty'],
79
+ },
80
+ initialData: [],
81
+ },
82
+ {
83
+ handleId: 'pipeline:depth-only',
84
+ kind: 'pipeline',
85
+ create: {
86
+ kind: 'createRenderPipeline',
87
+ handleId: 'pipeline:depth-only',
88
+ desc: { vertex: { entryPoint: 'main', buffers: [] } },
89
+ layoutHandleId: 'pipeline-layout:empty',
90
+ vertexShaderModuleHandleId: 'shader:vertex',
91
+ },
92
+ initialData: [],
93
+ },
94
+ ],
95
+ events: makeWorkTape().events,
96
+ blobs: [],
97
+ };
98
+ const result = await openReplay(tape, await makeBackend());
99
+ expect(result.ok).toBe(true);
100
+ if (!result.ok) return;
101
+ expect((await result.value.inspectWork(0)).ok).toBe(true);
102
+ });
103
+
104
+ it('replays a work index on a caller-provided fresh backend', async () => {
105
+ const result = await openReplay(makeWorkTape(), await makeBackend());
106
+ expect(result.ok).toBe(true);
107
+ if (!result.ok) return;
108
+
109
+ const inspection = await result.value.inspectWork(0);
110
+ expect(inspection.ok).toBe(true);
111
+ if (!inspection.ok) return;
112
+ expect(inspection.value.workIndex).toBe(0);
113
+ expect(inspection.value.eventIndex).toBe(2);
114
+ expect(inspection.value.attachment).toBeUndefined();
115
+ expect('stepTo' in result.value).toBe(false);
116
+ expect('commitThroughDraw' in result.value).toBe(false);
117
+ });
118
+
119
+ it('returns selected-work facts for every inspection selector', async () => {
120
+ const result = await openReplay(makeWorkTape(), await makeBackend());
121
+ expect(result.ok).toBe(true);
122
+ if (!result.ok) return;
123
+
124
+ const inspection = await result.value.inspectWork(0, ['bindings', 'pipeline']);
125
+ expect(inspection.ok).toBe(true);
126
+ if (!inspection.ok) return;
127
+ expect(inspection.value.pipeline).toBeDefined();
128
+ expect(inspection.value.bindings).toBeDefined();
129
+ expect(inspection.value.vertexBuffers).toBeDefined();
130
+ expect(inspection.value.indexBuffer).toBeDefined();
131
+ expect(inspection.value.shaders).toBeDefined();
132
+ expect(inspection.value.resourceIds).toBeDefined();
133
+ });
134
+
135
+ it('resets internally when selecting an earlier work and rejects invalid positions', async () => {
136
+ const tape = makeWorkTape();
137
+ const withSecondWork: Tape = {
138
+ ...tape,
139
+ header: { ...tape.header, eventCount: tape.events.length + 1 },
140
+ events: [
141
+ ...tape.events.slice(0, 3),
142
+ { kind: 'dispatchWorkgroups', passHandleId: 'pass:1', x: 2, y: 1, z: 1 },
143
+ ...tape.events.slice(3),
144
+ ],
145
+ };
146
+ const result = await openReplay(withSecondWork, await makeBackend());
147
+ expect(result.ok).toBe(true);
148
+ if (!result.ok) return;
149
+
150
+ expect((await result.value.inspectWork(1)).ok).toBe(true);
151
+ const earlier = await result.value.inspectWork(0);
152
+ expect(earlier.ok).toBe(true);
153
+ if (!earlier.ok) return;
154
+ expect(earlier.value.workIndex).toBe(0);
155
+
156
+ const invalid = await result.value.inspectWork(2);
157
+ expect(invalid.ok).toBe(false);
158
+ if (!invalid.ok) expect(invalid.error.code).toBe('replay-position-invalid');
159
+ });
160
+
161
+ it('returns a structured terminal error after dispose and keeps the session closed', async () => {
162
+ const result = await openReplay(makeWorkTape(), await makeBackend());
163
+ expect(result.ok).toBe(true);
164
+ if (!result.ok) return;
165
+
166
+ expect((await result.value.dispose()).ok).toBe(true);
167
+ const reused = await result.value.inspectWork(0);
168
+ expect(reused.ok).toBe(false);
169
+ if (!reused.ok) expect(reused.error.code).toBe('replay-position-invalid');
170
+ });
171
+ });