@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.
- package/LICENSE +202 -0
- package/README.md +178 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/bootstrap-integration.test.d.ts +2 -0
- package/dist/__tests__/bootstrap-integration.test.d.ts.map +1 -0
- package/dist/__tests__/consumer-inventory.unit.test.d.ts +2 -0
- package/dist/__tests__/consumer-inventory.unit.test.d.ts.map +1 -0
- package/dist/__tests__/copy-src-usage-validity.dawn.test.d.ts +15 -0
- package/dist/__tests__/copy-src-usage-validity.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/coverage-invariant.test-d.d.ts +2 -0
- package/dist/__tests__/coverage-invariant.test-d.d.ts.map +1 -0
- package/dist/__tests__/coverage-invariant.unit.test.d.ts +2 -0
- package/dist/__tests__/coverage-invariant.unit.test.d.ts.map +1 -0
- package/dist/__tests__/e2e.browser.test.d.ts +2 -0
- package/dist/__tests__/e2e.browser.test.d.ts.map +1 -0
- package/dist/__tests__/error-code-narrowing.test-d.d.ts +2 -0
- package/dist/__tests__/error-code-narrowing.test-d.d.ts.map +1 -0
- package/dist/__tests__/errors-v7.test-d.d.ts +2 -0
- package/dist/__tests__/errors-v7.test-d.d.ts.map +1 -0
- package/dist/__tests__/errors-v7.unit.test.d.ts +2 -0
- package/dist/__tests__/errors-v7.unit.test.d.ts.map +1 -0
- package/dist/__tests__/errors.unit.test.d.ts +2 -0
- package/dist/__tests__/errors.unit.test.d.ts.map +1 -0
- package/dist/__tests__/event-semantics-coverage.unit.test.d.ts +2 -0
- package/dist/__tests__/event-semantics-coverage.unit.test.d.ts.map +1 -0
- package/dist/__tests__/format-info.unit.test.d.ts +2 -0
- package/dist/__tests__/format-info.unit.test.d.ts.map +1 -0
- package/dist/__tests__/frame-model-parity.test-d.d.ts +2 -0
- package/dist/__tests__/frame-model-parity.test-d.d.ts.map +1 -0
- package/dist/__tests__/frame-model-parity.unit.test.d.ts +2 -0
- package/dist/__tests__/frame-model-parity.unit.test.d.ts.map +1 -0
- package/dist/__tests__/guard-gates.test.d.ts +2 -0
- package/dist/__tests__/guard-gates.test.d.ts.map +1 -0
- package/dist/__tests__/public-surface.integration.test.d.ts +2 -0
- package/dist/__tests__/public-surface.integration.test.d.ts.map +1 -0
- package/dist/__tests__/readback-fault-cleanup.unit.test.d.ts +2 -0
- package/dist/__tests__/readback-fault-cleanup.unit.test.d.ts.map +1 -0
- package/dist/__tests__/readback-format-matrix.dawn.test.d.ts +2 -0
- package/dist/__tests__/readback-format-matrix.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/readback-format-matrix.unit.test.d.ts +2 -0
- package/dist/__tests__/readback-format-matrix.unit.test.d.ts.map +1 -0
- package/dist/__tests__/readback-matrix-fixture.d.ts +32 -0
- package/dist/__tests__/readback-matrix-fixture.d.ts.map +1 -0
- package/dist/__tests__/recorder-bootstrap.unit.test.d.ts +2 -0
- package/dist/__tests__/recorder-bootstrap.unit.test.d.ts.map +1 -0
- package/dist/__tests__/recorder-budget.unit.test.d.ts +2 -0
- package/dist/__tests__/recorder-budget.unit.test.d.ts.map +1 -0
- package/dist/__tests__/recorder-fail-fast.unit.test.d.ts +2 -0
- package/dist/__tests__/recorder-fail-fast.unit.test.d.ts.map +1 -0
- package/dist/__tests__/recorder-lifecycle.unit.test.d.ts +2 -0
- package/dist/__tests__/recorder-lifecycle.unit.test.d.ts.map +1 -0
- package/dist/__tests__/recorder-push-event.unit.test.d.ts +2 -0
- package/dist/__tests__/recorder-push-event.unit.test.d.ts.map +1 -0
- package/dist/__tests__/recorder-session.integration.test.d.ts +2 -0
- package/dist/__tests__/recorder-session.integration.test.d.ts.map +1 -0
- package/dist/__tests__/recorder-session.unit.test.d.ts +2 -0
- package/dist/__tests__/recorder-session.unit.test.d.ts.map +1 -0
- package/dist/__tests__/recorder-steady-frame.dawn.test.d.ts +2 -0
- package/dist/__tests__/recorder-steady-frame.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/replay-fail-fast.unit.test.d.ts +2 -0
- package/dist/__tests__/replay-fail-fast.unit.test.d.ts.map +1 -0
- package/dist/__tests__/replay-session.dawn.test.d.ts +2 -0
- package/dist/__tests__/replay-session.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/replay-session.test-d.d.ts +2 -0
- package/dist/__tests__/replay-session.test-d.d.ts.map +1 -0
- package/dist/__tests__/replay-session.unit.test.d.ts +2 -0
- package/dist/__tests__/replay-session.unit.test.d.ts.map +1 -0
- package/dist/__tests__/resolve-texture-descriptor.unit.test.d.ts +2 -0
- package/dist/__tests__/resolve-texture-descriptor.unit.test.d.ts.map +1 -0
- package/dist/__tests__/resource-lifecycle.unit.test.d.ts +2 -0
- package/dist/__tests__/resource-lifecycle.unit.test.d.ts.map +1 -0
- package/dist/__tests__/rhi-debug-fresh-replay.dawn.test.d.ts +2 -0
- package/dist/__tests__/rhi-debug-fresh-replay.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/tape-index.unit.test.d.ts +2 -0
- package/dist/__tests__/tape-index.unit.test.d.ts.map +1 -0
- package/dist/__tests__/tape-v7-invalid.unit.test.d.ts +2 -0
- package/dist/__tests__/tape-v7-invalid.unit.test.d.ts.map +1 -0
- package/dist/__tests__/tape-v7-roundtrip.unit.test.d.ts +2 -0
- package/dist/__tests__/tape-v7-roundtrip.unit.test.d.ts.map +1 -0
- package/dist/__tests__/tape-v7-version.unit.test.d.ts +2 -0
- package/dist/__tests__/tape-v7-version.unit.test.d.ts.map +1 -0
- package/dist/__tests__/tree-shake.unit.test.d.ts +2 -0
- package/dist/__tests__/tree-shake.unit.test.d.ts.map +1 -0
- package/dist/__tests__/types.test.d.ts +2 -0
- package/dist/__tests__/types.test.d.ts.map +1 -0
- package/dist/browser.d.ts +45 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.mjs +83 -0
- package/dist/browser.mjs.map +1 -0
- package/dist/errors.d.ts +85 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/frame-model.d.ts +158 -0
- package/dist/frame-model.d.ts.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +10415 -0
- package/dist/index.mjs.map +1 -0
- package/dist/protocol/codec.d.ts +6 -0
- package/dist/protocol/codec.d.ts.map +1 -0
- package/dist/protocol/event-semantics.d.ts +15 -0
- package/dist/protocol/event-semantics.d.ts.map +1 -0
- package/dist/protocol/tape-index.d.ts +30 -0
- package/dist/protocol/tape-index.d.ts.map +1 -0
- package/dist/protocol/types.d.ts +44 -0
- package/dist/protocol/types.d.ts.map +1 -0
- package/dist/protocol/validation.d.ts +7 -0
- package/dist/protocol/validation.d.ts.map +1 -0
- package/dist/readback.d.ts +156 -0
- package/dist/readback.d.ts.map +1 -0
- package/dist/recorder/assemble.d.ts +11 -0
- package/dist/recorder/assemble.d.ts.map +1 -0
- package/dist/recorder/closure.d.ts +39 -0
- package/dist/recorder/closure.d.ts.map +1 -0
- package/dist/recorder/core.d.ts +296 -0
- package/dist/recorder/core.d.ts.map +1 -0
- package/dist/recorder/device.d.ts +4 -0
- package/dist/recorder/device.d.ts.map +1 -0
- package/dist/recorder/encoder.d.ts +5 -0
- package/dist/recorder/encoder.d.ts.map +1 -0
- package/dist/recorder/lifecycle.d.ts +20 -0
- package/dist/recorder/lifecycle.d.ts.map +1 -0
- package/dist/recorder/pass.d.ts +6 -0
- package/dist/recorder/pass.d.ts.map +1 -0
- package/dist/recorder/proxy.d.ts +19 -0
- package/dist/recorder/proxy.d.ts.map +1 -0
- package/dist/recorder/queue.d.ts +4 -0
- package/dist/recorder/queue.d.ts.map +1 -0
- package/dist/recorder/resource-registry.d.ts +15 -0
- package/dist/recorder/resource-registry.d.ts.map +1 -0
- package/dist/recorder/session.d.ts +25 -0
- package/dist/recorder/session.d.ts.map +1 -0
- package/dist/recorder/shader.d.ts +3 -0
- package/dist/recorder/shader.d.ts.map +1 -0
- package/dist/recorder/snapshot.d.ts +10 -0
- package/dist/recorder/snapshot.d.ts.map +1 -0
- package/dist/recorder/wrap.d.ts +4 -0
- package/dist/recorder/wrap.d.ts.map +1 -0
- package/dist/recorder.d.ts +7 -0
- package/dist/recorder.d.ts.map +1 -0
- package/dist/replay/device-request.d.ts +8 -0
- package/dist/replay/device-request.d.ts.map +1 -0
- package/dist/replay/execute-support.d.ts +33 -0
- package/dist/replay/execute-support.d.ts.map +1 -0
- package/dist/replay/execute.d.ts +18 -0
- package/dist/replay/execute.d.ts.map +1 -0
- package/dist/replay/readback.d.ts +32 -0
- package/dist/replay/readback.d.ts.map +1 -0
- package/dist/replay/resources.d.ts +57 -0
- package/dist/replay/resources.d.ts.map +1 -0
- package/dist/replay/session.d.ts +49 -0
- package/dist/replay/session.d.ts.map +1 -0
- package/dist/replay/texture-format.d.ts +21 -0
- package/dist/replay/texture-format.d.ts.map +1 -0
- package/dist/texel-decode.d.ts +35 -0
- package/dist/texel-decode.d.ts.map +1 -0
- package/dist/texel-layout.d.ts +76 -0
- package/dist/texel-layout.d.ts.map +1 -0
- package/dist/types.d.ts +722 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +72 -0
- package/src/__tests__/bootstrap-integration.test.ts +268 -0
- package/src/__tests__/consumer-inventory.unit.test.ts +154 -0
- package/src/__tests__/copy-src-usage-validity.dawn.test.ts +117 -0
- package/src/__tests__/coverage-invariant.test-d.ts +115 -0
- package/src/__tests__/coverage-invariant.unit.test.ts +45 -0
- package/src/__tests__/e2e.browser.test.ts +239 -0
- package/src/__tests__/error-code-narrowing.test-d.ts +32 -0
- package/src/__tests__/errors-v7.test-d.ts +19 -0
- package/src/__tests__/errors-v7.unit.test.ts +15 -0
- package/src/__tests__/errors.unit.test.ts +83 -0
- package/src/__tests__/event-semantics-coverage.unit.test.ts +22 -0
- package/src/__tests__/format-info.unit.test.ts +85 -0
- package/src/__tests__/frame-model-parity.test-d.ts +20 -0
- package/src/__tests__/frame-model-parity.unit.test.ts +285 -0
- package/src/__tests__/guard-gates.test.ts +317 -0
- package/src/__tests__/public-surface.integration.test.ts +38 -0
- package/src/__tests__/readback-fault-cleanup.unit.test.ts +66 -0
- package/src/__tests__/readback-format-matrix.dawn.test.ts +378 -0
- package/src/__tests__/readback-format-matrix.unit.test.ts +120 -0
- package/src/__tests__/readback-matrix-fixture.ts +20 -0
- package/src/__tests__/recorder-bootstrap.unit.test.ts +302 -0
- package/src/__tests__/recorder-budget.unit.test.ts +42 -0
- package/src/__tests__/recorder-fail-fast.unit.test.ts +434 -0
- package/src/__tests__/recorder-lifecycle.unit.test.ts +42 -0
- package/src/__tests__/recorder-push-event.unit.test.ts +573 -0
- package/src/__tests__/recorder-session.integration.test.ts +32 -0
- package/src/__tests__/recorder-session.unit.test.ts +63 -0
- package/src/__tests__/recorder-steady-frame.dawn.test.ts +52 -0
- package/src/__tests__/replay-fail-fast.unit.test.ts +155 -0
- package/src/__tests__/replay-session.dawn.test.ts +220 -0
- package/src/__tests__/replay-session.test-d.ts +27 -0
- package/src/__tests__/replay-session.unit.test.ts +171 -0
- package/src/__tests__/resolve-texture-descriptor.unit.test.ts +144 -0
- package/src/__tests__/resource-lifecycle.unit.test.ts +114 -0
- package/src/__tests__/rhi-debug-fresh-replay.dawn.test.ts +178 -0
- package/src/__tests__/tape-index.unit.test.ts +56 -0
- package/src/__tests__/tape-v7-invalid.unit.test.ts +45 -0
- package/src/__tests__/tape-v7-roundtrip.unit.test.ts +66 -0
- package/src/__tests__/tape-v7-version.unit.test.ts +13 -0
- package/src/__tests__/tree-shake.unit.test.ts +151 -0
- package/src/__tests__/types.test.ts +257 -0
- package/src/browser.ts +146 -0
- package/src/errors.ts +173 -0
- package/src/frame-model.ts +753 -0
- package/src/index.ts +92 -0
- package/src/pako.d.ts +8 -0
- package/src/protocol/codec.ts +220 -0
- package/src/protocol/event-semantics.ts +180 -0
- package/src/protocol/tape-index.ts +120 -0
- package/src/protocol/types.ts +60 -0
- package/src/protocol/validation.ts +173 -0
- package/src/readback.ts +804 -0
- package/src/recorder/assemble.ts +192 -0
- package/src/recorder/closure.ts +408 -0
- package/src/recorder/core.ts +587 -0
- package/src/recorder/device.ts +474 -0
- package/src/recorder/encoder.ts +251 -0
- package/src/recorder/lifecycle.ts +847 -0
- package/src/recorder/pass.ts +241 -0
- package/src/recorder/proxy.ts +59 -0
- package/src/recorder/queue.ts +109 -0
- package/src/recorder/resource-registry.ts +53 -0
- package/src/recorder/session.ts +152 -0
- package/src/recorder/shader.ts +47 -0
- package/src/recorder/snapshot.ts +38 -0
- package/src/recorder/wrap.ts +157 -0
- package/src/recorder.ts +15 -0
- package/src/replay/device-request.ts +38 -0
- package/src/replay/execute-support.ts +243 -0
- package/src/replay/execute.ts +870 -0
- package/src/replay/readback.ts +671 -0
- package/src/replay/resources.ts +154 -0
- package/src/replay/session.ts +509 -0
- package/src/replay/texture-format.ts +99 -0
- package/src/texel-decode.ts +289 -0
- package/src/texel-layout.ts +324 -0
- package/src/types.ts +888 -0
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
// @forgeax/engine-rhi-debug/src/recorder/lifecycle -- capture state and snapshot lifecycle.
|
|
2
|
+
|
|
3
|
+
/// <reference types="@webgpu/types" />
|
|
4
|
+
|
|
5
|
+
import type { Result, RhiDevice } from '@forgeax/engine-rhi';
|
|
6
|
+
import { err as makeErr, ok as makeOk } from '@forgeax/engine-types';
|
|
7
|
+
import { createRhiDebugError, type RhiDebugError } from '../errors';
|
|
8
|
+
import {
|
|
9
|
+
readbackBufferBytes,
|
|
10
|
+
readbackBufferBytesBatch,
|
|
11
|
+
readbackTexturePixels,
|
|
12
|
+
readbackTexturePixelsBatch,
|
|
13
|
+
} from '../readback';
|
|
14
|
+
import { computeTextureLayout, projectTextureExtent } from '../texel-layout';
|
|
15
|
+
import type { HandleId, RhiCallEvent, Tape } from '../types';
|
|
16
|
+
import {
|
|
17
|
+
_collectFrameReferencedHandleIds,
|
|
18
|
+
_computeClosure,
|
|
19
|
+
_getCreateEventReferencedHandleIds,
|
|
20
|
+
_topoSortClosure,
|
|
21
|
+
} from './closure';
|
|
22
|
+
import {
|
|
23
|
+
isDepthOrStencilFormat,
|
|
24
|
+
isMappableBuffer,
|
|
25
|
+
isSnapshottableColorTexture,
|
|
26
|
+
pushSnapshotEvent,
|
|
27
|
+
type RecorderInternal,
|
|
28
|
+
RecorderState,
|
|
29
|
+
reconcileSwapchainViewFormats,
|
|
30
|
+
SNAPSHOT_RESOURCE_BATCH_SIZE,
|
|
31
|
+
SNAPSHOT_TIMEOUT_MS,
|
|
32
|
+
snapshotStageOf,
|
|
33
|
+
snapshotTimeoutDetail,
|
|
34
|
+
storeBlob,
|
|
35
|
+
TAPE_FORMAT_VERSION,
|
|
36
|
+
} from './core';
|
|
37
|
+
|
|
38
|
+
export function createRecorderLifecycle(s: RecorderInternal) {
|
|
39
|
+
function arm(frames: number): Result<void, RhiDebugError> {
|
|
40
|
+
if (
|
|
41
|
+
s.state === RecorderState.Armed ||
|
|
42
|
+
s.state === RecorderState.Snapshotting ||
|
|
43
|
+
s.state === RecorderState.Recording ||
|
|
44
|
+
s.state === RecorderState.Finalizing
|
|
45
|
+
) {
|
|
46
|
+
return makeErr(
|
|
47
|
+
createRhiDebugError('capture-busy', {
|
|
48
|
+
stage: 'capture',
|
|
49
|
+
cause: 'arm() called while the recorder is already capturing',
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (s.state === RecorderState.Error) {
|
|
54
|
+
return makeErr(
|
|
55
|
+
createRhiDebugError('capture-unavailable', {
|
|
56
|
+
stage: 'capture',
|
|
57
|
+
cause: 'the recorder is in an error state; dispose the failed capture before re-arming',
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
s.state = RecorderState.Armed;
|
|
63
|
+
// A previous bounded snapshot may still be unwinding after its timeout.
|
|
64
|
+
// Its generation fence prevents stale cleanup from touching this capture;
|
|
65
|
+
// reset the suppression latch here so the new capture can record normally.
|
|
66
|
+
s._skipRecord = false;
|
|
67
|
+
s.snapshotGeneration += 1;
|
|
68
|
+
s.requestedFrames = frames;
|
|
69
|
+
s.recordedFrames = 0;
|
|
70
|
+
s.events = [];
|
|
71
|
+
s.blobPool = new Map();
|
|
72
|
+
s.snapshotSeededHandles.clear();
|
|
73
|
+
s.snapshotProgress = undefined;
|
|
74
|
+
s.frameIdx = 0;
|
|
75
|
+
s.bootstrap = true;
|
|
76
|
+
s.valid = true;
|
|
77
|
+
return makeOk(undefined);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function onFrameEnd(): void {
|
|
81
|
+
if (s.state === RecorderState.Idle) {
|
|
82
|
+
s.frameIdx++;
|
|
83
|
+
s.bootstrap = false;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Snapshotting = the async frame-header snapshot loop is mid-flight. Its
|
|
88
|
+
// readbacks await between resources, so the host rAF loop CAN fire
|
|
89
|
+
// onFrameEnd while the loop is still pushing initialData events. If we let
|
|
90
|
+
// that tick advance the state machine (Recording -> frameMark -> Finalizing
|
|
91
|
+
// -> Idle), the still-running snapshot loop's later pushEvent() calls hit
|
|
92
|
+
// the Idle gate and are silently dropped -- the exact race that lost every
|
|
93
|
+
// texture initialData (material default textures all-zero -> black cube).
|
|
94
|
+
// Ignore the tick entirely: snapshotAllLiveResources() sets Recording when
|
|
95
|
+
// it completes, and the NEXT onFrameEnd records the real frame.
|
|
96
|
+
if (s.state === RecorderState.Snapshotting) {
|
|
97
|
+
s.bootstrap = false;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Armed at frame end -> recording. This is the fallback for hosts that
|
|
102
|
+
// never call snapshotAllLiveResources() (the snapshot loop is opt-in at the
|
|
103
|
+
// seam); they record straight from Armed with no frame-header snapshot.
|
|
104
|
+
if (s.state === RecorderState.Armed) {
|
|
105
|
+
s.state = RecorderState.Recording;
|
|
106
|
+
s.bootstrap = false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (s.state === RecorderState.Recording) {
|
|
110
|
+
// Emit frameMark at end of this frame
|
|
111
|
+
s.events.push({ kind: 'frameMark', frameIdx: s.frameIdx });
|
|
112
|
+
s.recordedFrames++;
|
|
113
|
+
s.frameIdx++;
|
|
114
|
+
|
|
115
|
+
if (s.recordedFrames >= s.requestedFrames) {
|
|
116
|
+
s.state = RecorderState.Finalizing;
|
|
117
|
+
if (s.onFrameEndUnsubscribe) {
|
|
118
|
+
s.onFrameEndUnsubscribe();
|
|
119
|
+
s.onFrameEndUnsubscribe = undefined;
|
|
120
|
+
}
|
|
121
|
+
s.state = RecorderState.Idle;
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// finalizing or error: no-op
|
|
127
|
+
s.frameIdx++;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getTape(): Tape | RhiDebugError | undefined {
|
|
131
|
+
if (s.events.length === 0) return undefined;
|
|
132
|
+
|
|
133
|
+
reconcileSwapchainViewFormats(s);
|
|
134
|
+
|
|
135
|
+
// Pre-scan s.events for create* declarations: handleIds whose
|
|
136
|
+
// create event is already carried by the frame events (transient
|
|
137
|
+
// per-frame resources like swapchain textures, command encoders).
|
|
138
|
+
// These handles do NOT need bootstrap prefixing -- they were born
|
|
139
|
+
// during the recorded frame and their create event is in s.events.
|
|
140
|
+
// Collect handleIds that are directly declared by create* events in s.events.
|
|
141
|
+
// Only include the handleId field of the create event itself — NOT backward-refs
|
|
142
|
+
// (layoutHandleId, resourceHandleIds, etc.) from _getCreateEventReferencedHandleIds.
|
|
143
|
+
//
|
|
144
|
+
// Backward-refs from in-frame create events often point to persistent resources
|
|
145
|
+
// (buffers, textures, pipelines) that were created before arm(). Including them
|
|
146
|
+
// in inFrameHandleIds would exclude those resources from bootstrap prefixing,
|
|
147
|
+
// causing tapes to be non-self-contained (missing create* events for early handles).
|
|
148
|
+
//
|
|
149
|
+
// Swapchain textures that have no createTexture event are handled elsewhere:
|
|
150
|
+
// createTextureView (line 1237) detects missing bootstrap entries and constructs
|
|
151
|
+
// faithful createTexture events at capture time, so they are already in both
|
|
152
|
+
// bootstrapCreates and s.events.
|
|
153
|
+
const inFrameHandleIds = new Set<HandleId>();
|
|
154
|
+
for (const e of s.events) {
|
|
155
|
+
// create* events declare handleId (or resultHandleId for createTextureView).
|
|
156
|
+
if (
|
|
157
|
+
e.kind.startsWith('create') &&
|
|
158
|
+
'handleId' in e &&
|
|
159
|
+
typeof (e as { handleId: unknown }).handleId === 'string'
|
|
160
|
+
) {
|
|
161
|
+
inFrameHandleIds.add((e as { handleId: HandleId }).handleId);
|
|
162
|
+
}
|
|
163
|
+
// createTextureView declares the result via resultHandleId.
|
|
164
|
+
if (
|
|
165
|
+
e.kind === 'createTextureView' &&
|
|
166
|
+
'resultHandleId' in e &&
|
|
167
|
+
typeof (e as { resultHandleId: unknown }).resultHandleId === 'string'
|
|
168
|
+
) {
|
|
169
|
+
inFrameHandleIds.add((e as { resultHandleId: HandleId }).resultHandleId);
|
|
170
|
+
}
|
|
171
|
+
// createCommandEncoder declares the cmd via cmdHandleId.
|
|
172
|
+
if (
|
|
173
|
+
e.kind === 'createCommandEncoder' &&
|
|
174
|
+
'cmdHandleId' in e &&
|
|
175
|
+
typeof (e as { cmdHandleId: unknown }).cmdHandleId === 'string'
|
|
176
|
+
) {
|
|
177
|
+
inFrameHandleIds.add((e as { cmdHandleId: HandleId }).cmdHandleId);
|
|
178
|
+
}
|
|
179
|
+
// beginRenderPass / beginComputePass declare passHandleId.
|
|
180
|
+
if (
|
|
181
|
+
(e.kind === 'beginRenderPass' || e.kind === 'beginComputePass') &&
|
|
182
|
+
'passHandleId' in e &&
|
|
183
|
+
typeof (e as { passHandleId: unknown }).passHandleId === 'string'
|
|
184
|
+
) {
|
|
185
|
+
inFrameHandleIds.add((e as { passHandleId: HandleId }).passHandleId);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Collect frame-referenced handleIds from per-frame events.
|
|
190
|
+
const allFrameHandleIds = _collectFrameReferencedHandleIds(s.events);
|
|
191
|
+
|
|
192
|
+
// Exclude handles whose create event is already in s.events:
|
|
193
|
+
// only compute bootstrap closure for handles that need prefixing.
|
|
194
|
+
const prefixSeedIds = new Set<HandleId>();
|
|
195
|
+
for (const hId of allFrameHandleIds) {
|
|
196
|
+
if (!inFrameHandleIds.has(hId)) {
|
|
197
|
+
prefixSeedIds.add(hId);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Transitive closure from bootstrapCreates
|
|
202
|
+
const { closure, missing } = _computeClosure(
|
|
203
|
+
prefixSeedIds,
|
|
204
|
+
s.bootstrapCreates,
|
|
205
|
+
inFrameHandleIds,
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
if (missing !== null) {
|
|
209
|
+
// Missing create — return error (hint refined in w9)
|
|
210
|
+
const referencingEventIndex = s.events.findIndex((event) => {
|
|
211
|
+
try {
|
|
212
|
+
return JSON.stringify(event).includes(missing);
|
|
213
|
+
} catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
const referencingEventKind =
|
|
218
|
+
referencingEventIndex >= 0 ? s.events[referencingEventIndex]?.kind : undefined;
|
|
219
|
+
const referencingCreate = [...s.bootstrapCreates.entries()].find(([, event]) =>
|
|
220
|
+
_getCreateEventReferencedHandleIds(event).includes(missing),
|
|
221
|
+
);
|
|
222
|
+
return createRhiDebugError('tape-invalid', {
|
|
223
|
+
stage: 'validate',
|
|
224
|
+
cause: `handleId '${missing}' has no create event in bootstrap table; referenced by event ${referencingEventIndex} (${referencingEventKind ?? 'unknown'}) and bootstrap ${referencingCreate?.[0] ?? 'unknown'} (${referencingCreate?.[1].kind ?? 'unknown'})`,
|
|
225
|
+
handleId: missing,
|
|
226
|
+
eventIndex: referencingEventIndex,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Topological sort: dependencies (leaf resources) before dependents
|
|
231
|
+
const prefixEvents: RhiCallEvent[] = _topoSortClosure(closure, s.bootstrapCreates);
|
|
232
|
+
|
|
233
|
+
// dedup: only prefix create events not already in s.events.
|
|
234
|
+
const dedupedPrefx = prefixEvents.filter((e) => {
|
|
235
|
+
if ('handleId' in e && typeof (e as { handleId: unknown }).handleId === 'string') {
|
|
236
|
+
return !inFrameHandleIds.has((e as { handleId: HandleId }).handleId);
|
|
237
|
+
}
|
|
238
|
+
return true;
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
formatVersion: TAPE_FORMAT_VERSION,
|
|
243
|
+
rhiCapsRecorded: s.recordedCaps ?? {
|
|
244
|
+
canvasFormat: 'bgra8unorm' as GPUTextureFormat,
|
|
245
|
+
rgba16floatRenderable: false,
|
|
246
|
+
float32Filterable: false,
|
|
247
|
+
textureCompressionBc: false,
|
|
248
|
+
textureCompressionEtc2: false,
|
|
249
|
+
textureCompressionAstc: false,
|
|
250
|
+
storageBuffer: false,
|
|
251
|
+
timestampQuery: false,
|
|
252
|
+
},
|
|
253
|
+
events: [...dedupedPrefx, ...s.events],
|
|
254
|
+
blobPool: s.blobPool,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function getState(): string {
|
|
259
|
+
return s.state;
|
|
260
|
+
}
|
|
261
|
+
function getEvents(): readonly RhiCallEvent[] {
|
|
262
|
+
return s.events;
|
|
263
|
+
}
|
|
264
|
+
function getBlobPool(): ReadonlyMap<string, ArrayBuffer> {
|
|
265
|
+
return s.blobPool;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function transitionToError(): void {
|
|
269
|
+
if (
|
|
270
|
+
s.state === RecorderState.Recording ||
|
|
271
|
+
s.state === RecorderState.Armed ||
|
|
272
|
+
s.state === RecorderState.Snapshotting
|
|
273
|
+
) {
|
|
274
|
+
s.state = RecorderState.Error;
|
|
275
|
+
s.snapshotGeneration += 1;
|
|
276
|
+
s._skipRecord = false;
|
|
277
|
+
s.valid = false;
|
|
278
|
+
if (s.onFrameEndUnsubscribe) {
|
|
279
|
+
s.onFrameEndUnsubscribe();
|
|
280
|
+
s.onFrameEndUnsubscribe = undefined;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function disposeError(): void {
|
|
286
|
+
if (s.state === RecorderState.Error) {
|
|
287
|
+
s.state = RecorderState.Idle;
|
|
288
|
+
s.snapshotGeneration += 1;
|
|
289
|
+
s._skipRecord = false;
|
|
290
|
+
s.events = [];
|
|
291
|
+
s.blobPool = new Map();
|
|
292
|
+
s.valid = true;
|
|
293
|
+
s.snapshotProgress = undefined;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Snapshot a resource's GPU bytes into the tape as an initialData event.
|
|
299
|
+
*
|
|
300
|
+
* Reads the resource shape from the descriptor registry, copies the bytes
|
|
301
|
+
* back from the GPU via readbackBufferBytes (buffer) / readbackTexturePixels
|
|
302
|
+
* (texture), stores them in the blobPool (djb2 hash-dedup), and pushes an
|
|
303
|
+
* `initialData` event into the stream. The snapshot's own copy/submit are
|
|
304
|
+
* wrapped in `_skipRecord = true` so they never leak into the tape event
|
|
305
|
+
* stream (D-8 isolation).
|
|
306
|
+
*
|
|
307
|
+
* Async because the GPU readback chain (copyToBuffer -> submit ->
|
|
308
|
+
* onSubmittedWorkDone -> mapAsync) is inherently asynchronous; the M1 stub
|
|
309
|
+
* locked a sync signature, but no caller existed yet — the frame-header loop
|
|
310
|
+
* added here is the first consumer (Change stance: optimal > compatible).
|
|
311
|
+
*
|
|
312
|
+
* Returns Result with {handleId, dataHash} on success, or
|
|
313
|
+
* capture-snapshot-failed (with structured snapshot detail) on a
|
|
314
|
+
* failure, so AI users can switch-exhaustive narrow the code (D-3).
|
|
315
|
+
*/
|
|
316
|
+
async function snapshotResource(
|
|
317
|
+
handleId: HandleId,
|
|
318
|
+
snapshotGeneration?: number,
|
|
319
|
+
): Promise<Result<{ handleId: HandleId; dataHash: string }, RhiDebugError>> {
|
|
320
|
+
type SnapshotResult = Result<{ handleId: HandleId; dataHash: string }, RhiDebugError>;
|
|
321
|
+
const fail = (
|
|
322
|
+
stage: 'copy' | 'map' | 'store',
|
|
323
|
+
_expected: string,
|
|
324
|
+
hint: string,
|
|
325
|
+
): SnapshotResult =>
|
|
326
|
+
makeErr(
|
|
327
|
+
createRhiDebugError('capture-snapshot-failed', {
|
|
328
|
+
stage: 'snapshot',
|
|
329
|
+
cause: `${stage}: ${hint}`,
|
|
330
|
+
handleId,
|
|
331
|
+
}),
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const entry = s.descriptorTable.get(handleId);
|
|
335
|
+
if (entry === undefined) {
|
|
336
|
+
return fail(
|
|
337
|
+
'copy',
|
|
338
|
+
'handleId present in descriptor registry',
|
|
339
|
+
`no live resource registered for handleId '${handleId}'; it may have been destroyed or never created through the recorder proxy`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const device = s.capturedDevice;
|
|
344
|
+
if (device === undefined) {
|
|
345
|
+
return fail(
|
|
346
|
+
'copy',
|
|
347
|
+
'a captured RhiDevice to drive GPU readback',
|
|
348
|
+
'no device has been acquired through the recorder proxy yet; drive requestAdapter().requestDevice() before snapshotting',
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Resolve the unwrapped real device — readback issues copy/submit/mapAsync
|
|
353
|
+
// through it. The proxy device would re-record those calls were it not for
|
|
354
|
+
// the _skipRecord guard below; using the real device sidesteps the proxy
|
|
355
|
+
// entirely for the readback staging buffer.
|
|
356
|
+
const realDevice = (device as RhiDevice & { _realDevice?: RhiDevice })._realDevice ?? device;
|
|
357
|
+
const snapshotIsActive = () =>
|
|
358
|
+
snapshotGeneration === undefined ||
|
|
359
|
+
(s.state === RecorderState.Snapshotting && s.snapshotGeneration === snapshotGeneration);
|
|
360
|
+
const cancelled = () =>
|
|
361
|
+
makeErr(
|
|
362
|
+
createRhiDebugError('capture-snapshot-failed', {
|
|
363
|
+
stage: 'snapshot',
|
|
364
|
+
cause:
|
|
365
|
+
'snapshot was cancelled after a timeout or recorder error; discard this capture and retry',
|
|
366
|
+
handleId,
|
|
367
|
+
}),
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
let bytes: ArrayBuffer;
|
|
371
|
+
const prevSkip = s._skipRecord;
|
|
372
|
+
s._skipRecord = true;
|
|
373
|
+
try {
|
|
374
|
+
if (entry.kind === 'buffer') {
|
|
375
|
+
const size = typeof entry.size === 'number' ? entry.size : 0;
|
|
376
|
+
const res = await readbackBufferBytes(realDevice, entry.resource, size);
|
|
377
|
+
if (!res.ok) return fail(snapshotStageOf(res.error), res.error.expected, res.error.hint);
|
|
378
|
+
if (!snapshotIsActive()) return cancelled();
|
|
379
|
+
bytes = res.value;
|
|
380
|
+
} else {
|
|
381
|
+
const { width, height, layerCount } = projectTextureExtent(entry.size);
|
|
382
|
+
const layout = computeTextureLayout(
|
|
383
|
+
entry.format,
|
|
384
|
+
width,
|
|
385
|
+
height,
|
|
386
|
+
layerCount,
|
|
387
|
+
entry.mipLevelCount ?? 1,
|
|
388
|
+
);
|
|
389
|
+
if (layout === undefined) {
|
|
390
|
+
// Should not happen: the snapshot loop's isSnapshottableColorTexture
|
|
391
|
+
// gate already excludes formats with no texel size. Fail fast rather
|
|
392
|
+
// than emit a corrupt seed.
|
|
393
|
+
return fail(
|
|
394
|
+
'copy',
|
|
395
|
+
'a snapshottable color format with a known texel size',
|
|
396
|
+
`format '${entry.format}' has no byte layout; the snapshot gate should have skipped it`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
// Read every (layer, mip) subresource and concatenate tight into one
|
|
401
|
+
// blob in the canonical order computeTextureLayout defines; the seed
|
|
402
|
+
// side walks the same layout to writeTexture each slice back.
|
|
403
|
+
const blob = new Uint8Array(layout.totalBytes);
|
|
404
|
+
for (const slice of layout.slices) {
|
|
405
|
+
if (!snapshotIsActive()) return cancelled();
|
|
406
|
+
const sub = await readbackTexturePixels(
|
|
407
|
+
realDevice,
|
|
408
|
+
entry.resource,
|
|
409
|
+
slice.width,
|
|
410
|
+
slice.height,
|
|
411
|
+
{
|
|
412
|
+
bytesPerBlock: layout.bytesPerBlock,
|
|
413
|
+
blockWidth: layout.blockWidth,
|
|
414
|
+
blockHeight: layout.blockHeight,
|
|
415
|
+
mipLevel: slice.mip,
|
|
416
|
+
baseArrayLayer: slice.layer,
|
|
417
|
+
},
|
|
418
|
+
);
|
|
419
|
+
if (!snapshotIsActive()) return cancelled();
|
|
420
|
+
blob.set(sub.subarray(0, slice.byteLength), slice.byteOffset);
|
|
421
|
+
}
|
|
422
|
+
bytes = blob.buffer.slice(
|
|
423
|
+
blob.byteOffset,
|
|
424
|
+
blob.byteOffset + blob.byteLength,
|
|
425
|
+
) as ArrayBuffer;
|
|
426
|
+
} catch (e) {
|
|
427
|
+
return fail(
|
|
428
|
+
'copy',
|
|
429
|
+
'texture GPU byte readback to succeed',
|
|
430
|
+
`readbackTexturePixels failed: ${String(e)}`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
} finally {
|
|
435
|
+
// A retry may begin before a timed-out readback promise settles. Do not
|
|
436
|
+
// let the stale generation restore its old suppression bit over the new
|
|
437
|
+
// capture's active snapshot; only the generation that acquired it may
|
|
438
|
+
// release it.
|
|
439
|
+
if (snapshotGeneration === undefined || s.snapshotGeneration === snapshotGeneration) {
|
|
440
|
+
s._skipRecord = prevSkip;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (!snapshotIsActive()) return cancelled();
|
|
445
|
+
|
|
446
|
+
// storeBlob: djb2 hash-dedup into the unified blobPool (D-1, no separate
|
|
447
|
+
// init-data pool). Reuses the same tag space as writeBuffer/writeTexture.
|
|
448
|
+
let dataHash: string;
|
|
449
|
+
try {
|
|
450
|
+
dataHash = storeBlob(s, bytes);
|
|
451
|
+
} catch (e) {
|
|
452
|
+
return fail(
|
|
453
|
+
'store',
|
|
454
|
+
'storeBlob to hash + insert the snapshot bytes',
|
|
455
|
+
`storeBlob failed: ${String(e)}`,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
pushSnapshotEvent(s, { kind: 'initialData', handleId, dataHash });
|
|
460
|
+
s.snapshotSeededHandles.add(handleId);
|
|
461
|
+
return makeOk({ handleId, dataHash });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Frame-header snapshot loop (D-5, C-3, C-4): with the recorder in the
|
|
466
|
+
* Snapshotting middle state, await all submitted work, then snapshot every
|
|
467
|
+
* live resource in the descriptor registry (full-table dump, no size
|
|
468
|
+
* threshold / allowlist trimming — that for-loop is the single Phase 2
|
|
469
|
+
* policy seam, OOS-4 / OOS-5). On full success the recorder advances to
|
|
470
|
+
* Recording; a single snapshot failure aborts with its Result so the
|
|
471
|
+
* caller fails fast (architecture §5) rather than recording a partial seed.
|
|
472
|
+
*/
|
|
473
|
+
async function snapshotAllLiveResources(
|
|
474
|
+
timeoutMs = SNAPSHOT_TIMEOUT_MS,
|
|
475
|
+
): Promise<Result<void, RhiDebugError>> {
|
|
476
|
+
if (s.state !== RecorderState.Armed && s.state !== RecorderState.Snapshotting) {
|
|
477
|
+
return makeErr(
|
|
478
|
+
createRhiDebugError('capture-unavailable', {
|
|
479
|
+
stage: 'capture',
|
|
480
|
+
cause: `snapshotAllLiveResources called while recorder is in '${s.state}' state; arm() before the frame-header snapshot`,
|
|
481
|
+
}),
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
const snapshotGeneration = s.snapshotGeneration;
|
|
485
|
+
s.state = RecorderState.Snapshotting;
|
|
486
|
+
|
|
487
|
+
const timeoutError = () =>
|
|
488
|
+
createRhiDebugError('capture-timeout', snapshotTimeoutDetail(s.snapshotProgress, timeoutMs));
|
|
489
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
490
|
+
const timeoutResult = new Promise<Result<void, RhiDebugError>>((resolve) => {
|
|
491
|
+
timer = setTimeout(() => {
|
|
492
|
+
// The caller may have timed out first, disposed the failed capture,
|
|
493
|
+
// and already started a new generation. A late timer from that stale
|
|
494
|
+
// snapshot must not poison the retry that now owns the recorder.
|
|
495
|
+
if (s.state === RecorderState.Snapshotting && s.snapshotGeneration === snapshotGeneration) {
|
|
496
|
+
transitionToError();
|
|
497
|
+
}
|
|
498
|
+
resolve(makeErr(timeoutError()));
|
|
499
|
+
}, timeoutMs);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
try {
|
|
503
|
+
const result = await Promise.race([
|
|
504
|
+
runSnapshotAllLiveResources(snapshotGeneration),
|
|
505
|
+
timeoutResult,
|
|
506
|
+
]);
|
|
507
|
+
if (
|
|
508
|
+
!result.ok &&
|
|
509
|
+
s.state === RecorderState.Snapshotting &&
|
|
510
|
+
s.snapshotGeneration === snapshotGeneration
|
|
511
|
+
) {
|
|
512
|
+
transitionToError();
|
|
513
|
+
}
|
|
514
|
+
return result;
|
|
515
|
+
} catch (error) {
|
|
516
|
+
if (s.state === RecorderState.Snapshotting && s.snapshotGeneration === snapshotGeneration) {
|
|
517
|
+
transitionToError();
|
|
518
|
+
}
|
|
519
|
+
throw error;
|
|
520
|
+
} finally {
|
|
521
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function runSnapshotAllLiveResources(
|
|
526
|
+
snapshotGeneration: number,
|
|
527
|
+
): Promise<Result<void, RhiDebugError>> {
|
|
528
|
+
s.snapshotProgress = {
|
|
529
|
+
startedAt: Date.now(),
|
|
530
|
+
stage: 'queue-drain',
|
|
531
|
+
totalResources: s.descriptorTable.size,
|
|
532
|
+
completedResources: 0,
|
|
533
|
+
skippedResources: 0,
|
|
534
|
+
currentHandleId: null,
|
|
535
|
+
currentKind: null,
|
|
536
|
+
currentSizeBytes: null,
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
// C-3 conservative timing: drain queued work so snapshots read frame-outside
|
|
540
|
+
// / historical content, never a half-written in-frame value (A-2).
|
|
541
|
+
const device = s.capturedDevice;
|
|
542
|
+
const realDevice =
|
|
543
|
+
device === undefined
|
|
544
|
+
? undefined
|
|
545
|
+
: ((device as RhiDevice & { _realDevice?: RhiDevice })._realDevice ?? device);
|
|
546
|
+
if (realDevice !== undefined) {
|
|
547
|
+
const prevSkip = s._skipRecord;
|
|
548
|
+
s._skipRecord = true;
|
|
549
|
+
try {
|
|
550
|
+
await realDevice.queue.onSubmittedWorkDone();
|
|
551
|
+
} finally {
|
|
552
|
+
if (s.snapshotGeneration === snapshotGeneration) s._skipRecord = prevSkip;
|
|
553
|
+
}
|
|
554
|
+
if (s.snapshotProgress !== undefined) {
|
|
555
|
+
s.snapshotProgress = { ...s.snapshotProgress, stage: 'resource-readback' };
|
|
556
|
+
}
|
|
557
|
+
if (s.state !== RecorderState.Snapshotting || s.snapshotGeneration !== snapshotGeneration) {
|
|
558
|
+
return makeErr(
|
|
559
|
+
createRhiDebugError('capture-snapshot-failed', {
|
|
560
|
+
stage: 'snapshot',
|
|
561
|
+
cause: 'snapshot was cancelled while queued GPU work was draining',
|
|
562
|
+
}),
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const liveEntries = [...s.descriptorTable.entries()];
|
|
568
|
+
const candidates = liveEntries.filter(([handleId, entry]) => {
|
|
569
|
+
// Mappable buffers are staging scratch, not seedable authored resources.
|
|
570
|
+
if (entry.kind === 'buffer' && isMappableBuffer(entry.usage)) {
|
|
571
|
+
if (s.snapshotProgress !== undefined) {
|
|
572
|
+
s.snapshotProgress = {
|
|
573
|
+
...s.snapshotProgress,
|
|
574
|
+
skippedResources: s.snapshotProgress.skippedResources + 1,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
// Depth/stencil and multisample textures cannot be faithfully re-seeded.
|
|
580
|
+
if (
|
|
581
|
+
entry.kind === 'texture' &&
|
|
582
|
+
(isDepthOrStencilFormat(entry.format) ||
|
|
583
|
+
!isSnapshottableColorTexture(entry.format, entry.size, entry.sampleCount))
|
|
584
|
+
) {
|
|
585
|
+
if (s.snapshotProgress !== undefined) {
|
|
586
|
+
s.snapshotProgress = {
|
|
587
|
+
...s.snapshotProgress,
|
|
588
|
+
skippedResources: s.snapshotProgress.skippedResources + 1,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
// Preserve the existing transient-texture policy: a texture destroyed
|
|
594
|
+
// before its turn is skipped, while a resource already admitted to a
|
|
595
|
+
// batch retains its seed even if it is released during that batch.
|
|
596
|
+
if (entry.kind === 'texture' && !s.descriptorTable.has(handleId)) {
|
|
597
|
+
if (s.snapshotProgress !== undefined) {
|
|
598
|
+
s.snapshotProgress = {
|
|
599
|
+
...s.snapshotProgress,
|
|
600
|
+
skippedResources: s.snapshotProgress.skippedResources + 1,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
return true;
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
const snapshotIsActive = () =>
|
|
609
|
+
s.state === RecorderState.Snapshotting && s.snapshotGeneration === snapshotGeneration;
|
|
610
|
+
const cancelledResult = () =>
|
|
611
|
+
makeErr(
|
|
612
|
+
createRhiDebugError('capture-snapshot-failed', {
|
|
613
|
+
stage: 'snapshot',
|
|
614
|
+
cause: 'snapshot was cancelled while live resources were being seeded',
|
|
615
|
+
}),
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
// No captured device is an existing error path for snapshotResource. Keep
|
|
619
|
+
// it serial and deterministic rather than manufacturing a batch surface.
|
|
620
|
+
if (realDevice === undefined) {
|
|
621
|
+
for (const [handleId, entry] of candidates) {
|
|
622
|
+
if (s.snapshotProgress !== undefined) {
|
|
623
|
+
s.snapshotProgress = {
|
|
624
|
+
...s.snapshotProgress,
|
|
625
|
+
stage: 'resource-readback',
|
|
626
|
+
currentHandleId: handleId,
|
|
627
|
+
currentKind: entry.kind,
|
|
628
|
+
currentSizeBytes:
|
|
629
|
+
entry.kind === 'buffer' && typeof entry.size === 'number' ? entry.size : null,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
const result = await snapshotResource(handleId, snapshotGeneration);
|
|
633
|
+
if (!result.ok) return result;
|
|
634
|
+
if (s.snapshotProgress !== undefined) {
|
|
635
|
+
s.snapshotProgress = {
|
|
636
|
+
...s.snapshotProgress,
|
|
637
|
+
completedResources: s.snapshotProgress.completedResources + 1,
|
|
638
|
+
currentHandleId: null,
|
|
639
|
+
currentKind: null,
|
|
640
|
+
currentSizeBytes: null,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
} else {
|
|
645
|
+
let offset = 0;
|
|
646
|
+
while (offset < candidates.length) {
|
|
647
|
+
const first = candidates[offset];
|
|
648
|
+
if (first === undefined) break;
|
|
649
|
+
const kind = first[1].kind;
|
|
650
|
+
const batchEntries: typeof candidates = [];
|
|
651
|
+
while (
|
|
652
|
+
offset < candidates.length &&
|
|
653
|
+
batchEntries.length < SNAPSHOT_RESOURCE_BATCH_SIZE &&
|
|
654
|
+
candidates[offset]?.[1].kind === kind
|
|
655
|
+
) {
|
|
656
|
+
const candidate = candidates[offset];
|
|
657
|
+
if (candidate !== undefined) batchEntries.push(candidate);
|
|
658
|
+
offset += 1;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (s.snapshotProgress !== undefined) {
|
|
662
|
+
s.snapshotProgress = {
|
|
663
|
+
...s.snapshotProgress,
|
|
664
|
+
stage: 'resource-readback',
|
|
665
|
+
currentHandleId: batchEntries[0]?.[0] ?? null,
|
|
666
|
+
currentKind: kind,
|
|
667
|
+
currentSizeBytes:
|
|
668
|
+
kind === 'buffer' && typeof batchEntries[0]?.[1].size === 'number'
|
|
669
|
+
? batchEntries[0][1].size
|
|
670
|
+
: null,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (kind === 'buffer') {
|
|
675
|
+
const batch = await readbackBufferBytesBatch(
|
|
676
|
+
realDevice,
|
|
677
|
+
batchEntries.map(([handleId, entry]) => ({
|
|
678
|
+
handleId,
|
|
679
|
+
buffer: entry.resource,
|
|
680
|
+
size: typeof entry.size === 'number' ? entry.size : 0,
|
|
681
|
+
})),
|
|
682
|
+
{
|
|
683
|
+
onResourceStart: (handleId) => {
|
|
684
|
+
const entry = s.descriptorTable.get(handleId);
|
|
685
|
+
if (s.snapshotProgress !== undefined && entry !== undefined) {
|
|
686
|
+
s.snapshotProgress = {
|
|
687
|
+
...s.snapshotProgress,
|
|
688
|
+
currentHandleId: handleId,
|
|
689
|
+
currentKind: 'buffer',
|
|
690
|
+
currentSizeBytes: typeof entry.size === 'number' ? entry.size : null,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
},
|
|
694
|
+
onResourceComplete: () => {
|
|
695
|
+
if (s.snapshotProgress !== undefined) {
|
|
696
|
+
s.snapshotProgress = {
|
|
697
|
+
...s.snapshotProgress,
|
|
698
|
+
completedResources: s.snapshotProgress.completedResources + 1,
|
|
699
|
+
currentHandleId: null,
|
|
700
|
+
currentKind: null,
|
|
701
|
+
currentSizeBytes: null,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
},
|
|
705
|
+
isCancelled: () => !snapshotIsActive(),
|
|
706
|
+
},
|
|
707
|
+
);
|
|
708
|
+
if (!batch.ok) return batch;
|
|
709
|
+
if (!snapshotIsActive()) return cancelledResult();
|
|
710
|
+
for (const [handleId] of batchEntries) {
|
|
711
|
+
const bytes = batch.value.get(handleId);
|
|
712
|
+
if (bytes === undefined) {
|
|
713
|
+
return makeErr(
|
|
714
|
+
createRhiDebugError('capture-snapshot-failed', {
|
|
715
|
+
stage: 'snapshot',
|
|
716
|
+
cause: 'the batched GPU readback returned no bytes for a live buffer',
|
|
717
|
+
handleId,
|
|
718
|
+
resourceKind: 'buffer',
|
|
719
|
+
}),
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
const dataHash = storeBlob(s, bytes);
|
|
723
|
+
pushSnapshotEvent(s, { kind: 'initialData', handleId, dataHash });
|
|
724
|
+
s.snapshotSeededHandles.add(handleId);
|
|
725
|
+
}
|
|
726
|
+
} else {
|
|
727
|
+
const requests = batchEntries.map(([handleId, entry]) => {
|
|
728
|
+
const { width, height, layerCount } = projectTextureExtent(entry.size);
|
|
729
|
+
const layout = computeTextureLayout(
|
|
730
|
+
entry.format,
|
|
731
|
+
width,
|
|
732
|
+
height,
|
|
733
|
+
layerCount,
|
|
734
|
+
entry.mipLevelCount ?? 1,
|
|
735
|
+
);
|
|
736
|
+
if (layout === undefined) {
|
|
737
|
+
throw new Error(`texture '${handleId}' has no snapshottable byte layout`);
|
|
738
|
+
}
|
|
739
|
+
return {
|
|
740
|
+
handleId,
|
|
741
|
+
texture: entry.resource,
|
|
742
|
+
bytesPerBlock: layout.bytesPerBlock,
|
|
743
|
+
blockWidth: layout.blockWidth,
|
|
744
|
+
blockHeight: layout.blockHeight,
|
|
745
|
+
totalBytes: layout.totalBytes,
|
|
746
|
+
slices: layout.slices,
|
|
747
|
+
};
|
|
748
|
+
});
|
|
749
|
+
const batch = await readbackTexturePixelsBatch(realDevice, requests, {
|
|
750
|
+
onResourceStart: (handleId) => {
|
|
751
|
+
if (s.snapshotProgress !== undefined) {
|
|
752
|
+
s.snapshotProgress = {
|
|
753
|
+
...s.snapshotProgress,
|
|
754
|
+
currentHandleId: handleId,
|
|
755
|
+
currentKind: 'texture',
|
|
756
|
+
currentSizeBytes: null,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
},
|
|
760
|
+
isCancelled: () => !snapshotIsActive(),
|
|
761
|
+
});
|
|
762
|
+
if (!batch.ok) return batch;
|
|
763
|
+
if (!snapshotIsActive()) return cancelledResult();
|
|
764
|
+
for (const [handleId] of batchEntries) {
|
|
765
|
+
// A transient texture can be released while its batch is waiting
|
|
766
|
+
// on the GPU. The copy was intentionally isolated and cleaned up,
|
|
767
|
+
// but the released handle must not become a seed for the next
|
|
768
|
+
// frame. Buffers retain the historical batch behavior above.
|
|
769
|
+
if (!s.descriptorTable.has(handleId)) {
|
|
770
|
+
if (s.snapshotProgress !== undefined) {
|
|
771
|
+
s.snapshotProgress = {
|
|
772
|
+
...s.snapshotProgress,
|
|
773
|
+
skippedResources: s.snapshotProgress.skippedResources + 1,
|
|
774
|
+
currentHandleId: null,
|
|
775
|
+
currentKind: null,
|
|
776
|
+
currentSizeBytes: null,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
const bytes = batch.value.get(handleId);
|
|
782
|
+
if (bytes === undefined) {
|
|
783
|
+
return makeErr(
|
|
784
|
+
createRhiDebugError('capture-snapshot-failed', {
|
|
785
|
+
stage: 'snapshot',
|
|
786
|
+
cause: 'the batched GPU readback returned no bytes for a live texture',
|
|
787
|
+
handleId,
|
|
788
|
+
resourceKind: 'texture',
|
|
789
|
+
}),
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
const dataHash = storeBlob(s, bytes);
|
|
793
|
+
pushSnapshotEvent(s, { kind: 'initialData', handleId, dataHash });
|
|
794
|
+
s.snapshotSeededHandles.add(handleId);
|
|
795
|
+
if (s.snapshotProgress !== undefined) {
|
|
796
|
+
s.snapshotProgress = {
|
|
797
|
+
...s.snapshotProgress,
|
|
798
|
+
completedResources: s.snapshotProgress.completedResources + 1,
|
|
799
|
+
currentHandleId: null,
|
|
800
|
+
currentKind: null,
|
|
801
|
+
currentSizeBytes: null,
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
if (!snapshotIsActive()) return cancelledResult();
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (s.snapshotProgress !== undefined) {
|
|
811
|
+
s.snapshotProgress = {
|
|
812
|
+
...s.snapshotProgress,
|
|
813
|
+
currentHandleId: null,
|
|
814
|
+
currentKind: null,
|
|
815
|
+
currentSizeBytes: null,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (s.state !== RecorderState.Snapshotting || s.snapshotGeneration !== snapshotGeneration) {
|
|
820
|
+
return makeErr(
|
|
821
|
+
createRhiDebugError('capture-snapshot-failed', {
|
|
822
|
+
stage: 'snapshot',
|
|
823
|
+
cause: 'snapshot was cancelled before the full live-resource table was seeded',
|
|
824
|
+
}),
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
s.state = RecorderState.Recording;
|
|
828
|
+
return makeOk(undefined);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// --------------------------------------------------
|
|
832
|
+
// proxy construction
|
|
833
|
+
// --------------------------------------------------
|
|
834
|
+
|
|
835
|
+
return {
|
|
836
|
+
arm,
|
|
837
|
+
onFrameEnd,
|
|
838
|
+
getTape,
|
|
839
|
+
getState,
|
|
840
|
+
getEvents,
|
|
841
|
+
getBlobPool,
|
|
842
|
+
transitionToError,
|
|
843
|
+
disposeError,
|
|
844
|
+
snapshotResource,
|
|
845
|
+
snapshotAllLiveResources,
|
|
846
|
+
};
|
|
847
|
+
}
|