@forgeax/engine-rhi-debug 0.1.6 → 0.1.7
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/README.md +39 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/__tests__/readback-matrix-fixture.d.ts +32 -0
- package/dist/__tests__/readback-matrix-fixture.d.ts.map +1 -0
- package/dist/frame-model.d.ts +84 -52
- package/dist/frame-model.d.ts.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +395 -40
- package/dist/index.mjs.map +1 -1
- package/dist/replay/device-request.d.ts +8 -0
- package/dist/replay/device-request.d.ts.map +1 -0
- package/dist/replay/readback.d.ts +7 -0
- package/dist/replay/readback.d.ts.map +1 -1
- package/dist/replay/session.d.ts +17 -0
- package/dist/replay/session.d.ts.map +1 -1
- package/dist/texel-decode.d.ts +5 -4
- package/dist/texel-decode.d.ts.map +1 -1
- package/package.json +7 -7
- package/src/__tests__/consumer-inventory.unit.test.ts +49 -1
- package/src/__tests__/e2e.browser.test.ts +6 -1
- package/src/__tests__/frame-model-parity.test-d.ts +13 -1
- package/src/__tests__/frame-model-parity.unit.test.ts +262 -15
- package/src/__tests__/guard-gates.test.ts +176 -1
- package/src/__tests__/public-surface.integration.test.ts +8 -0
- package/src/__tests__/readback-format-matrix.dawn.test.ts +226 -3
- package/src/__tests__/readback-format-matrix.unit.test.ts +25 -1
- package/src/__tests__/readback-matrix-fixture.ts +20 -0
- package/src/__tests__/replay-fail-fast.unit.test.ts +12 -0
- package/src/__tests__/replay-session.dawn.test.ts +3 -0
- package/src/__tests__/replay-session.test-d.ts +7 -1
- package/src/__tests__/replay-session.unit.test.ts +16 -0
- package/src/__tests__/rhi-debug-fresh-replay.dawn.test.ts +2 -0
- package/src/__tests__/tape-index.unit.test.ts +3 -0
- package/src/__tests__/tree-shake.unit.test.ts +24 -0
- package/src/frame-model.ts +381 -83
- package/src/index.ts +17 -10
- package/src/replay/device-request.ts +38 -0
- package/src/replay/readback.ts +106 -18
- package/src/replay/session.ts +47 -2
- package/src/texel-decode.ts +37 -3
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Tape } from '../protocol/types';
|
|
2
|
+
/**
|
|
3
|
+
* Build the strongest fresh WebGPU device the current adapter can provide for
|
|
4
|
+
* a recorded tape. Optional features must be enabled, not merely advertised,
|
|
5
|
+
* and replay receives concrete adapter limits instead of lower device defaults.
|
|
6
|
+
*/
|
|
7
|
+
export declare function replayDeviceRequest(tape: Tape, adapterFeatures: ReadonlySet<GPUFeatureName>, adapterLimits: Readonly<Record<string, number>>): GPUDeviceDescriptor;
|
|
8
|
+
//# sourceMappingURL=device-request.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"device-request.d.ts","sourceRoot":"","sources":["../../src/replay/device-request.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,mBAAmB,CAAC;AAY9C;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,IAAI,EACV,eAAe,EAAE,WAAW,CAAC,cAAc,CAAC,EAC5C,aAAa,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAC9C,mBAAmB,CAgBrB"}
|
|
@@ -13,6 +13,12 @@ export interface TextureReadbackSubresource {
|
|
|
13
13
|
readonly aspect?: 'all' | 'depth-only' | 'stencil-only';
|
|
14
14
|
}
|
|
15
15
|
export type ReplayReadbackRequest = BufferReadbackRange | TextureReadbackSubresource;
|
|
16
|
+
export interface ReplayReadbackProvenance {
|
|
17
|
+
readonly generation: number;
|
|
18
|
+
readonly resourceId: string;
|
|
19
|
+
readonly subresource: ReplayReadbackRequest | null;
|
|
20
|
+
readonly selectedWorkIndex?: number;
|
|
21
|
+
}
|
|
16
22
|
export interface ReplayReadbackResult {
|
|
17
23
|
readonly resourceId: string;
|
|
18
24
|
readonly kind: 'buffer' | 'texture';
|
|
@@ -20,6 +26,7 @@ export interface ReplayReadbackResult {
|
|
|
20
26
|
readonly width?: number;
|
|
21
27
|
readonly height?: number;
|
|
22
28
|
readonly bytes: Uint8Array;
|
|
29
|
+
readonly provenance: ReplayReadbackProvenance;
|
|
23
30
|
}
|
|
24
31
|
export declare function readReplayResource(device: RhiDevice, table: ResourceTable, resourceId: string, subresource: ReplayReadbackRequest | undefined, createShaderModule: CreateShaderModuleFn): Promise<Result<ReplayReadbackResult, RhiDebugError>>;
|
|
25
32
|
//# sourceMappingURL=readback.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"readback.d.ts","sourceRoot":"","sources":["../../src/replay/readback.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAIV,SAAS,EAEV,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAW,KAAK,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,EAAuB,KAAK,aAAa,EAAE,MAAM,WAAW,CAAC;AACpE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AACxD,OAAO,KAAK,EAAE,aAAa,EAAsB,MAAM,aAAa,CAAC;AAWrE,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,MAAM,CAAC,EAAE,KAAK,GAAG,YAAY,GAAG,cAAc,CAAC;CACzD;AAED,MAAM,MAAM,qBAAqB,GAAG,mBAAmB,GAAG,0BAA0B,CAAC;AAErF,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,IAAI,EAAE,QAAQ,GAAG,SAAS,CAAC;IACpC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC;
|
|
1
|
+
{"version":3,"file":"readback.d.ts","sourceRoot":"","sources":["../../src/replay/readback.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAIV,SAAS,EAEV,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAW,KAAK,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,EAAuB,KAAK,aAAa,EAAE,MAAM,WAAW,CAAC;AACpE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AACxD,OAAO,KAAK,EAAE,aAAa,EAAsB,MAAM,aAAa,CAAC;AAWrE,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,MAAM,CAAC,EAAE,KAAK,GAAG,YAAY,GAAG,cAAc,CAAC;CACzD;AAED,MAAM,MAAM,qBAAqB,GAAG,mBAAmB,GAAG,0BAA0B,CAAC;AAErF,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,WAAW,EAAE,qBAAqB,GAAG,IAAI,CAAC;IACnD,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;CACrC;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,IAAI,EAAE,QAAQ,GAAG,SAAS,CAAC;IACpC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC;IAC3B,QAAQ,CAAC,UAAU,EAAE,wBAAwB,CAAC;CAC/C;AAID,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,aAAa,EACpB,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,qBAAqB,GAAG,SAAS,EAC9C,kBAAkB,EAAE,oBAAoB,GACvC,OAAO,CAAC,MAAM,CAAC,oBAAoB,EAAE,aAAa,CAAC,CAAC,CAoCtD"}
|
package/dist/replay/session.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { RhiDevice } from '@forgeax/engine-rhi';
|
|
2
2
|
import { type Result } from '@forgeax/engine-types';
|
|
3
3
|
import { type RhiDebugError } from '../errors';
|
|
4
|
+
import { type WorkBinding, type WorkPipeline } from '../frame-model';
|
|
4
5
|
import type { Tape } from '../protocol/types';
|
|
5
6
|
import type { CreateShaderModuleFn } from '../recorder';
|
|
6
7
|
import { type BufferReadbackRange, type ReplayReadbackResult } from './readback';
|
|
@@ -20,6 +21,22 @@ export interface WorkInspection {
|
|
|
20
21
|
readonly eventIndex: number;
|
|
21
22
|
readonly passIndex: number;
|
|
22
23
|
readonly attachment: ReplayReadbackResult | undefined;
|
|
24
|
+
readonly pipeline?: WorkPipeline;
|
|
25
|
+
readonly bindings?: readonly WorkBinding[];
|
|
26
|
+
readonly vertexBuffers?: readonly {
|
|
27
|
+
readonly slot: number;
|
|
28
|
+
readonly bufferHandleId: string;
|
|
29
|
+
readonly offset: number;
|
|
30
|
+
readonly size: number | null;
|
|
31
|
+
}[];
|
|
32
|
+
readonly indexBuffer?: {
|
|
33
|
+
readonly bufferHandleId: string;
|
|
34
|
+
readonly format: string;
|
|
35
|
+
readonly offset: number;
|
|
36
|
+
readonly size: number | null;
|
|
37
|
+
} | null;
|
|
38
|
+
readonly shaders?: WorkPipeline['shaders'];
|
|
39
|
+
readonly resourceIds?: readonly string[];
|
|
23
40
|
}
|
|
24
41
|
export interface ReplaySession {
|
|
25
42
|
readonly generation: number;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/replay/session.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAGV,SAAS,EAEV,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAW,KAAK,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,EAAuB,KAAK,aAAa,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/replay/session.ts"],"names":[],"mappings":"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"}
|
package/dist/texel-decode.d.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
/** Decode a single IEEE half-float (16-bit) bit pattern to a JS number. */
|
|
2
2
|
export declare function halfToFloat(h: number): number;
|
|
3
3
|
/**
|
|
4
|
-
* Decode tight raw GPU bytes of
|
|
5
|
-
* canvas can paint via putImageData. Returns null when the format has
|
|
6
|
-
* {@link formatInfo} entry
|
|
4
|
+
* Decode tight raw GPU bytes of a supported color or depth/stencil plane into
|
|
5
|
+
* RGBA8 the canvas can paint via putImageData. Returns null when the format has
|
|
6
|
+
* no {@link formatInfo} entry or the requested aspect is invalid.
|
|
7
7
|
*
|
|
8
8
|
* @param bytes - Tight readback bytes (no row padding), length = w*h*bytesPerTexel.
|
|
9
9
|
* @param format - The texture's real format string.
|
|
10
10
|
* @param width - Texture width in pixels.
|
|
11
11
|
* @param height - Texture height in pixels.
|
|
12
|
+
* @param aspect - Explicit plane selected by readback for depth/stencil formats.
|
|
12
13
|
*/
|
|
13
|
-
export declare function decodeToRgba8(bytes: Uint8Array, format: string, width: number, height: number): Uint8ClampedArray<ArrayBuffer> | null;
|
|
14
|
+
export declare function decodeToRgba8(bytes: Uint8Array, format: string, width: number, height: number, aspect?: 'all' | 'depth-only' | 'stencil-only'): Uint8ClampedArray<ArrayBuffer> | null;
|
|
14
15
|
/**
|
|
15
16
|
* Decode a single texel from tight raw GPU bytes into raw float RGBA values
|
|
16
17
|
* without the display clamp (D-4: raw byte bypass for HDR fidelity).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"texel-decode.d.ts","sourceRoot":"","sources":["../src/texel-decode.ts"],"names":[],"mappings":"AAqBA,2EAA2E;AAC3E,wBAAgB,WAAW,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAe7C;AA8GD
|
|
1
|
+
{"version":3,"file":"texel-decode.d.ts","sourceRoot":"","sources":["../src/texel-decode.ts"],"names":[],"mappings":"AAqBA,2EAA2E;AAC3E,wBAAgB,WAAW,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAe7C;AA8GD;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,UAAU,EACjB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,MAAM,GAAE,KAAK,GAAG,YAAY,GAAG,cAAsB,GACpD,iBAAiB,CAAC,WAAW,CAAC,GAAG,IAAI,CAuCvC;AA+BD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,UAAU,EACjB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,GACb,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CA4BzC"}
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgeax/engine-rhi-debug",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"sideEffects": false,
|
|
8
|
-
"description": "Single-file v7 RHI tape capture, deterministic fresh-backend replay, and structured
|
|
8
|
+
"description": "Single-file v7 RHI tape capture, FrameModel projection, deterministic fresh-backend replay, and structured AI-readable inspection for forgeax-engine.",
|
|
9
9
|
"exports": {
|
|
10
10
|
".": {
|
|
11
11
|
"types": "./dist/index.d.ts",
|
|
@@ -26,19 +26,19 @@
|
|
|
26
26
|
"LICENSE"
|
|
27
27
|
],
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@forgeax/engine-rhi": "0.1.
|
|
30
|
-
"@forgeax/engine-types": "0.1.
|
|
29
|
+
"@forgeax/engine-rhi": "0.1.7",
|
|
30
|
+
"@forgeax/engine-types": "0.1.7",
|
|
31
31
|
"@noble/hashes": "^2.2.0",
|
|
32
32
|
"pako": "^1.0.11"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
|
-
"@forgeax/engine-rhi-null": "0.1.
|
|
35
|
+
"@forgeax/engine-rhi-null": "0.1.7",
|
|
36
36
|
"@types/node": "^20.14.0",
|
|
37
37
|
"@webgpu/types": "^0.1.71"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"@forgeax/engine-rhi-webgpu": "0.1.
|
|
41
|
-
"@forgeax/engine-rhi-wgpu": "0.1.
|
|
40
|
+
"@forgeax/engine-rhi-webgpu": "0.1.7",
|
|
41
|
+
"@forgeax/engine-rhi-wgpu": "0.1.7"
|
|
42
42
|
},
|
|
43
43
|
"forgeax": {
|
|
44
44
|
"metrics": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
// @perf-budget-skip: intentional repository-wide inventory subprocess gate.
|
|
3
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join, resolve } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
@@ -8,6 +8,17 @@ import { describe, expect, it } from 'vitest';
|
|
|
8
8
|
|
|
9
9
|
const repoRoot = resolve(fileURLToPath(new URL('../../../../', import.meta.url)));
|
|
10
10
|
const inventoryScript = join(repoRoot, 'apps/shared/scripts/rhi-debug-consumer-inventory.mjs');
|
|
11
|
+
const oosInventoryScript = join(repoRoot, 'apps/shared/scripts/rhi-debug-oos-inventory.mjs');
|
|
12
|
+
|
|
13
|
+
const deletedFieldConsumers = [
|
|
14
|
+
'apps/shared/scripts/rhi-debug-browser-admission.mjs',
|
|
15
|
+
'apps/shared/scripts/rhi-debug-verify.mjs',
|
|
16
|
+
'apps/learn-render/4.advanced-opengl/5.framebuffers/scripts/smoke-browser-live.mjs',
|
|
17
|
+
'apps/rhi-debug-viewer/src/viewer-model.ts',
|
|
18
|
+
'apps/rhi-debug-viewer/scripts/smoke-browser.mjs',
|
|
19
|
+
'apps/rhi-debug-viewer/scripts/smoke-browser-no-webgpu.mjs',
|
|
20
|
+
'packages/rhi-debug/src/__tests__/e2e.browser.test.ts',
|
|
21
|
+
];
|
|
11
22
|
|
|
12
23
|
type ChannelReport = {
|
|
13
24
|
totalMatches: number;
|
|
@@ -40,6 +51,33 @@ function runInventory(root: string): { status: number | null; report: InventoryR
|
|
|
40
51
|
}
|
|
41
52
|
|
|
42
53
|
describe('rhi-debug consumer inventory', () => {
|
|
54
|
+
it('reports all nine OOS boundaries and retained owners as machine-readable pass rows', () => {
|
|
55
|
+
const result = spawnSync(process.execPath, [oosInventoryScript, '--json', '--assert-pass'], {
|
|
56
|
+
cwd: repoRoot,
|
|
57
|
+
encoding: 'utf8',
|
|
58
|
+
});
|
|
59
|
+
expect(result.status).toBe(0);
|
|
60
|
+
const report = JSON.parse(result.stdout) as {
|
|
61
|
+
status: string;
|
|
62
|
+
oos: readonly { id: string; status: string }[];
|
|
63
|
+
retainedCore: readonly { present: boolean }[];
|
|
64
|
+
};
|
|
65
|
+
expect(report.status).toBe('pass');
|
|
66
|
+
expect(report.oos.map((entry) => entry.id)).toEqual([
|
|
67
|
+
'OOS-1',
|
|
68
|
+
'OOS-2',
|
|
69
|
+
'OOS-3',
|
|
70
|
+
'OOS-4',
|
|
71
|
+
'OOS-5',
|
|
72
|
+
'OOS-6',
|
|
73
|
+
'OOS-7',
|
|
74
|
+
'OOS-8',
|
|
75
|
+
'OOS-9',
|
|
76
|
+
]);
|
|
77
|
+
expect(report.oos.every((entry) => entry.status === 'pass')).toBe(true);
|
|
78
|
+
expect(report.retainedCore.every((entry) => entry.present)).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
43
81
|
it('enumerates TS, erased-script, and JSON/schema consumer channels', () => {
|
|
44
82
|
const fixture = mkdtempSync(join(tmpdir(), 'forgeax-rhi-debug-consumers-'));
|
|
45
83
|
try {
|
|
@@ -103,4 +141,14 @@ describe('rhi-debug consumer inventory', () => {
|
|
|
103
141
|
),
|
|
104
142
|
);
|
|
105
143
|
});
|
|
144
|
+
|
|
145
|
+
it('finds no deleted FrameModel fields across the three consumer channels', () => {
|
|
146
|
+
const deleted =
|
|
147
|
+
/(?:\bmodel\.(?:tree|draws|meta|resourceTable|resourceEntries|totalDraws)|buildTapeIndex)/;
|
|
148
|
+
const matches = deletedFieldConsumers.flatMap((relativePath) => {
|
|
149
|
+
const source = readFileSync(join(repoRoot, relativePath), 'utf8');
|
|
150
|
+
return deleted.test(source) ? [relativePath] : [];
|
|
151
|
+
});
|
|
152
|
+
expect(matches).toEqual([]);
|
|
153
|
+
});
|
|
106
154
|
});
|
|
@@ -180,7 +180,9 @@ describe.skipIf(!GPU_AVAILABLE)('M4 browser capture and replay gate', () => {
|
|
|
180
180
|
const model = buildFrameModel(captured.tape);
|
|
181
181
|
expect(captured.tape.header.formatVersion).toBe(7);
|
|
182
182
|
expect(captured.tape.events.length).toBeGreaterThan(0);
|
|
183
|
-
expect(model.
|
|
183
|
+
expect(model.works.some((work) => work.kind === 'draw' || work.kind === 'drawIndexed')).toBe(
|
|
184
|
+
true,
|
|
185
|
+
);
|
|
184
186
|
expect(captured.tape.events.some((event) => event.kind === 'setBindGroup')).toBe(true);
|
|
185
187
|
expect(model.works.length).toBeGreaterThan(0);
|
|
186
188
|
|
|
@@ -199,6 +201,9 @@ describe.skipIf(!GPU_AVAILABLE)('M4 browser capture and replay gate', () => {
|
|
|
199
201
|
expect(work.attachment?.kind).toBe('texture');
|
|
200
202
|
expect(work.attachment?.width).toBe(SIZE);
|
|
201
203
|
expect(work.attachment?.height).toBe(SIZE);
|
|
204
|
+
expect(work.attachment?.provenance.selectedWorkIndex).toBe(0);
|
|
205
|
+
expect(work.attachment?.provenance.resourceId).toBeDefined();
|
|
206
|
+
expect(work.attachment?.provenance.subresource).toBeNull();
|
|
202
207
|
expect(hasVisibleRgb(work.attachment?.bytes ?? new Uint8Array())).toBe(true);
|
|
203
208
|
await session.dispose();
|
|
204
209
|
}, 60_000);
|
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import { expectTypeOf, it } from 'vitest';
|
|
2
2
|
import type { FrameModel } from '../frame-model';
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
type HasKey<T, K extends PropertyKey> = K extends keyof T ? true : false;
|
|
5
|
+
type IsReadonlyMap<T> = T extends ReadonlyMap<unknown, unknown> ? true : false;
|
|
6
|
+
|
|
7
|
+
it('exposes only the canonical JSON-safe model shape', () => {
|
|
8
|
+
expectTypeOf<HasKey<FrameModel, 'commands'>>().toEqualTypeOf<true>();
|
|
9
|
+
expectTypeOf<HasKey<FrameModel, 'passes'>>().toEqualTypeOf<true>();
|
|
10
|
+
expectTypeOf<HasKey<FrameModel, 'resources'>>().toEqualTypeOf<true>();
|
|
11
|
+
expectTypeOf<HasKey<FrameModel, 'works'>>().toEqualTypeOf<true>();
|
|
12
|
+
expectTypeOf<HasKey<FrameModel, 'tree'>>().toEqualTypeOf<false>();
|
|
13
|
+
expectTypeOf<HasKey<FrameModel, 'draws'>>().toEqualTypeOf<false>();
|
|
14
|
+
expectTypeOf<HasKey<FrameModel, 'meta'>>().toEqualTypeOf<false>();
|
|
15
|
+
expectTypeOf<HasKey<FrameModel, 'resourceLifecycle'>>().toEqualTypeOf<true>();
|
|
16
|
+
expectTypeOf<IsReadonlyMap<FrameModel['resources']>>().toEqualTypeOf<false>();
|
|
5
17
|
expectTypeOf<FrameModel['works'][number]['workIndex']>().toEqualTypeOf<number>();
|
|
6
18
|
expectTypeOf<FrameModel['works'][number]['eventIndex']>().toEqualTypeOf<number>();
|
|
7
19
|
expectTypeOf<FrameModel['works'][number]['passIndex']>().toEqualTypeOf<number>();
|
|
@@ -2,37 +2,284 @@ import { describe, expect, it } from 'vitest';
|
|
|
2
2
|
import { buildFrameModel } from '../frame-model';
|
|
3
3
|
import type { Tape } from '../protocol/types';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
function makeTape(): Tape {
|
|
6
|
+
return {
|
|
7
|
+
header: { formatVersion: 7, rhiCaps: {}, eventCount: 15, blobCount: 0 },
|
|
8
|
+
bootstrap: [
|
|
9
|
+
{
|
|
10
|
+
handleId: 'buffer-1',
|
|
11
|
+
kind: 'buffer',
|
|
12
|
+
create: { kind: 'createBuffer', handleId: 'buffer-1', desc: { size: 64, usage: 4 } },
|
|
13
|
+
initialData: [],
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
handleId: 'shader:1',
|
|
17
|
+
kind: 'shader-module',
|
|
18
|
+
create: {
|
|
19
|
+
kind: 'createShaderModule',
|
|
20
|
+
handleId: 'shader:1',
|
|
21
|
+
wgslCode: '@vertex fn vs_main() -> @builtin(position) vec4f { return vec4f(0.0); }',
|
|
22
|
+
},
|
|
23
|
+
initialData: [],
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
events: [
|
|
27
|
+
{
|
|
28
|
+
kind: 'beginRenderPass',
|
|
29
|
+
passHandleId: 'pass:render',
|
|
30
|
+
cmdHandleId: 'encoder:1',
|
|
31
|
+
desc: { colorAttachments: [] },
|
|
32
|
+
colorAttachmentViewHandleIds: [],
|
|
33
|
+
},
|
|
34
|
+
{ kind: 'pushDebugGroup', cmdHandleId: 'encoder:1', groupLabel: 'render group' },
|
|
35
|
+
{ kind: 'passPushDebugGroup', passHandleId: 'pass:render', groupLabel: 'nested group' },
|
|
36
|
+
{ kind: 'insertDebugMarker', cmdHandleId: 'encoder:1', markerLabel: 'before draws' },
|
|
37
|
+
{
|
|
38
|
+
kind: 'draw',
|
|
39
|
+
passHandleId: 'pass:render',
|
|
40
|
+
vertexCount: 3,
|
|
41
|
+
instanceCount: 1,
|
|
42
|
+
firstVertex: 0,
|
|
43
|
+
firstInstance: 0,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
kind: 'drawIndexed',
|
|
47
|
+
passHandleId: 'pass:render',
|
|
48
|
+
indexCount: 3,
|
|
49
|
+
instanceCount: 1,
|
|
50
|
+
firstIndex: 0,
|
|
51
|
+
baseVertex: 0,
|
|
52
|
+
firstInstance: 0,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
kind: 'drawIndirect',
|
|
56
|
+
passHandleId: 'pass:render',
|
|
57
|
+
indirectBufferHandleId: 'buffer-1',
|
|
58
|
+
indirectOffset: 0,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
kind: 'drawIndexedIndirect',
|
|
62
|
+
passHandleId: 'pass:render',
|
|
63
|
+
indirectBufferHandleId: 'buffer-1',
|
|
64
|
+
indirectOffset: 16,
|
|
65
|
+
},
|
|
66
|
+
{ kind: 'passPopDebugGroup', passHandleId: 'pass:render' },
|
|
67
|
+
{ kind: 'popDebugGroup', cmdHandleId: 'encoder:1' },
|
|
68
|
+
{ kind: 'endRenderPass', passHandleId: 'pass:render' },
|
|
69
|
+
{
|
|
70
|
+
kind: 'beginComputePass',
|
|
71
|
+
passHandleId: 'pass:compute',
|
|
72
|
+
cmdHandleId: 'encoder:1',
|
|
73
|
+
desc: {},
|
|
74
|
+
},
|
|
75
|
+
{ kind: 'dispatchWorkgroups', passHandleId: 'pass:compute', x: 2, y: 3, z: 1 },
|
|
76
|
+
{
|
|
77
|
+
kind: 'dispatchWorkgroupsIndirect',
|
|
78
|
+
passHandleId: 'pass:compute',
|
|
79
|
+
indirectBufferHandleId: 'buffer-1',
|
|
80
|
+
indirectOffset: 32,
|
|
81
|
+
},
|
|
82
|
+
{ kind: 'endComputePass', passHandleId: 'pass:compute' },
|
|
83
|
+
],
|
|
84
|
+
blobs: [],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe('FrameModel canonical projection', () => {
|
|
89
|
+
it('exposes one JSON-safe projection with six globally indexed work kinds', () => {
|
|
90
|
+
const model = buildFrameModel(makeTape());
|
|
91
|
+
const roundTrip = JSON.parse(JSON.stringify(model)) as typeof model;
|
|
92
|
+
|
|
93
|
+
expect(roundTrip).toEqual(model);
|
|
94
|
+
expect(Object.keys(model)).toEqual([
|
|
95
|
+
'commands',
|
|
96
|
+
'passes',
|
|
97
|
+
'resources',
|
|
98
|
+
'resourceLifecycle',
|
|
99
|
+
'works',
|
|
100
|
+
]);
|
|
101
|
+
expect(
|
|
102
|
+
model.works.map((work) => [work.workIndex, work.eventIndex, work.passIndex, work.kind]),
|
|
103
|
+
).toEqual([
|
|
104
|
+
[0, 4, 0, 'draw'],
|
|
105
|
+
[1, 5, 0, 'drawIndexed'],
|
|
106
|
+
[2, 6, 0, 'drawIndirect'],
|
|
107
|
+
[3, 7, 0, 'drawIndexedIndirect'],
|
|
108
|
+
[4, 12, 1, 'dispatchWorkgroups'],
|
|
109
|
+
[5, 13, 1, 'dispatchWorkgroupsIndirect'],
|
|
110
|
+
]);
|
|
111
|
+
expect(model.commands[1]).toMatchObject({
|
|
112
|
+
eventIndex: 1,
|
|
113
|
+
passIndex: 0,
|
|
114
|
+
group: ['render group'],
|
|
115
|
+
});
|
|
116
|
+
expect(model.commands[3]).toMatchObject({ eventIndex: 3, marker: 'before draws' });
|
|
117
|
+
expect(model.passes.map((pass) => pass.workIndices)).toEqual([
|
|
118
|
+
[0, 1, 2, 3],
|
|
119
|
+
[4, 5],
|
|
120
|
+
]);
|
|
121
|
+
expect(model.resources).toEqual(
|
|
122
|
+
expect.arrayContaining([
|
|
123
|
+
expect.objectContaining({ resourceId: 'buffer-1', kind: 'buffer' }),
|
|
124
|
+
expect.objectContaining({ resourceId: 'shader:1', kind: 'shader-module' }),
|
|
125
|
+
]),
|
|
126
|
+
);
|
|
127
|
+
expect(model.works.every((work) => work.eventIndex >= 0 && work.passIndex >= 0)).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('fails closed instead of exposing empty legacy projections or Map values', () => {
|
|
131
|
+
const model = buildFrameModel(makeTape()) as unknown as Record<string, unknown>;
|
|
132
|
+
for (const field of ['tree', 'draws', 'meta']) {
|
|
133
|
+
expect(model, `legacy field ${field} must be absent`).not.toHaveProperty(field);
|
|
134
|
+
}
|
|
135
|
+
expect(model).not.toHaveProperty('totalDraws');
|
|
136
|
+
expect(model.resources).toBeInstanceOf(Array);
|
|
137
|
+
expect(JSON.stringify(model)).not.toContain('ReadonlyMap');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('resolves pipeline, shader, and bind-group facts from the bootstrap closure', () => {
|
|
7
141
|
const tape: Tape = {
|
|
8
|
-
header: { formatVersion: 7, rhiCaps: {}, eventCount:
|
|
9
|
-
bootstrap: [
|
|
142
|
+
header: { formatVersion: 7, rhiCaps: {}, eventCount: 5, blobCount: 0 },
|
|
143
|
+
bootstrap: [
|
|
144
|
+
{
|
|
145
|
+
handleId: 'texture:sample',
|
|
146
|
+
kind: 'texture',
|
|
147
|
+
create: {
|
|
148
|
+
kind: 'createTexture',
|
|
149
|
+
handleId: 'texture:sample',
|
|
150
|
+
desc: { size: [2, 2, 1], format: 'rgba8unorm', usage: 4 },
|
|
151
|
+
},
|
|
152
|
+
initialData: [],
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
handleId: 'view:sample',
|
|
156
|
+
kind: 'texture-view',
|
|
157
|
+
create: {
|
|
158
|
+
kind: 'createTextureView',
|
|
159
|
+
sourceHandleId: 'texture:sample',
|
|
160
|
+
resultHandleId: 'view:sample',
|
|
161
|
+
desc: {},
|
|
162
|
+
},
|
|
163
|
+
initialData: [],
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
handleId: 'shader:vertex',
|
|
167
|
+
kind: 'shader-module',
|
|
168
|
+
create: {
|
|
169
|
+
kind: 'createShaderModule',
|
|
170
|
+
handleId: 'shader:vertex',
|
|
171
|
+
wgslCode: '@vertex fn main() -> @builtin(position) vec4f { return vec4f(); }',
|
|
172
|
+
},
|
|
173
|
+
initialData: [],
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
handleId: 'shader:fragment',
|
|
177
|
+
kind: 'shader-module',
|
|
178
|
+
create: {
|
|
179
|
+
kind: 'createShaderModule',
|
|
180
|
+
handleId: 'shader:fragment',
|
|
181
|
+
wgslCode: '@fragment fn main() -> @location(0) vec4f { return vec4f(1.0); }',
|
|
182
|
+
},
|
|
183
|
+
initialData: [],
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
handleId: 'bgl:main',
|
|
187
|
+
kind: 'binding',
|
|
188
|
+
create: {
|
|
189
|
+
kind: 'createBindGroupLayout',
|
|
190
|
+
handleId: 'bgl:main',
|
|
191
|
+
desc: { entries: [] },
|
|
192
|
+
},
|
|
193
|
+
initialData: [],
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
handleId: 'bindGroup:main',
|
|
197
|
+
kind: 'binding',
|
|
198
|
+
create: {
|
|
199
|
+
kind: 'createBindGroup',
|
|
200
|
+
handleId: 'bindGroup:main',
|
|
201
|
+
layoutHandleId: 'bgl:main',
|
|
202
|
+
entries: [{ binding: 3, resourceKind: 'textureView' }],
|
|
203
|
+
resourceHandleIds: ['view:sample'],
|
|
204
|
+
},
|
|
205
|
+
initialData: [],
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
handleId: 'pipelineLayout:main',
|
|
209
|
+
kind: 'binding',
|
|
210
|
+
create: {
|
|
211
|
+
kind: 'createPipelineLayout',
|
|
212
|
+
handleId: 'pipelineLayout:main',
|
|
213
|
+
bglHandleIds: ['bgl:main'],
|
|
214
|
+
},
|
|
215
|
+
initialData: [],
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
handleId: 'pipeline:main',
|
|
219
|
+
kind: 'pipeline',
|
|
220
|
+
create: {
|
|
221
|
+
kind: 'createRenderPipeline',
|
|
222
|
+
handleId: 'pipeline:main',
|
|
223
|
+
desc: {
|
|
224
|
+
vertex: { entryPoint: 'main', buffers: [] },
|
|
225
|
+
fragment: { entryPoint: 'main', targets: [{ format: 'rgba8unorm' }] },
|
|
226
|
+
},
|
|
227
|
+
layoutHandleId: 'pipelineLayout:main',
|
|
228
|
+
vertexShaderModuleHandleId: 'shader:vertex',
|
|
229
|
+
fragmentShaderModuleHandleId: 'shader:fragment',
|
|
230
|
+
},
|
|
231
|
+
initialData: [],
|
|
232
|
+
},
|
|
233
|
+
],
|
|
10
234
|
events: [
|
|
11
235
|
{
|
|
12
236
|
kind: 'beginRenderPass',
|
|
13
|
-
passHandleId: 'pass:
|
|
14
|
-
cmdHandleId: '
|
|
237
|
+
passHandleId: 'pass:render',
|
|
238
|
+
cmdHandleId: 'encoder:1',
|
|
15
239
|
desc: { colorAttachments: [] },
|
|
16
|
-
colorAttachmentViewHandleIds: [],
|
|
240
|
+
colorAttachmentViewHandleIds: ['view:sample'],
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
kind: 'setPipeline',
|
|
244
|
+
passHandleId: 'pass:render',
|
|
245
|
+
pipelineHandleId: 'pipeline:main',
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
kind: 'setBindGroup',
|
|
249
|
+
passHandleId: 'pass:render',
|
|
250
|
+
index: 0,
|
|
251
|
+
bindGroupHandleId: 'bindGroup:main',
|
|
17
252
|
},
|
|
18
253
|
{
|
|
19
254
|
kind: 'draw',
|
|
20
|
-
passHandleId: 'pass:
|
|
255
|
+
passHandleId: 'pass:render',
|
|
21
256
|
vertexCount: 3,
|
|
22
257
|
instanceCount: 1,
|
|
23
258
|
firstVertex: 0,
|
|
24
259
|
firstInstance: 0,
|
|
25
260
|
},
|
|
261
|
+
{ kind: 'endRenderPass', passHandleId: 'pass:render' },
|
|
26
262
|
],
|
|
27
263
|
blobs: [],
|
|
28
264
|
};
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
kind: 'draw',
|
|
265
|
+
|
|
266
|
+
const work = buildFrameModel(tape).works[0];
|
|
267
|
+
expect(work?.pipeline).toMatchObject({
|
|
268
|
+
status: 'available',
|
|
269
|
+
pipelineHandleId: 'pipeline:main',
|
|
35
270
|
});
|
|
36
|
-
expect(
|
|
271
|
+
expect(work?.pipeline.shaders.map((shader) => shader.moduleHandleId)).toEqual([
|
|
272
|
+
'shader:vertex',
|
|
273
|
+
'shader:fragment',
|
|
274
|
+
]);
|
|
275
|
+
expect(work?.bindings).toEqual([
|
|
276
|
+
expect.objectContaining({
|
|
277
|
+
groupIndex: 0,
|
|
278
|
+
binding: 3,
|
|
279
|
+
bindGroupId: 'bindGroup:main',
|
|
280
|
+
resourceId: 'view:sample',
|
|
281
|
+
resourceKind: 'textureView',
|
|
282
|
+
}),
|
|
283
|
+
]);
|
|
37
284
|
});
|
|
38
285
|
});
|