@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,587 @@
1
+ // @forgeax/engine-rhi-debug/src/recorder/core -- recorder state and event ownership.
2
+
3
+ /// <reference types="@webgpu/types" />
4
+
5
+ import type {
6
+ Result,
7
+ RhiDevice,
8
+ RhiInstance,
9
+ ShaderModule,
10
+ TextureView,
11
+ } from '@forgeax/engine-rhi';
12
+ import { createRhiDebugError, type RhiDebugError } from '../errors';
13
+ import { textureBlockLayout } from '../texel-layout';
14
+ import type { HandleId, RhiCallEvent, RhiCapsRecorded, Tape } from '../types';
15
+ import { _getCreateEventReferencedHandleIds } from './closure';
16
+
17
+ const SNAPSHOT_TIMEOUT_MS = 30_000;
18
+
19
+ // Result factories `makeOk` / `makeErr` re-import the canonical
20
+ // `ok` / `err` from `@forgeax/engine-types` (architecture-principles #1
21
+ // SSOT — same shape, same factory, no inline duplicate). Aliased on
22
+ // import to avoid a free-form rename diff in this file's existing
23
+ // `makeOk(...)` / `makeErr(...)` call sites; semantics identical.
24
+
25
+ // ============================================================================
26
+ // Constants
27
+ // ============================================================================
28
+
29
+ export const PER_EVENT_OVERHEAD = 192 as const;
30
+
31
+ // COPY_SRC promotion bit values (D-5). GPUBufferUsage and GPUTextureUsage have
32
+ // DIFFERENT bit layouts — COPY_SRC is 0x04 for buffers but 0x01 for textures:
33
+ // GPUBufferUsage: MAP_READ=0x01 MAP_WRITE=0x02 COPY_SRC=0x04 COPY_DST=0x08 ...
34
+ // GPUTextureUsage: COPY_SRC=0x01 COPY_DST=0x02 TEXTURE_BINDING=0x04 ...
35
+ // A buffer carrying MAP_READ / MAP_WRITE cannot also carry COPY_SRC (WebGPU
36
+ // validation: a mappable buffer's only other allowed usage is the matching
37
+ // COPY_DST / COPY_SRC), so promotion is skipped for mappable buffers — those
38
+ // are staging buffers, never frame-header snapshot targets.
39
+ const BUFFER_USAGE_COPY_SRC = 0x04;
40
+ const BUFFER_USAGE_MAP_READ = 0x01;
41
+ const BUFFER_USAGE_MAP_WRITE = 0x02;
42
+ const TEXTURE_USAGE_COPY_SRC = 0x01;
43
+ const TEXTURE_USAGE_COPY_DST = 0x02;
44
+ const TEXTURE_USAGE_BINDING = 0x04;
45
+ /** Bound staging allocation, command submission, and map concurrency per batch. */
46
+ export const SNAPSHOT_RESOURCE_BATCH_SIZE = 32;
47
+
48
+ /**
49
+ * True for each depth / stencil texture format. Their content is render-pass
50
+ * output (shadow maps, z-buffers), never an uploaded byte payload, and
51
+ * queue.writeTexture rejects them (no CopyDst), so the frame-header snapshot
52
+ * loop skips them rather than emitting an un-seedable initialData event.
53
+ */
54
+ function isDepthOrStencilFormat(format: GPUTextureFormat | undefined): boolean {
55
+ return format !== undefined && (format.startsWith('depth') || format.startsWith('stencil'));
56
+ }
57
+
58
+ /**
59
+ * True for a texture the frame-header snapshot can read back AND re-seed
60
+ * faithfully: each color format with a known texel-block footprint, at each
61
+ * array-layer count and each mip count. The readback + seed path
62
+ * (readbackTexturePixels + computeTextureLayout + replayInitialData) walks
63
+ * every (layer, mip) subresource with block-aware bytesPerRow, so ordinary
64
+ * texels and BC/ETC/ASTC compressed assets share one round-trip contract.
65
+ *
66
+ * Still skipped (no faithful path today, Fail Fast rather than corrupt seed):
67
+ * - depth/stencil formats: queue.writeTexture rejects them (no CopyDst seed).
68
+ * - multisample (sampleCount > 1): writeTexture rejects an MSAA target. MSAA
69
+ * attachments are transient (resolved into a single-sample texture that IS
70
+ * snapshottable), so skipping loses no seed.
71
+ */
72
+ function isSnapshottableColorTexture(
73
+ format: GPUTextureFormat | undefined,
74
+ _size: number | GPUExtent3DStrict | undefined,
75
+ sampleCount?: number,
76
+ ): boolean {
77
+ if (isDepthOrStencilFormat(format)) return false;
78
+ // Multisample textures reject queue.writeTexture; skip (resolved target seeds).
79
+ if (sampleCount !== undefined && sampleCount > 1) return false;
80
+ // Round-trippable iff its texel-block footprint is known.
81
+ return textureBlockLayout(format) !== undefined;
82
+ }
83
+
84
+ /** Add COPY_SRC to a buffer usage unless it is a mappable (MAP_READ/WRITE) buffer. */
85
+ function promoteBufferUsage(usage: number): number {
86
+ if ((usage & (BUFFER_USAGE_MAP_READ | BUFFER_USAGE_MAP_WRITE)) !== 0) return usage;
87
+ return usage | BUFFER_USAGE_COPY_SRC;
88
+ }
89
+
90
+ /**
91
+ * True for a mappable (MAP_READ / MAP_WRITE) buffer. These are staging buffers
92
+ * (e.g. shadow-probe-staging): promoteBufferUsage deliberately does NOT add
93
+ * COPY_SRC to them (MAP_READ|COPY_SRC is an invalid WebGPU usage combo), so they
94
+ * cannot be a copyBufferToBuffer source. The frame-header snapshot loop must skip
95
+ * them — driving readbackBufferBytes on one throws "usage doesn't include
96
+ * CopySrc". Their bytes are transient readback scratch, never seed payload, so
97
+ * losing them is correct (mirrors promoteBufferUsage's own exclusion).
98
+ */
99
+ function isMappableBuffer(usage: number): boolean {
100
+ return (usage & (BUFFER_USAGE_MAP_READ | BUFFER_USAGE_MAP_WRITE)) !== 0;
101
+ }
102
+
103
+ export { TAPE_FORMAT_VERSION } from '../protocol/types';
104
+
105
+ // ============================================================================
106
+ // State machine
107
+ // ============================================================================
108
+
109
+ enum RecorderState {
110
+ Idle = 'idle',
111
+ Armed = 'armed',
112
+ Snapshotting = 'snapshotting',
113
+ Recording = 'recording',
114
+ Finalizing = 'finalizing',
115
+ Error = 'error',
116
+ }
117
+
118
+ // ============================================================================
119
+ // Hash utility for blob dedup
120
+ // ============================================================================
121
+
122
+ /** @internal */
123
+ let _nextHandleId = 0;
124
+
125
+ function allocHandleId(kind: string): HandleId {
126
+ return `${kind}:${++_nextHandleId}`;
127
+ }
128
+
129
+ function fastHash(data: ArrayBuffer): string {
130
+ const view = new Uint8Array(data);
131
+ let hash = 5381;
132
+ for (let i = 0; i < view.length; i++) {
133
+ hash = ((hash << 5) + hash + (view[i] ?? 0)) | 0;
134
+ }
135
+ return (hash >>> 0).toString(16);
136
+ }
137
+
138
+ function storeBlob(state: RecorderInternal, data: ArrayBuffer): string {
139
+ const hash = fastHash(data);
140
+ if (!state.blobPool.has(hash)) {
141
+ state.blobPool.set(hash, data.slice(0) as ArrayBuffer);
142
+ }
143
+ return hash;
144
+ }
145
+
146
+ /**
147
+ * Narrow a readbackBufferBytes failure to the snapshot stage.
148
+ * readbackBufferBytes already tags `.detail.phase` with 'copy' | 'map'; carry
149
+ * it through so the re-wrapped error preserves the failure point. Falls back to
150
+ * 'copy' when the inner error lacks a snapshot detail.
151
+ */
152
+ function snapshotStageOf(error: RhiDebugError): 'copy' | 'map' | 'store' {
153
+ const d = error.detail;
154
+ if ('phase' in d && d.phase !== undefined) return d.phase;
155
+ return 'copy';
156
+ }
157
+
158
+ // ============================================================================
159
+ // Internal recorder state
160
+ // ============================================================================
161
+
162
+ type SnapshotProgress = {
163
+ readonly startedAt: number;
164
+ readonly stage: 'queue-drain' | 'resource-readback';
165
+ readonly totalResources: number;
166
+ readonly completedResources: number;
167
+ readonly skippedResources: number;
168
+ readonly currentHandleId: string | null;
169
+ readonly currentKind: 'buffer' | 'texture' | null;
170
+ readonly currentSizeBytes: number | null;
171
+ };
172
+
173
+ interface RecorderInternal {
174
+ state: RecorderState;
175
+ requestedFrames: number;
176
+ recordedFrames: number;
177
+ events: RhiCallEvent[];
178
+ blobPool: Map<string, ArrayBuffer>;
179
+ handleMap: WeakMap<object, HandleId>;
180
+ textureViewHandleMap: WeakMap<TextureView, HandleId>;
181
+ /**
182
+ * @internal
183
+ * Bootstrap create-event table. Populated by registerHandle when called
184
+ * with a create event payload — records every create* (buffer, texture,
185
+ * pipeline, bindGroup, shaderModule, …) from the moment wrap() is called,
186
+ * independent of the recorder state machine (Idle / Armed / Recording).
187
+ * Preserved across arm() cycles (SSOT for closure computation in getTape).
188
+ */
189
+ bootstrapCreates: Map<HandleId, RhiCallEvent>;
190
+ /** Handles whose initialData event was emitted by the current capture. */
191
+ snapshotSeededHandles: Set<HandleId>;
192
+ /** Generation token invalidating async snapshot work after timeout/error. */
193
+ snapshotGeneration: number;
194
+ /** Last observable progress of the current/most recent resource snapshot. */
195
+ snapshotProgress: SnapshotProgress | undefined;
196
+ /**
197
+ * @internal
198
+ * Descriptor registry of currently-live resources. Written by createBuffer /
199
+ * createTexture (after registerHandle) and cleared by destroyBuffer /
200
+ * destroyTexture. Distinct from handleMap (WeakMap, handle object -> handleId
201
+ * identity, one-way): descriptorTable carries the descriptor *content* (kind /
202
+ * size / format / usage) AND the resource object keyed by handleId, so
203
+ * snapshotResource can both determine a resource's shape and reach the object
204
+ * for readback at frame-header time without re-scanning the event stream or
205
+ * reverse-walking the WeakMap (which cannot be iterated). destroy* removes the
206
+ * entry so the live-resource set never grows unbounded (AC-09). One registry,
207
+ * one delete on destroy — shape and object share the same lifecycle (SSOT).
208
+ */
209
+ descriptorTable: Map<
210
+ HandleId,
211
+ {
212
+ kind: 'buffer' | 'texture';
213
+ size?: number | GPUExtent3DStrict;
214
+ format?: GPUTextureFormat;
215
+ sampleCount?: number;
216
+ mipLevelCount?: number;
217
+ usage: number;
218
+ resource: object;
219
+ }
220
+ >;
221
+ /** @internal */
222
+ _skipRecord: boolean;
223
+ frameIdx: number;
224
+ bootstrap: boolean;
225
+ recordedCaps: RhiCapsRecorded | undefined;
226
+ onFrameEndUnsubscribe?: (() => void) | undefined;
227
+ /** true when the current recording is valid. */
228
+ valid: boolean;
229
+ /**
230
+ * @internal
231
+ * Most recent live RhiDevice produced by `requestAdapter().requestDevice()`
232
+ * via the recorder proxy chain. Captured so the adapter (I-2 fix) can
233
+ * reach the same device for replay without forcing the host to expose
234
+ * a separate channel.
235
+ */
236
+ capturedDevice: RhiDevice | undefined;
237
+ }
238
+
239
+ function snapshotTimeoutDetail(
240
+ progress: SnapshotProgress | undefined,
241
+ timeoutMs: number,
242
+ ): import('../errors').CaptureTimeoutDetail {
243
+ const current = progress ?? {
244
+ startedAt: Date.now(),
245
+ stage: 'queue-drain' as const,
246
+ totalResources: 0,
247
+ completedResources: 0,
248
+ skippedResources: 0,
249
+ currentHandleId: null,
250
+ currentKind: null,
251
+ currentSizeBytes: null,
252
+ };
253
+ return {
254
+ stage: 'snapshot',
255
+ cause: 'GPU readback did not complete before the bounded snapshot timeout',
256
+ timeoutMs,
257
+ progress: {
258
+ snapshotStage: current.stage,
259
+ totalResources: current.totalResources,
260
+ completedResources: current.completedResources,
261
+ skippedResources: current.skippedResources,
262
+ currentHandleId: current.currentHandleId,
263
+ currentKind: current.currentKind,
264
+ currentSizeBytes: current.currentSizeBytes,
265
+ elapsedMs: Math.max(0, Date.now() - current.startedAt),
266
+ },
267
+ };
268
+ }
269
+
270
+ /**
271
+ * @internal
272
+ * True while the recorder is in a state that appends normal RHI events to the
273
+ * tape: Armed / Recording. Snapshotting is deliberately excluded: the async
274
+ * frame-header seed phase must not fold live viewport frames into the capture.
275
+ * This is the SSOT recording predicate —
276
+ * `pushEvent` gates on it, and the proxy fast-path (writeBuffer / writeTexture /
277
+ * createCommandEncoder) short-circuits when it is false so an idle recorder
278
+ * (FORGEAX_ENGINE_RHI_DEBUG=1 but no capture in flight) pays no per-call
279
+ * event-object allocation, no storeBlob hash+copy, and no proxy-encoder wrapping.
280
+ *
281
+ * Deliberately ignores `_skipRecord`: that flag suppresses recorder-internal
282
+ * RHI calls (such as snapshot readback staging) during either capture phase.
283
+ * `shouldRecord` folds it in for the normal pushEvent gate.
284
+ */
285
+ function isRecordingActive(s: RecorderInternal): boolean {
286
+ return s.state === RecorderState.Armed || s.state === RecorderState.Recording;
287
+ }
288
+
289
+ /**
290
+ * A frame-header snapshot is not a normal render-recording phase, but it can
291
+ * already have copied a live resource and still need that resource's create
292
+ * event when it appends `initialData`. Keep bootstrap ownership through that
293
+ * async window: per-frame feature resources may be released while readback is
294
+ * awaiting GPU completion.
295
+ */
296
+ function retainsCaptureBootstrap(s: RecorderInternal): boolean {
297
+ return isRecordingActive(s) || s.state === RecorderState.Snapshotting;
298
+ }
299
+
300
+ /**
301
+ * @internal
302
+ * The exact pushEvent gate as a predicate: record iff not suppressed AND in an
303
+ * active recording state. Proxy methods that do pre-pushEvent work (storeBlob,
304
+ * event-object construction) check this first to skip that work when it would
305
+ * be discarded — same-condition-as-pushEvent guarantees no behavioural drift
306
+ * (a call that would record still does all its work).
307
+ */
308
+ function shouldRecord(s: RecorderInternal): boolean {
309
+ return !s._skipRecord && isRecordingActive(s);
310
+ }
311
+
312
+ function pushEvent(s: RecorderInternal, event: RhiCallEvent): void {
313
+ if (!shouldRecord(s)) return;
314
+ s.events.push(event);
315
+ }
316
+
317
+ /**
318
+ * Append a frame-header seed without reopening the normal RHI event gate.
319
+ * Snapshotting is not a render-recording state, but its async readback still
320
+ * needs to emit initialData events for the resources that seed replay.
321
+ */
322
+ function pushSnapshotEvent(s: RecorderInternal, event: RhiCallEvent): void {
323
+ if (s._skipRecord || (!isRecordingActive(s) && s.state !== RecorderState.Snapshotting)) {
324
+ return;
325
+ }
326
+ s.events.push(event);
327
+ }
328
+
329
+ function registerHandle(
330
+ s: RecorderInternal,
331
+ handle: object,
332
+ kind: string,
333
+ createEvent?: RhiCallEvent,
334
+ ): HandleId {
335
+ const hId = allocHandleId(kind);
336
+ s.handleMap.set(handle, hId);
337
+ if (createEvent !== undefined) {
338
+ if ('handleId' in createEvent) Object.assign(createEvent, { handleId: hId });
339
+ s.bootstrapCreates.set(hId, createEvent);
340
+ }
341
+ return hId;
342
+ }
343
+
344
+ function ensureTextureCreateEvent(
345
+ s: RecorderInternal,
346
+ texture: object,
347
+ textureId: HandleId,
348
+ viewFormat?: GPUTextureFormat,
349
+ ): RhiDebugError | undefined {
350
+ const existing = s.bootstrapCreates.get(textureId);
351
+ if (existing !== undefined) {
352
+ if (
353
+ viewFormat !== undefined &&
354
+ existing.kind === 'createTexture' &&
355
+ existing.origin === 'swapchain'
356
+ ) {
357
+ addSwapchainViewFormat(existing, viewFormat);
358
+ }
359
+ return undefined;
360
+ }
361
+
362
+ const raw = texture as Record<string, unknown>;
363
+ const width = raw.width as number | undefined;
364
+ const height = raw.height as number | undefined;
365
+ const depthOrArrayLayers = (raw.depthOrArrayLayers as number | undefined) ?? 1;
366
+ const format = raw.format as string | undefined;
367
+ const rawUsage = raw.usage as number | undefined;
368
+
369
+ if (
370
+ width === undefined ||
371
+ height === undefined ||
372
+ format === undefined ||
373
+ rawUsage === undefined
374
+ ) {
375
+ return createRhiDebugError('tape-invalid', {
376
+ stage: 'validate',
377
+ cause: `swapchain texture '${textureId}' has unreadable dimensions (width=${width}, height=${height}, format=${format}, usage=${rawUsage})`,
378
+ handleId: textureId,
379
+ eventIndex: -1,
380
+ });
381
+ }
382
+
383
+ const event: RhiCallEvent = {
384
+ kind: 'createTexture',
385
+ handleId: textureId,
386
+ origin: 'swapchain',
387
+ desc: {
388
+ size: { width, height, depthOrArrayLayers },
389
+ format: format as GPUTextureFormat,
390
+ usage: (rawUsage | TEXTURE_USAGE_COPY_SRC | TEXTURE_USAGE_COPY_DST) as GPUTextureUsageFlags,
391
+ ...(viewFormat === undefined ? {} : { viewFormats: [viewFormat] }),
392
+ },
393
+ };
394
+ s.bootstrapCreates.set(textureId, event);
395
+ pushEvent(s, event);
396
+ return undefined;
397
+ }
398
+
399
+ function addSwapchainViewFormat(
400
+ texture: Extract<RhiCallEvent, { kind: 'createTexture' }>,
401
+ viewFormat: GPUTextureFormat,
402
+ ): void {
403
+ if (texture.desc.format === viewFormat || texture.origin !== 'swapchain') return;
404
+ const viewFormats = new Set(texture.desc.viewFormats ?? []);
405
+ viewFormats.add(viewFormat);
406
+ (texture as { desc: typeof texture.desc }).desc = {
407
+ ...texture.desc,
408
+ viewFormats: [...viewFormats],
409
+ };
410
+ }
411
+
412
+ function reconcileSwapchainViewFormats(s: RecorderInternal): void {
413
+ const events = [...s.events, ...s.bootstrapCreates.values()];
414
+ for (const event of events) {
415
+ if (event.kind !== 'createTextureView' || event.desc.format === undefined) continue;
416
+ const source = s.bootstrapCreates.get(event.sourceHandleId);
417
+ if (source?.kind === 'createTexture' && source.origin === 'swapchain') {
418
+ addSwapchainViewFormat(source, event.desc.format);
419
+ }
420
+ }
421
+ }
422
+
423
+ function hasBootstrapDependency(s: RecorderInternal, handleId: HandleId): boolean {
424
+ for (const event of s.bootstrapCreates.values()) {
425
+ if (_getCreateEventReferencedHandleIds(event).includes(handleId)) return true;
426
+ }
427
+ return false;
428
+ }
429
+
430
+ function getHandleId(s: RecorderInternal, handle: object, kind: string): HandleId {
431
+ const id = s.handleMap.get(handle);
432
+ if (id !== undefined) return id;
433
+ const newId = registerHandle(s, handle, kind);
434
+ if (kind === 'texture') ensureTextureCreateEvent(s, handle, newId);
435
+ return newId;
436
+ }
437
+
438
+ // ============================================================================
439
+ // Transitive closure — bootstrapCreates → self-contained tape prefix
440
+ // ============================================================================
441
+
442
+ /**
443
+ * @internal
444
+ * Collect all handleIds referenced by frame events in `s.events`.
445
+ *
446
+ * Scans events for ALL fields that reference resources — mirrors
447
+ * the reference categories checked by findDanglingHandleId in
448
+ * tape-format.ts to achieve producer/consumer convergence (D-2).
449
+ *
450
+ * Includes: buffer/texture/pipeline/bindGroup/sampler/textureView/
451
+ * shaderModule handles (persistent), plus passHandleId and cmdHandleId
452
+ * from pass/encoder events (per-frame transient). Transient handles
453
+ * that are declared in-frame are excluded later by the inFrameHandleIds
454
+ * filter in getTape().
455
+ */
456
+
457
+ export interface DebugRhiInstance extends RhiInstance {
458
+ arm(frames: number): Result<void, RhiDebugError>;
459
+ onFrameEnd(): void;
460
+ getTape(): Tape | RhiDebugError | undefined;
461
+ getState(): string;
462
+ getEvents(): readonly RhiCallEvent[];
463
+ getBlobPool(): ReadonlyMap<string, ArrayBuffer>;
464
+ /** Transition to error state (e.g. on device.lost). Tape data preserved but valid=false. */
465
+ transitionToError(): void;
466
+ /** Clear error state to idle, allowing re-arm. */
467
+ disposeError(): void;
468
+ /**
469
+ * Snapshot a resource's GPU bytes into the tape as an initialData event.
470
+ *
471
+ * Reads the resource descriptor from the internal registry, copies the
472
+ * resource's bytes via copyToBuffer/mapAsync, stores the bytes into the
473
+ * blobPool (djb2 hash-dedup), and pushes an RhiCallEventInitialData into
474
+ * the event stream. Returns Result with {handleId, dataHash} on success,
475
+ * or capture-snapshot-failed on a readback/storeBlob failure.
476
+ *
477
+ * Async: the GPU readback chain (copyToBuffer -> submit ->
478
+ * onSubmittedWorkDone -> mapAsync) is inherently asynchronous.
479
+ */
480
+ snapshotResource(
481
+ handleId: HandleId,
482
+ ): Promise<Result<{ handleId: HandleId; dataHash: string }, RhiDebugError>>;
483
+ /**
484
+ * Frame-header snapshot loop: awaits all submitted GPU work, then snapshots
485
+ * every live resource in the descriptor registry (full-table dump, no
486
+ * trimming). Advances the recorder Armed -> Snapshotting -> Recording on
487
+ * success. Returns the first snapshot failure as a Result so the caller can
488
+ * fail fast rather than record a partial seed set.
489
+ */
490
+ snapshotAllLiveResources(timeoutMs?: number): Promise<Result<void, RhiDebugError>>;
491
+ /**
492
+ * @internal
493
+ * Append an event from a standalone wrapper (e.g. `wrapCreateShaderModule`)
494
+ * through the same `_skipRecord` + state-machine guard that the proxy
495
+ * methods use. This exists so external wrappers cannot bypass recursion
496
+ * protection (I-12, round 1 implement-review). Not part of the AI-user
497
+ * contract — `wrap*` helpers in this package are the only callers.
498
+ */
499
+ pushExternalEvent(event: RhiCallEvent): void;
500
+ /**
501
+ * @internal
502
+ * Register a shader module object in the recorder's handleMap so
503
+ * downstream pipeline events can look up its handleId via getHandleId.
504
+ */
505
+ registerShaderModule(handle: ShaderModule, handleId: HandleId): void;
506
+ /**
507
+ * @internal
508
+ * Route a create event through registerHandle (alloc id + write bootstrapCreates)
509
+ * and pushEvent in a single call. For standalone wrappers that cannot access
510
+ * the internal registerHandle/pushEvent functions directly.
511
+ * Returns the allocated HandleId so the caller can use it for downstream
512
+ * registration (e.g. shaderModule → handleMap).
513
+ */
514
+ pushExternalCreateEvent(handle: object, kind: string, event: RhiCallEvent): HandleId;
515
+ /**
516
+ * @internal
517
+ * Drop all device-bound recorder state after the host observes a real
518
+ * device loss. A tape recorded against the lost device cannot seed a fresh
519
+ * device, so the next capture must start from the rebuilt resource graph.
520
+ */
521
+ resetForDeviceLoss(): void;
522
+ /** @internal Return whether the current capture is valid. */
523
+ valid(): boolean;
524
+ /**
525
+ * @internal
526
+ * Return the number of entries in bootstrapCreates. Test-only accessor
527
+ * so unit tests can verify bootstrapCreates write/retain semantics
528
+ * without going through getTape() closure computation (M2).
529
+ */
530
+ bootstrapCreatesSize(): number;
531
+ /** @internal Return the create-event identities owned by the bootstrap registry. */
532
+ bootstrapEvents(): readonly RhiCallEvent[];
533
+ /**
534
+ * @internal
535
+ * Read-only view of the descriptor registry keyed by handleId. Test-only
536
+ * accessor so unit tests can verify create* register / destroy* remove
537
+ * semantics (AC-09) without reaching into the closed-over recorder state.
538
+ */
539
+ descriptorTable(): ReadonlyMap<
540
+ HandleId,
541
+ {
542
+ kind: 'buffer' | 'texture';
543
+ size?: number | GPUExtent3DStrict;
544
+ format?: GPUTextureFormat;
545
+ usage: number;
546
+ resource: object;
547
+ }
548
+ >;
549
+ }
550
+
551
+ // ============================================================================
552
+ // Type for standalone createShaderModule function (from rhi-webgpu)
553
+ // ============================================================================
554
+
555
+ export type CreateShaderModuleFn = (
556
+ device: RhiDevice,
557
+ desc: { code: string; label?: string | undefined },
558
+ ) => Promise<Result<ShaderModule, import('@forgeax/engine-rhi').RhiError>>;
559
+
560
+ export type { RecorderInternal, SnapshotProgress };
561
+ export {
562
+ addSwapchainViewFormat,
563
+ allocHandleId,
564
+ ensureTextureCreateEvent,
565
+ fastHash,
566
+ getHandleId,
567
+ hasBootstrapDependency,
568
+ isDepthOrStencilFormat,
569
+ isMappableBuffer,
570
+ isRecordingActive,
571
+ isSnapshottableColorTexture,
572
+ promoteBufferUsage,
573
+ pushEvent,
574
+ pushSnapshotEvent,
575
+ RecorderState,
576
+ reconcileSwapchainViewFormats,
577
+ registerHandle,
578
+ retainsCaptureBootstrap,
579
+ SNAPSHOT_TIMEOUT_MS,
580
+ shouldRecord,
581
+ snapshotStageOf,
582
+ snapshotTimeoutDetail,
583
+ storeBlob,
584
+ TEXTURE_USAGE_BINDING,
585
+ TEXTURE_USAGE_COPY_DST,
586
+ TEXTURE_USAGE_COPY_SRC,
587
+ };