@aethervtt/shard-verify 0.0.0-stage → 0.1.0

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick Dias
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,3 @@
1
- # Temporary Holding Version
1
+ # @aethervtt/shard-verify
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Part of [Shard](https://github.com/aethervtt/shard), a WebGPU-first 2D/3D game engine in TypeScript. See the repository for documentation.
@@ -0,0 +1,45 @@
1
+ /** 8-bit RGBA with straight (not premultiplied) alpha, rows top to bottom: a decoded PNG. */
2
+ export interface RgbaImage {
3
+ width: number;
4
+ height: number;
5
+ data: Uint8Array | Uint8ClampedArray;
6
+ }
7
+ /** How different a capture may be from its approved image (per shot, in the plan). */
8
+ export interface Tolerance {
9
+ /** ΔE2000 over which a pixel counts as changed. Default 2.3, about one just-noticeable difference. */
10
+ deltaE?: number;
11
+ /** Share of pixels (0 to 1) allowed to change. Default 0.001. */
12
+ maxShare?: number;
13
+ /** Lowest structural similarity allowed. Default 0.98. */
14
+ minSsim?: number;
15
+ }
16
+ export declare const DEFAULT_TOLERANCE: Required<Tolerance>;
17
+ export interface ImageDiff {
18
+ pass: boolean;
19
+ /** Why it failed, in a sentence; undefined when it passed. */
20
+ reason: string | undefined;
21
+ /** Pixels over the ΔE threshold. */
22
+ changed: number;
23
+ /** `changed` over all pixels. */
24
+ share: number;
25
+ maxDeltaE: number;
26
+ /** Mean SSIM over 8×8 windows of luminance. */
27
+ ssim: number;
28
+ /** ΔE2000 of every pixel, row by row (for the heatmap). */
29
+ deltaE: Float32Array;
30
+ tolerance: Required<Tolerance>;
31
+ }
32
+ /**
33
+ * A perceptual diff (0062): each pixel's ΔE2000 in Lab, and SSIM over luminance. Dithering and
34
+ * driver noise stay under a just-noticeable difference, so they don't count; a shifted grid line
35
+ * or a lost shadow does. Alpha counts too: pixels are compared composited over black and over
36
+ * white, and the larger difference wins, so a transparent surface that turns opaque is a change.
37
+ */
38
+ export declare function compareImages(approved: RgbaImage, capture: RgbaImage, tolerance?: Tolerance): ImageDiff;
39
+ /**
40
+ * A heatmap of a diff as RGBA: unchanged pixels are a dim gray copy of the approved image's
41
+ * luminance, changes go from yellow (just over the threshold) to red (ΔE 20 and up).
42
+ */
43
+ export declare function diffHeatmap(approved: RgbaImage, diff: ImageDiff): Uint8Array;
44
+ /** CIEDE2000 color difference (Sharma, Wu and Dalal's formulation), kL = kC = kH = 1. */
45
+ export declare function deltaE2000(L1: number, a1: number, b1: number, L2: number, a2: number, b2: number): number;
@@ -0,0 +1,213 @@
1
+ import { ShardError } from "@aethervtt/shard-core";
2
+ const DEFAULT_TOLERANCE = {
3
+ deltaE: 2.3,
4
+ maxShare: 1e-3,
5
+ minSsim: 0.98
6
+ };
7
+ function compareImages(approved, capture, tolerance = {}) {
8
+ const tol = { ...DEFAULT_TOLERANCE, ...tolerance };
9
+ if (approved.width !== capture.width || approved.height !== capture.height) {
10
+ throw new ShardError(
11
+ "verify/size-mismatch",
12
+ `The capture is ${capture.width}\xD7${capture.height}, the approved image ${approved.width}\xD7${approved.height}`,
13
+ { hint: "Check the viewport and DPR in the plan, or approve the new size with a reason." }
14
+ );
15
+ }
16
+ const n = approved.width * approved.height;
17
+ const deltaE = new Float32Array(n);
18
+ const a = approved.data;
19
+ const b = capture.data;
20
+ const labA = new Float64Array(6);
21
+ const labB = new Float64Array(6);
22
+ let changed = 0;
23
+ let maxDeltaE = 0;
24
+ for (let i = 0; i < n; i++) {
25
+ const o = i * 4;
26
+ if (a[o] === b[o] && a[o + 1] === b[o + 1] && a[o + 2] === b[o + 2] && a[o + 3] === b[o + 3]) {
27
+ continue;
28
+ }
29
+ composites(a, o, labA);
30
+ composites(b, o, labB);
31
+ const onBlack = deltaE2000(labA[0], labA[1], labA[2], labB[0], labB[1], labB[2]);
32
+ const onWhite = deltaE2000(labA[3], labA[4], labA[5], labB[3], labB[4], labB[5]);
33
+ const d = onBlack > onWhite ? onBlack : onWhite;
34
+ deltaE[i] = d;
35
+ if (d > tol.deltaE) changed++;
36
+ if (d > maxDeltaE) maxDeltaE = d;
37
+ }
38
+ const share = n === 0 ? 0 : changed / n;
39
+ const ssim = meanSsim(approved, capture);
40
+ let reason;
41
+ if (share > tol.maxShare) {
42
+ reason = `${(share * 100).toFixed(2)}% of pixels changed (\u0394E2000 > ${tol.deltaE}); ${(tol.maxShare * 100).toFixed(2)}% allowed`;
43
+ } else if (ssim < tol.minSsim) {
44
+ reason = `SSIM ${ssim.toFixed(4)} is under ${tol.minSsim}`;
45
+ }
46
+ return { pass: !reason, reason, changed, share, maxDeltaE, ssim, deltaE, tolerance: tol };
47
+ }
48
+ function diffHeatmap(approved, diff) {
49
+ const n = approved.width * approved.height;
50
+ const out = new Uint8Array(n * 4);
51
+ const src = approved.data;
52
+ const threshold = diff.tolerance.deltaE;
53
+ for (let i = 0; i < n; i++) {
54
+ const o = i * 4;
55
+ const d = diff.deltaE[i];
56
+ if (d > threshold) {
57
+ const t = Math.min(1, (d - threshold) / (20 - threshold));
58
+ out[o] = 255;
59
+ out[o + 1] = Math.round(220 * (1 - t));
60
+ out[o + 2] = 0;
61
+ } else {
62
+ const y = (0.2126 * src[o] + 0.7152 * src[o + 1] + 0.0722 * src[o + 2]) * (src[o + 3] / 255);
63
+ const gray = Math.round(40 + y * 0.25);
64
+ out[o] = gray;
65
+ out[o + 1] = gray;
66
+ out[o + 2] = gray;
67
+ }
68
+ out[o + 3] = 255;
69
+ }
70
+ return out;
71
+ }
72
+ const LINEAR = new Float64Array(256);
73
+ for (let i = 0; i < 256; i++) {
74
+ const c = i / 255;
75
+ LINEAR[i] = c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
76
+ }
77
+ function composites(data, o, out) {
78
+ const alpha = data[o + 3] / 255;
79
+ const r = LINEAR[data[o]] * alpha;
80
+ const g = LINEAR[data[o + 1]] * alpha;
81
+ const b = LINEAR[data[o + 2]] * alpha;
82
+ linearToLab(r, g, b, out, 0);
83
+ const white = 1 - alpha;
84
+ linearToLab(r + white, g + white, b + white, out, 3);
85
+ }
86
+ const XN = 0.95047;
87
+ const ZN = 1.08883;
88
+ function linearToLab(r, g, b, out, at) {
89
+ const x = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / XN;
90
+ const y = 0.2126729 * r + 0.7151522 * g + 0.072175 * b;
91
+ const z = (0.0193339 * r + 0.119192 * g + 0.9503041 * b) / ZN;
92
+ const fx = labF(x);
93
+ const fy = labF(y);
94
+ const fz = labF(z);
95
+ out[at] = 116 * fy - 16;
96
+ out[at + 1] = 500 * (fx - fy);
97
+ out[at + 2] = 200 * (fy - fz);
98
+ }
99
+ function labF(t) {
100
+ return t > 216 / 24389 ? Math.cbrt(t) : (24389 / 27 * t + 16) / 116;
101
+ }
102
+ const DEG = Math.PI / 180;
103
+ const POW25_7 = 25 ** 7;
104
+ function deltaE2000(L1, a1, b1, L2, a2, b2) {
105
+ const C12 = Math.sqrt(a1 * a1 + b1 * b1);
106
+ const C22 = Math.sqrt(a2 * a2 + b2 * b2);
107
+ const Cm7 = ((C12 + C22) / 2) ** 7;
108
+ const G = 0.5 * (1 - Math.sqrt(Cm7 / (Cm7 + POW25_7)));
109
+ const a1p = (1 + G) * a1;
110
+ const a2p = (1 + G) * a2;
111
+ const C1p = Math.sqrt(a1p * a1p + b1 * b1);
112
+ const C2p = Math.sqrt(a2p * a2p + b2 * b2);
113
+ const h1p = hueDegrees(b1, a1p);
114
+ const h2p = hueDegrees(b2, a2p);
115
+ const dLp = L2 - L1;
116
+ const dCp = C2p - C1p;
117
+ const chroma = C1p * C2p;
118
+ let dhp = 0;
119
+ if (chroma !== 0) {
120
+ dhp = h2p - h1p;
121
+ if (dhp > 180) dhp -= 360;
122
+ else if (dhp < -180) dhp += 360;
123
+ }
124
+ const dHp = 2 * Math.sqrt(chroma) * Math.sin(dhp / 2 * DEG);
125
+ const Lpm = (L1 + L2) / 2;
126
+ const Cpm = (C1p + C2p) / 2;
127
+ let hpm = h1p + h2p;
128
+ if (chroma !== 0) {
129
+ if (Math.abs(h1p - h2p) <= 180) hpm = (h1p + h2p) / 2;
130
+ else hpm = h1p + h2p < 360 ? (h1p + h2p + 360) / 2 : (h1p + h2p - 360) / 2;
131
+ }
132
+ const T = 1 - 0.17 * Math.cos((hpm - 30) * DEG) + 0.24 * Math.cos(2 * hpm * DEG) + 0.32 * Math.cos((3 * hpm + 6) * DEG) - 0.2 * Math.cos((4 * hpm - 63) * DEG);
133
+ const dTheta = 30 * Math.exp(-(((hpm - 275) / 25) ** 2));
134
+ const Cpm7 = Cpm ** 7;
135
+ const Rc = 2 * Math.sqrt(Cpm7 / (Cpm7 + POW25_7));
136
+ const L50 = (Lpm - 50) ** 2;
137
+ const Sl = 1 + 0.015 * L50 / Math.sqrt(20 + L50);
138
+ const Sc = 1 + 0.045 * Cpm;
139
+ const Sh = 1 + 0.015 * Cpm * T;
140
+ const Rt = -Math.sin(2 * dTheta * DEG) * Rc;
141
+ const l = dLp / Sl;
142
+ const c = dCp / Sc;
143
+ const h = dHp / Sh;
144
+ return Math.sqrt(l * l + c * c + h * h + Rt * c * h);
145
+ }
146
+ function hueDegrees(b, a) {
147
+ if (a === 0 && b === 0) return 0;
148
+ const h = Math.atan2(b, a) / DEG;
149
+ return h < 0 ? h + 360 : h;
150
+ }
151
+ const WINDOW = 8;
152
+ const STRIDE = 4;
153
+ const C1 = (0.01 * 255) ** 2;
154
+ const C2 = (0.03 * 255) ** 2;
155
+ function luminance(image) {
156
+ const n = image.width * image.height;
157
+ const out = new Float32Array(n);
158
+ const d = image.data;
159
+ for (let i = 0; i < n; i++) {
160
+ const o = i * 4;
161
+ const alpha = d[o + 3] / 255;
162
+ const y = 0.2126 * d[o] + 0.7152 * d[o + 1] + 0.0722 * d[o + 2];
163
+ out[i] = y * alpha + 128 * (1 - alpha);
164
+ }
165
+ return out;
166
+ }
167
+ function meanSsim(a, b) {
168
+ const width = a.width;
169
+ const height = a.height;
170
+ const x = luminance(a);
171
+ const y = luminance(b);
172
+ const w = Math.min(WINDOW, width);
173
+ const h = Math.min(WINDOW, height);
174
+ if (w === 0 || h === 0) return 1;
175
+ let total = 0;
176
+ let windows = 0;
177
+ for (let top = 0; top + h <= height; top += STRIDE) {
178
+ for (let left = 0; left + w <= width; left += STRIDE) {
179
+ let sx = 0;
180
+ let sy = 0;
181
+ let sxx = 0;
182
+ let syy = 0;
183
+ let sxy = 0;
184
+ for (let j = 0; j < h; j++) {
185
+ const row = (top + j) * width + left;
186
+ for (let i = 0; i < w; i++) {
187
+ const p = x[row + i];
188
+ const q = y[row + i];
189
+ sx += p;
190
+ sy += q;
191
+ sxx += p * p;
192
+ syy += q * q;
193
+ sxy += p * q;
194
+ }
195
+ }
196
+ const count = w * h;
197
+ const mx = sx / count;
198
+ const my = sy / count;
199
+ const vx = sxx / count - mx * mx;
200
+ const vy = syy / count - my * my;
201
+ const cov = sxy / count - mx * my;
202
+ total += (2 * mx * my + C1) * (2 * cov + C2) / ((mx * mx + my * my + C1) * (vx + vy + C2));
203
+ windows++;
204
+ }
205
+ }
206
+ return windows === 0 ? 1 : total / windows;
207
+ }
208
+ export {
209
+ DEFAULT_TOLERANCE,
210
+ compareImages,
211
+ deltaE2000,
212
+ diffHeatmap
213
+ };
@@ -0,0 +1,14 @@
1
+ import type { Expectations } from './plan.js';
2
+ /** A check that failed: which step, which client, which probe path, and what was wrong. */
3
+ export interface CheckFailure {
4
+ step: string;
5
+ client: string;
6
+ path: string;
7
+ message: string;
8
+ }
9
+ /** The value at a dotted path (`owners.scene`, `entities.0`); undefined where it stops. */
10
+ export declare function valueAt(value: unknown, path: string): unknown;
11
+ /** Structural equality over JSON values. */
12
+ export declare function deepEqual(a: unknown, b: unknown): boolean;
13
+ /** Checks one client's probe after a step. Every failed matcher is its own failure. */
14
+ export declare function checkExpectations(step: string, client: string, probe: unknown, expectations: Expectations): CheckFailure[];
package/dist/expect.js ADDED
@@ -0,0 +1,70 @@
1
+ function valueAt(value, path) {
2
+ let at = value;
3
+ for (const key of path.split(".")) {
4
+ if (at === null || typeof at !== "object") return void 0;
5
+ at = at[key];
6
+ }
7
+ return at;
8
+ }
9
+ function deepEqual(a, b) {
10
+ if (a === b) return true;
11
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
12
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
13
+ if (Array.isArray(a)) {
14
+ const other = b;
15
+ return a.length === other.length && a.every((item, i) => deepEqual(item, other[i]));
16
+ }
17
+ const ka = Object.keys(a);
18
+ const kb = Object.keys(b);
19
+ if (ka.length !== kb.length) return false;
20
+ return ka.every(
21
+ (k) => deepEqual(a[k], b[k])
22
+ );
23
+ }
24
+ const show = (value) => value === void 0 ? "nothing" : JSON.stringify(value);
25
+ function checkExpectations(step, client, probe, expectations) {
26
+ const failures = [];
27
+ for (const [path, matcher] of Object.entries(expectations)) {
28
+ for (const message of match(valueAt(probe, path), matcher, probe)) {
29
+ failures.push({ step, client, path, message });
30
+ }
31
+ }
32
+ return failures;
33
+ }
34
+ function match(value, m, probe) {
35
+ const out = [];
36
+ if ("equals" in m && !deepEqual(value, m.equals)) {
37
+ out.push(`expected ${show(m.equals)}, got ${show(value)}`);
38
+ }
39
+ if (m.includes || m.excludes) {
40
+ if (!Array.isArray(value)) out.push(`expected a list, got ${show(value)}`);
41
+ else {
42
+ for (const item of m.includes ?? []) {
43
+ if (!value.some((v) => deepEqual(v, item))) out.push(`expected it to include ${show(item)}`);
44
+ }
45
+ for (const item of m.excludes ?? []) {
46
+ if (value.some((v) => deepEqual(v, item)))
47
+ out.push(`expected it not to include ${show(item)}`);
48
+ }
49
+ }
50
+ }
51
+ if (m.min !== void 0 || m.max !== void 0) {
52
+ if (typeof value !== "number") out.push(`expected a number, got ${show(value)}`);
53
+ else {
54
+ if (m.min !== void 0 && value < m.min) out.push(`expected at least ${m.min}, got ${value}`);
55
+ if (m.max !== void 0 && value > m.max) out.push(`expected at most ${m.max}, got ${value}`);
56
+ }
57
+ }
58
+ if (m.sameAs !== void 0) {
59
+ const other = valueAt(probe, m.sameAs);
60
+ if (!deepEqual(value, other)) {
61
+ out.push(`expected the same as ${m.sameAs} (${show(other)}), got ${show(value)}`);
62
+ }
63
+ }
64
+ return out;
65
+ }
66
+ export {
67
+ checkExpectations,
68
+ deepEqual,
69
+ valueAt
70
+ };
@@ -0,0 +1,7 @@
1
+ export { compareImages, DEFAULT_TOLERANCE, deltaE2000, diffHeatmap, type ImageDiff, type RgbaImage, type Tolerance, } from './compare.js';
2
+ export { type CheckFailure, checkExpectations, deepEqual, valueAt } from './expect.js';
3
+ export { type BrowserName, type CapturePlan, type CaptureScope, capturePlanJsonSchema, type Expectations, type Matcher, type PlanClient, type PlanConditions, type PlanScenario, type PlanShot, type PlanStep, parsePlan, shotId, type ThresholdRule, type Thresholds, } from './plan.js';
4
+ export { type PerfDevice, type PerfRecord, parsePerfRecord, perfRecordJsonSchema, rendererName, validateJson, } from './record.js';
5
+ export { type ReportShot, renderReport } from './report.js';
6
+ export { type Approval, type ApprovalsFile, addApproval, type CapturedShot, type CaptureManifest, } from './run.js';
7
+ export { type Breach, checkThresholds, type PerfCheck } from './thresholds.js';
package/dist/index.js ADDED
@@ -0,0 +1,42 @@
1
+ import {
2
+ compareImages,
3
+ DEFAULT_TOLERANCE,
4
+ deltaE2000,
5
+ diffHeatmap
6
+ } from "./compare.js";
7
+ import { checkExpectations, deepEqual, valueAt } from "./expect.js";
8
+ import {
9
+ capturePlanJsonSchema,
10
+ parsePlan,
11
+ shotId
12
+ } from "./plan.js";
13
+ import {
14
+ parsePerfRecord,
15
+ perfRecordJsonSchema,
16
+ rendererName,
17
+ validateJson
18
+ } from "./record.js";
19
+ import { renderReport } from "./report.js";
20
+ import {
21
+ addApproval
22
+ } from "./run.js";
23
+ import { checkThresholds } from "./thresholds.js";
24
+ export {
25
+ DEFAULT_TOLERANCE,
26
+ addApproval,
27
+ capturePlanJsonSchema,
28
+ checkExpectations,
29
+ checkThresholds,
30
+ compareImages,
31
+ deepEqual,
32
+ deltaE2000,
33
+ diffHeatmap,
34
+ parsePerfRecord,
35
+ parsePlan,
36
+ perfRecordJsonSchema,
37
+ renderReport,
38
+ rendererName,
39
+ shotId,
40
+ validateJson,
41
+ valueAt
42
+ };
@@ -0,0 +1 @@
1
+ export { Metrics, type MetricsOptions, MetricsResource, metricsMethods, metricsPlugin, type RecordMeta, } from './metrics.js';
@@ -0,0 +1,12 @@
1
+ import {
2
+ Metrics,
3
+ MetricsResource,
4
+ metricsMethods,
5
+ metricsPlugin
6
+ } from "./metrics.js";
7
+ export {
8
+ Metrics,
9
+ MetricsResource,
10
+ metricsMethods,
11
+ metricsPlugin
12
+ };
@@ -0,0 +1,83 @@
1
+ import type { HostPerformance } from '@aethervtt/shard-platform';
2
+ import { type App, type AppMethod, type Plugin } from '@aethervtt/shard-runtime';
3
+ import type { PerfRecord } from '../record.js';
4
+ export interface MetricsOptions {
5
+ /**
6
+ * The host's instruments: long tasks and downloads (`createWebPerformance()` from
7
+ * `@aethervtt/shard-platform-web`, or a platform's `performance`). Without them both read 0.
8
+ */
9
+ performance?: HostPerformance;
10
+ /** How far back `frameTime` and `longTasks` look, in ms. Default 30 s. */
11
+ windowMs?: number;
12
+ /** What records name the renderer: `shard@<sha>`. Default `shard`. */
13
+ renderer?: string;
14
+ /**
15
+ * The clock, in ms. Default `performance.now`, which counts from navigation start. It must be
16
+ * the app's clock too (`AppOptions.now`).
17
+ */
18
+ now?: () => number;
19
+ /** Navigation start on that clock. Default 0. */
20
+ navigationStart?: number;
21
+ }
22
+ export interface RecordMeta {
23
+ fixture: string;
24
+ scenario: string;
25
+ /** Overrides `MetricsOptions.renderer` for this record. */
26
+ renderer?: string;
27
+ }
28
+ /**
29
+ * What an app measured for 0062's performance records: frame intervals, GPU frame times, traced
30
+ * host writes, and long tasks, each timestamped so a record covers one window. Recording into it
31
+ * each frame allocates nothing.
32
+ */
33
+ export declare class Metrics {
34
+ readonly windowMs: number;
35
+ readonly renderer: string;
36
+ private readonly app;
37
+ private readonly now;
38
+ private readonly navigationStart;
39
+ private readonly performance;
40
+ private readonly frames;
41
+ private readonly gpuFrames;
42
+ private readonly traces;
43
+ private readonly longTasks;
44
+ private windowStart;
45
+ private lastStart;
46
+ /** The last frame asked for the next one, so the interval between them is a frame time. */
47
+ private continued;
48
+ private gpuSamples;
49
+ private scaleMin;
50
+ private scaleMax;
51
+ private scaleAuto;
52
+ private scaled;
53
+ private pipelinesMs;
54
+ private assetsMs;
55
+ constructor(app: App, options?: MetricsOptions);
56
+ /** Starts a new window: what was measured so far no longer counts (startup numbers stay). */
57
+ reset(): void;
58
+ /** @internal As a frame starts: the interval since the last one, if that one asked for this. */
59
+ frameStart(): void;
60
+ /** @internal As a frame ends: GPU time, render scale, and whether another frame follows. */
61
+ frameEnd(): void;
62
+ /** @internal */
63
+ trace(ms: number): void;
64
+ /** @internal */
65
+ longTask(start: number, duration: number): void;
66
+ /** @internal The first usable frame was presented: startup work in flight until now counts. */
67
+ usable(at: number): void;
68
+ /**
69
+ * The record of this app so far: startup, and the current window (since `reset()`, at most
70
+ * `windowMs`). Throws `verify/not-usable` before the first usable frame.
71
+ */
72
+ record(meta: RecordMeta): PerfRecord;
73
+ private device;
74
+ }
75
+ export declare const MetricsResource: import("@aethervtt/shard-core").ResourceDef<Metrics>;
76
+ /** `metrics.record` and `metrics.reset`, for agents measuring a running app. */
77
+ export declare const metricsMethods: AppMethod[];
78
+ /**
79
+ * Records 0062's performance metrics: `world.resource(MetricsResource).record(meta)` or the
80
+ * protocol's `metrics.record`. The host calls `app.markUsable()` once the scene is interactive,
81
+ * and `app.trace(label)` after writes whose latency counts.
82
+ */
83
+ export declare function metricsPlugin(options?: MetricsOptions): Plugin;