@aethervtt/shard-runtime 0.0.1 → 0.2.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/dist/app.js CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  PreUpdate,
7
7
  Profiler,
8
8
  ProfilerResource,
9
+ ProfilerSettings,
9
10
  Rng,
10
11
  Schedule,
11
12
  ShardError,
@@ -14,9 +15,17 @@ import {
14
15
  World
15
16
  } from "@aethervtt/shard-core";
16
17
  import { AppControl, AppControlResource } from "./control.js";
18
+ import { FrameDemand, FrameDemandState, LOADING_DEMAND } from "./demand.js";
17
19
  import { Log, LogResource } from "./log.js";
20
+ import { PerfScenario } from "./scenario.js";
18
21
  import { OnEnter, OnExit } from "./state.js";
19
- import { DisplayRate, FixedTime, GlobalRng, Time } from "./time.js";
22
+ import {
23
+ DevMode,
24
+ DisplayRate,
25
+ FixedTime,
26
+ GlobalRng,
27
+ Time
28
+ } from "./time.js";
20
29
  const FRAME_SCHEDULES = [Startup, First, PreUpdate, FixedUpdate, Update, PostUpdate, Last];
21
30
  const EPSILON = 1e-9;
22
31
  const TimePlugin = {
@@ -34,15 +43,31 @@ class App {
34
43
  now;
35
44
  plugins = /* @__PURE__ */ new Map();
36
45
  pending = [];
46
+ /** Plugins in the order they were built; disposal runs it backwards. */
47
+ built = [];
48
+ scopes = [];
49
+ driver;
50
+ disposing;
37
51
  schedules = /* @__PURE__ */ new Map();
38
52
  systemNames = /* @__PURE__ */ new Set();
39
53
  states = [];
40
- profiler = new Profiler();
54
+ profiler;
41
55
  runner;
42
56
  initialized = false;
43
57
  startupDone = false;
44
58
  accumulator = 0;
45
59
  frameListeners = /* @__PURE__ */ new Set();
60
+ presenter;
61
+ /** Traces stamped since the last frame started; the next frame carries them. */
62
+ traces = [];
63
+ traceListeners = /* @__PURE__ */ new Set();
64
+ usableWaiters = [];
65
+ startup_ = {
66
+ initStart: Number.NaN,
67
+ initEnd: Number.NaN,
68
+ usableMarked: Number.NaN,
69
+ usable: Number.NaN
70
+ };
46
71
  appMethods = /* @__PURE__ */ new Map();
47
72
  /** The plugin whose `build` is running, so registrations can be attributed to it. */
48
73
  building;
@@ -51,17 +76,27 @@ class App {
51
76
  this.fixedHz = options.fixedHz ?? 60;
52
77
  this.maxFixedSteps = options.maxFixedSteps ?? 5;
53
78
  this.now = options.now ?? (() => performance.now());
79
+ const settings = { enabled: true, window: 120, ...options.profiler };
80
+ this.profiler = new Profiler({ now: this.now, settings });
81
+ this.world.insertResource(ProfilerSettings, settings);
54
82
  this.world.insertResource(ProfilerResource, this.profiler);
55
83
  this.world.insertResource(GlobalRng, new Rng(options.seed ?? 0));
84
+ this.world.insertResource(DevMode, { enabled: options.dev ?? false });
85
+ this.world.insertResource(PerfScenario, { name: options.perfScenario ?? null });
56
86
  const log = new Log();
57
87
  log.now = () => this.world.tryResource(Time)?.elapsed ?? 0;
58
88
  this.world.insertResource(LogResource, log);
59
- this.world.insertResource(AppControlResource, new AppControl());
89
+ this.profiler.onWarning = (w) => log.log("warn", w.message, { code: w.code, hint: w.hint, path: w.path });
90
+ const control = new AppControl();
91
+ control.onRequest = () => this.driver?.requestFrame();
92
+ this.world.insertResource(AppControlResource, control);
93
+ this.world.insertResource(FrameDemand, new FrameDemandState(this.now));
60
94
  this.addPlugin(TimePlugin);
61
95
  }
62
96
  // --- building --------------------------------------------------------------
63
97
  /** Adds plugins. They build in dependency order during `init()`. */
64
98
  addPlugin(...plugins) {
99
+ this.assertLive();
65
100
  for (const plugin of plugins) {
66
101
  if (this.plugins.has(plugin.name)) {
67
102
  throw new ShardError("app/duplicate-plugin", `Plugin "${plugin.name}" was added twice`);
@@ -73,6 +108,7 @@ class App {
73
108
  }
74
109
  /** Adds protocol methods (see `AppMethod`). A second method with a taken name throws. */
75
110
  addMethod(...methods) {
111
+ this.assertLive();
76
112
  for (const method of methods) {
77
113
  if (this.appMethods.has(method.name)) {
78
114
  throw new ShardError("app/duplicate-method", `Method "${method.name}" was added twice`);
@@ -81,11 +117,22 @@ class App {
81
117
  }
82
118
  return this;
83
119
  }
120
+ /**
121
+ * The budget scenario the app declares (0075): features with a budget resource adapt to their
122
+ * slice of its frame, and the `perf` overlay shows its slices. null (the default): none.
123
+ */
124
+ get perfScenario() {
125
+ return this.world.resource(PerfScenario).name;
126
+ }
127
+ set perfScenario(name) {
128
+ this.world.resource(PerfScenario).name = name;
129
+ }
84
130
  /** Methods plugins added, in the order they were added. */
85
131
  get methods() {
86
132
  return [...this.appMethods.values()];
87
133
  }
88
134
  addSystems(schedule, ...systems) {
135
+ this.assertLive();
89
136
  const target = this.schedule(schedule);
90
137
  for (const system of systems) {
91
138
  const name = "system" in system ? system.system.name : system.name;
@@ -101,6 +148,7 @@ class App {
101
148
  return this;
102
149
  }
103
150
  configureSets(schedule, ...sets) {
151
+ this.assertLive();
104
152
  const target = this.schedule(schedule);
105
153
  for (const set of sets) {
106
154
  target.configureSet(set);
@@ -135,7 +183,11 @@ class App {
135
183
  * itself. Resources and entities stay. For hot reload; returns the removed system names.
136
184
  */
137
185
  unloadPlugin(name) {
186
+ this.assertLive();
138
187
  const owned = this.owned.get(name);
188
+ const plugin = this.plugins.get(name);
189
+ const at = plugin ? this.built.indexOf(plugin) : -1;
190
+ if (at !== -1) this.built.splice(at, 1);
139
191
  this.plugins.delete(name);
140
192
  this.owned.delete(name);
141
193
  if (!owned) return [];
@@ -159,37 +211,38 @@ class App {
159
211
  * first run. Startup systems it adds don't run: startup already happened.
160
212
  */
161
213
  async loadPlugin(plugin) {
214
+ this.assertLive();
162
215
  if (this.plugins.has(plugin.name)) {
163
216
  throw new ShardError("app/duplicate-plugin", `Plugin "${plugin.name}" is already loaded`, {
164
217
  hint: "Unload it first (unloadPlugin)."
165
218
  });
166
219
  }
167
220
  this.plugins.set(plugin.name, plugin);
168
- this.building = plugin.name;
169
- try {
170
- plugin.build(this);
171
- } finally {
172
- this.building = void 0;
173
- }
174
- await plugin.ready?.(this);
221
+ this.buildPlugin(plugin);
222
+ await this.readyPlugin(plugin);
175
223
  }
176
224
  insertResource(def, value) {
225
+ this.assertLive();
177
226
  this.world.insertResource(def, value);
178
227
  return this;
179
228
  }
180
229
  /** Registers a state machine. `OnEnter(initial)` runs at the start of the first frame. */
181
230
  initState(state, initial = state.values[0]) {
231
+ this.assertLive();
182
232
  this.world.insertResource(state.resource, { current: initial, next: void 0 });
183
233
  this.states.push({ state, entered: false });
184
234
  return this;
185
235
  }
186
236
  setRunner(runner) {
237
+ this.assertLive();
187
238
  this.runner = runner;
188
239
  return this;
189
240
  }
190
241
  /** Builds plugins in dependency order, then awaits their `ready` hooks. */
191
242
  async init() {
243
+ this.assertLive();
192
244
  if (this.initialized) return;
245
+ if (Number.isNaN(this.startup_.initStart)) this.startup_.initStart = this.now();
193
246
  const built = [];
194
247
  const builtNames = /* @__PURE__ */ new Set();
195
248
  while (this.pending.length > 0) {
@@ -198,32 +251,157 @@ class App {
198
251
  );
199
252
  if (index === -1) throw this.dependencyError(builtNames);
200
253
  const [plugin] = this.pending.splice(index, 1);
201
- this.building = plugin.name;
202
- try {
203
- plugin.build(this);
204
- } finally {
205
- this.building = void 0;
206
- }
254
+ this.buildPlugin(plugin);
207
255
  built.push(plugin);
208
256
  builtNames.add(plugin.name);
209
257
  }
210
- for (const plugin of built) await plugin.ready?.(this);
258
+ for (const plugin of built) await this.readyPlugin(plugin);
211
259
  this.initialized = true;
260
+ this.startup_.initEnd = this.now();
212
261
  }
213
262
  /** Initializes, then hands control to the runner (manual if none was set). */
214
263
  async run() {
215
264
  await this.init();
216
265
  await this.runner?.(this);
217
266
  }
267
+ /**
268
+ * Stops the runner, waits for every plugin's work in flight (`beforeDispose`), then disposes
269
+ * plugins in reverse build order, so each releases what it created: GPU objects, surfaces,
270
+ * listeners, workers (0052). Idempotent; afterwards any other
271
+ * call throws `runtime/disposed`. A plugin whose dispose throws is logged and the rest still run.
272
+ */
273
+ dispose() {
274
+ this.disposing ??= this.disposeAll();
275
+ return this.disposing;
276
+ }
277
+ /** True once `dispose()` was called. */
278
+ get disposed() {
279
+ return this.disposing !== void 0;
280
+ }
281
+ /**
282
+ * Adds a scope around everything the app runs (see `AppScope`). Returns a function that removes
283
+ * it; don't call that from inside the app's own code (a frame, a plugin's dispose): disposal
284
+ * drops every scope once plugins are done. Scopes enter in the order added and exit in reverse.
285
+ */
286
+ addScope(scope) {
287
+ this.assertLive();
288
+ this.scopes.push(scope);
289
+ return () => {
290
+ const i = this.scopes.indexOf(scope);
291
+ if (i !== -1) this.scopes.splice(i, 1);
292
+ };
293
+ }
294
+ /**
295
+ * Called by a loop runner as it starts. `requestFrame` and `dispose` reach the loop through it.
296
+ */
297
+ attachDriver(driver) {
298
+ this.assertLive();
299
+ this.driver = driver;
300
+ }
301
+ /**
302
+ * Asks for a frame: an on-demand runner schedules one if none is coming (0052). Host code that
303
+ * changes what's on screen without writing the world (a canvas style, a DOM overlay) calls it.
304
+ */
305
+ requestFrame() {
306
+ this.assertLive();
307
+ this.driver?.requestFrame();
308
+ }
309
+ // --- presentation (0062) ----------------------------------------------------
310
+ /** Sets how a frame waits to be on screen (see `FramePresenter`). The render plugin calls it. */
311
+ setPresenter(presenter) {
312
+ this.presenter = presenter;
313
+ }
314
+ /** When init started and ended and when the app became usable, in ms on the app's clock. */
315
+ get startup() {
316
+ return this.startup_;
317
+ }
318
+ /**
319
+ * The host says the app is usable (for a VTT, once the scene is interactive). The first complete
320
+ * frame presented after this is the first usable frame (`startup.usable`, `whenUsable()`). Later
321
+ * calls do nothing.
322
+ */
323
+ markUsable() {
324
+ this.assertLive();
325
+ if (!Number.isNaN(this.startup_.usableMarked)) return;
326
+ this.startup_.usableMarked = this.now();
327
+ this.driver?.requestFrame();
328
+ }
329
+ /** Resolves with `startup.usable` once the first usable frame is presented. */
330
+ whenUsable() {
331
+ if (!Number.isNaN(this.startup_.usable)) return Promise.resolve(this.startup_.usable);
332
+ return new Promise((resolve) => this.usableWaiters.push(resolve));
333
+ }
334
+ /**
335
+ * Stamps a host write (a patch, a token move) for latency: resolves with the ms from now until the
336
+ * frame that carries it is presented. Call it right after the write. The next frame to start
337
+ * carries every trace stamped before it; a frame that ends still loading (`LOADING_DEMAND`: a
338
+ * pipeline compiling, a mesh not uploaded) hands them on, so the latency runs until the write is
339
+ * visible. `onTrace` listeners see each result.
340
+ */
341
+ trace(label) {
342
+ this.assertLive();
343
+ return new Promise((resolve) => {
344
+ this.traces.push({ label, at: this.now(), resolve });
345
+ this.driver?.requestFrame();
346
+ });
347
+ }
348
+ /**
349
+ * Requests a frame and resolves (with the ms waited) once a complete frame is presented, as
350
+ * `trace` does, but no `onTrace` listener sees it: for tools waiting on the screen, not for
351
+ * latency.
352
+ */
353
+ whenPresented() {
354
+ this.assertLive();
355
+ return new Promise((resolve) => {
356
+ this.traces.push({ label: void 0, at: this.now(), resolve });
357
+ this.driver?.requestFrame();
358
+ });
359
+ }
360
+ /** Called with every resolved trace: its label and latency in ms. Returns an unsubscribe function. */
361
+ onTrace(listener) {
362
+ this.assertLive();
363
+ this.traceListeners.add(listener);
364
+ return () => this.traceListeners.delete(listener);
365
+ }
366
+ /**
367
+ * Drops accumulated fixed-step time, so the next frame runs FixedUpdate for its own delta only.
368
+ * On-demand runners call it on waking, so a scene idle for a minute doesn't simulate the minute.
369
+ */
370
+ resetFixedTime() {
371
+ this.accumulator = 0;
372
+ }
218
373
  // --- running ---------------------------------------------------------------
219
374
  /** Runs one frame. `delta` is in seconds. */
220
375
  update(delta) {
376
+ this.assertLive();
221
377
  if (!this.initialized) {
222
378
  throw new ShardError("app/not-initialized", "App.update() was called before init()", {
223
379
  hint: "Await app.init() (or app.run()) first."
224
380
  });
225
381
  }
382
+ const scoped = this.enter();
383
+ try {
384
+ this.frame(delta);
385
+ } finally {
386
+ this.exit(scoped);
387
+ }
388
+ }
389
+ /** One frame, timed as the `frame` span (0074). */
390
+ frame(delta) {
391
+ const profiler = this.profiler;
392
+ const token = profiler.beginFrame(this.world.resource(Time).frame);
393
+ try {
394
+ this.frameBody(delta);
395
+ } catch (err) {
396
+ profiler.cancel(token);
397
+ throw err;
398
+ }
399
+ profiler.endFrame(token);
400
+ }
401
+ frameBody(delta) {
226
402
  const world = this.world;
403
+ const traces = this.traces.length > 0 ? this.traces.splice(0) : void 0;
404
+ const usable = Number.isNaN(this.startup_.usable) && !Number.isNaN(this.startup_.usableMarked);
227
405
  if (!this.startupDone) {
228
406
  this.runSchedule(Startup);
229
407
  this.startupDone = true;
@@ -252,9 +430,39 @@ class App {
252
430
  this.runSchedule(Last);
253
431
  time.frame++;
254
432
  for (const listener of this.frameListeners) listener(time.frame);
433
+ if (!traces && !usable) return;
434
+ if (world.resource(FrameDemand).isHeld(LOADING_DEMAND)) {
435
+ if (traces) this.traces.unshift(...traces);
436
+ return;
437
+ }
438
+ this.present(traces, usable);
439
+ }
440
+ /** Resolves traces and the usable mark once this frame is on screen. */
441
+ present(traces, usable) {
442
+ const done = (when) => {
443
+ const at = when ?? this.now();
444
+ if (usable && Number.isNaN(this.startup_.usable)) {
445
+ this.startup_.usable = at;
446
+ for (const resolve of this.usableWaiters.splice(0)) resolve(at);
447
+ }
448
+ if (!traces) return;
449
+ for (const trace of traces) {
450
+ const ms = Math.max(0, at - trace.at);
451
+ trace.resolve(ms);
452
+ const label = trace.label;
453
+ if (label !== void 0) for (const listener of this.traceListeners) listener(label, ms);
454
+ }
455
+ };
456
+ const presenter = this.presenter;
457
+ if (!presenter) {
458
+ done();
459
+ return;
460
+ }
461
+ presenter().then(done, () => done());
255
462
  }
256
463
  /** Called after every frame with the number of frames completed. Returns an unsubscribe function. */
257
464
  onFrame(listener) {
465
+ this.assertLive();
258
466
  this.frameListeners.add(listener);
259
467
  return () => this.frameListeners.delete(listener);
260
468
  }
@@ -263,6 +471,7 @@ class App {
263
471
  * without a frame loop (headless servers); loop-driven runners step one frame per tick instead.
264
472
  */
265
473
  pump() {
474
+ this.assertLive();
266
475
  const control = this.world.resource(AppControlResource);
267
476
  while (control.pendingSteps > 0) {
268
477
  try {
@@ -301,6 +510,113 @@ class App {
301
510
  };
302
511
  }
303
512
  // --- internals -------------------------------------------------------------
513
+ assertLive() {
514
+ if (this.disposing === void 0) return;
515
+ throw new ShardError("runtime/disposed", "This app was disposed", {
516
+ hint: "Create a new App; a disposed one has released its GPU objects and listeners."
517
+ });
518
+ }
519
+ /**
520
+ * Runs `fn` inside the app's scopes, as build and ready hooks run. For the part of an async ready
521
+ * hook after its first await, which runs outside them: what it makes counts against the app.
522
+ */
523
+ scoped(fn) {
524
+ const n = this.enter();
525
+ try {
526
+ return fn();
527
+ } finally {
528
+ this.exit(n);
529
+ }
530
+ }
531
+ /** Enters every scope; returns how many, for the matching `exit`. */
532
+ enter() {
533
+ const n = this.scopes.length;
534
+ for (let i = 0; i < n; i++) this.scopes[i].enter();
535
+ return n;
536
+ }
537
+ /** Exits the first `n` scopes, so one added inside the section never sees an unmatched exit. */
538
+ exit(n) {
539
+ for (let i = Math.min(n, this.scopes.length) - 1; i >= 0; i--) this.scopes[i].exit();
540
+ }
541
+ buildPlugin(plugin) {
542
+ this.building = plugin.name;
543
+ const scoped = this.enter();
544
+ try {
545
+ plugin.build(this);
546
+ } finally {
547
+ this.exit(scoped);
548
+ this.building = void 0;
549
+ }
550
+ this.built.push(plugin);
551
+ }
552
+ /** Awaits a ready hook; the part that runs before its first await is inside the app's scopes. */
553
+ async readyPlugin(plugin) {
554
+ if (!plugin.ready) return;
555
+ const scoped = this.enter();
556
+ let result;
557
+ try {
558
+ result = plugin.ready(this);
559
+ } finally {
560
+ this.exit(scoped);
561
+ }
562
+ await result;
563
+ }
564
+ async disposeAll() {
565
+ const driver = this.driver;
566
+ this.driver = void 0;
567
+ driver?.stop();
568
+ this.frameListeners.clear();
569
+ this.traceListeners.clear();
570
+ this.presenter = void 0;
571
+ this.world.onWake = void 0;
572
+ this.world.asleep = false;
573
+ const log = this.world.tryResource(LogResource);
574
+ let first;
575
+ for (const plugin of this.built) {
576
+ if (!plugin.beforeDispose) continue;
577
+ const scoped = this.enter();
578
+ let result;
579
+ try {
580
+ result = plugin.beforeDispose(this);
581
+ } catch (err) {
582
+ first ??= err;
583
+ log?.error(err);
584
+ continue;
585
+ } finally {
586
+ this.exit(scoped);
587
+ }
588
+ try {
589
+ await result;
590
+ } catch (err) {
591
+ first ??= err;
592
+ log?.error(err);
593
+ }
594
+ }
595
+ for (let i = this.built.length - 1; i >= 0; i--) {
596
+ const plugin = this.built[i];
597
+ if (!plugin.dispose) continue;
598
+ const scoped = this.enter();
599
+ let result;
600
+ try {
601
+ result = plugin.dispose(this);
602
+ } catch (err) {
603
+ first ??= err;
604
+ log?.error(err);
605
+ continue;
606
+ } finally {
607
+ this.exit(scoped);
608
+ }
609
+ try {
610
+ await result;
611
+ } catch (err) {
612
+ first ??= err;
613
+ log?.error(err);
614
+ }
615
+ }
616
+ this.built.length = 0;
617
+ this.scopes.length = 0;
618
+ if (first !== void 0) throw first;
619
+ }
304
620
  schedule(label) {
305
621
  let schedule = this.schedules.get(label);
306
622
  if (!schedule) {
@@ -0,0 +1,197 @@
1
+ import { type Profiler, type World } from '@aethervtt/shard-core';
2
+ export type PerfTrack = 'gpu' | 'cpu';
3
+ export declare const PERF_TRACKS: readonly PerfTrack[];
4
+ export interface PerfMachine {
5
+ /** Part of the CPU model string (`node:os`). */
6
+ cpu: string;
7
+ /** Part of the GPU adapter's vendor, architecture, device or description. */
8
+ gpu: string;
9
+ backend?: string;
10
+ display?: [number, number];
11
+ /** How GPU slices are checked: per-pass timestamps, or ablation where passes overlap. */
12
+ passTiming?: 'ablation' | 'timestamps';
13
+ note?: string;
14
+ }
15
+ /** `bench/perf/machines.json`. */
16
+ export interface PerfMachinesFile {
17
+ machines: Record<string, PerfMachine>;
18
+ }
19
+ export interface PerfBudgetEntry {
20
+ kind: 'target' | 'unit' | 'rate' | 'guard';
21
+ per?: string;
22
+ in?: string;
23
+ margin?: number;
24
+ note?: string;
25
+ /** A number per machine name. */
26
+ [machine: string]: unknown;
27
+ }
28
+ export interface PerfScenarioEntry {
29
+ what?: string;
30
+ resolution?: [number, number];
31
+ renderScale?: number;
32
+ fps?: number;
33
+ /** The frame budget per track, per machine, in ms. */
34
+ frame: Record<PerfTrack, Record<string, number>>;
35
+ /** Each track's slices as shares of the frame, `headroom` included; they sum to 1. */
36
+ slices: Record<PerfTrack, Record<string, number>>;
37
+ /** Absolute slices in ms per machine, where hardware differs unevenly. */
38
+ overrides?: Record<string, Partial<Record<PerfTrack, Record<string, number>>>>;
39
+ note?: string;
40
+ }
41
+ /** `bench/perf/budgets.json`. */
42
+ export interface PerfBudgetsFile {
43
+ spans: Record<string, PerfBudgetEntry>;
44
+ scenarios: Record<string, PerfScenarioEntry>;
45
+ }
46
+ export interface PerfBudgetsData {
47
+ machines: PerfMachinesFile;
48
+ budgets: PerfBudgetsFile;
49
+ /** Where they came from (a path or URL), for `perf.budgets`. */
50
+ source?: string;
51
+ /** The CPU model, where the host knows it (Node: `os.cpus()[0].model`). Browsers don't. */
52
+ cpu?: string;
53
+ /** A machine name that overrides detection (`SHARD_MACHINE`, `?machine=`). */
54
+ machine?: string;
55
+ }
56
+ /**
57
+ * Budgets for the running app (0075), handed over by the host: the CLI loads `bench/perf/` from
58
+ * the repo (or `perf/` in the project), `shard dev` serves them to its page. Absent: no budgets.
59
+ */
60
+ export declare const PerfBudgets: import("@aethervtt/shard-core").ResourceDef<PerfBudgetsData>;
61
+ export interface PerfAdapterInfo {
62
+ vendor?: string;
63
+ architecture?: string;
64
+ device?: string;
65
+ description?: string;
66
+ }
67
+ export interface PerfWarning {
68
+ code: string;
69
+ message: string;
70
+ hint?: string;
71
+ }
72
+ export interface MachineDetection {
73
+ /** The named machine, or null when this one can't be told (or isn't named). */
74
+ machine: string | null;
75
+ source: 'detected' | 'override' | 'unknown';
76
+ /** The machine that matched best, which unknown machines report against. */
77
+ closest: string | null;
78
+ warnings: PerfWarning[];
79
+ }
80
+ /** The graphics API an adapter's description names. */
81
+ export declare function backendOf(description?: string): string | undefined;
82
+ /**
83
+ * Which machine of machines.json this is (the rule `pnpm bench` uses, bench/perf/src/perf.mjs): a
84
+ * machine matches when both its CPU and GPU strings do. A host that can't tell the CPU model (a
85
+ * browser) never matches, and gets `perf/unknown-machine`; `override` names the machine instead.
86
+ */
87
+ export declare function detectPerfMachine(machines: PerfMachinesFile, o?: {
88
+ cpu?: string;
89
+ adapter?: PerfAdapterInfo;
90
+ override?: string;
91
+ }): MachineDetection;
92
+ /** A scenario's frame and slices on one machine, in ms (null without a number). */
93
+ export interface ResolvedPerfScenario {
94
+ frame: Record<PerfTrack, number | null>;
95
+ /** Each slice in ms: its share of the frame, or the machine's override. */
96
+ slices: Record<PerfTrack, Record<string, number | null>>;
97
+ /** Each slice's share of the frame, as budgets.json states it. */
98
+ shares: Record<PerfTrack, Record<string, number>>;
99
+ }
100
+ /** A scenario of budgets.json on `machine` (null: shares only). */
101
+ export declare function resolvePerfScenario(scenario: PerfScenarioEntry, machine: string | null): ResolvedPerfScenario;
102
+ /** The detected machine and the app's budgets data, cached until either changes. */
103
+ export interface PerfBudgetState {
104
+ data: PerfBudgetsData;
105
+ detection: MachineDetection;
106
+ }
107
+ /**
108
+ * The app's budgets and which machine it runs on, or undefined without `PerfBudgets`. Cached per
109
+ * world until the data, the override or the adapter changes; not for per-frame use.
110
+ */
111
+ export declare function perfBudgetState(world: World, adapter?: PerfAdapterInfo): PerfBudgetState | undefined;
112
+ /**
113
+ * A scenario slice in ms on the detected machine, or undefined (no budgets, no scenario, no such
114
+ * slice, or an unknown machine). What 0045's foliage defaults its target to.
115
+ */
116
+ export declare function scenarioSliceMs(world: World, scenario: string, track: PerfTrack, slice: string, adapter?: PerfAdapterInfo): number | undefined;
117
+ /**
118
+ * Time a key covers in an average frame, from the profiler's aggregates: the averages of every span
119
+ * the key covers (`spanCovers`), a span inside another covered span (its `parentOf` chain) counted
120
+ * once, as `Capture.spanTime` does. A span that doesn't run every frame counts its average run.
121
+ */
122
+ export declare function coveredMs(profiler: Profiler, key: string): number | undefined;
123
+ export interface SliceMeasure {
124
+ slice: string;
125
+ track: PerfTrack;
126
+ /** Its share of the frame in budgets.json. */
127
+ share: number;
128
+ /** Its budget on this machine (ms), or null. */
129
+ budgetMs: number | null;
130
+ /** Measured: the covered spans' average, and its share of the measured frame. */
131
+ measuredMs: number | null;
132
+ measuredShare: number | null;
133
+ verdict: 'over' | 'pass' | 'unmeasured';
134
+ }
135
+ /**
136
+ * Each slice of a scenario against the profiler's aggregates: measured shares of the frame (`frame`
137
+ * for CPU, `gpu:frame` for GPU) next to the budget's. `headroom` is what the slices leave: the frame
138
+ * less every slice.
139
+ */
140
+ export declare function measureSlices(profiler: Profiler, scenario: PerfScenarioEntry, machine: string | null): {
141
+ frame: Record<PerfTrack, {
142
+ budgetMs: number | null;
143
+ measuredMs: number | null;
144
+ }>;
145
+ slices: SliceMeasure[];
146
+ };
147
+ export interface BudgetMeasure {
148
+ key: string;
149
+ kind: PerfBudgetEntry['kind'];
150
+ per?: string;
151
+ in?: string;
152
+ /** The machine's number (null on an unknown machine). */
153
+ budget: number | null;
154
+ /** What a measurement is compared with: a guard's plus its margin. */
155
+ limit: number | null;
156
+ /** The span's p95 over the profiler's window, where a span by this name ran. */
157
+ measured: number | null;
158
+ verdict: 'over' | 'pass' | 'unmeasured' | 'unbudgeted';
159
+ }
160
+ export interface DescribeBudgetsOptions {
161
+ /** A scenario to measure; default the app's (`PerfScenario`). */
162
+ scenario?: string;
163
+ /** The GPU adapter, for detection. Default: the render plugin's (`PerfProviders.adapter`). */
164
+ adapter?: PerfAdapterInfo;
165
+ }
166
+ /** A scenario's frame and slices against the profiler's aggregates, over budget first. */
167
+ export interface ScenarioMeasure {
168
+ name: string;
169
+ what?: string;
170
+ resolution?: [number, number];
171
+ renderScale?: number;
172
+ fps?: number;
173
+ frame: Record<PerfTrack, {
174
+ budgetMs: number | null;
175
+ measuredMs: number | null;
176
+ }>;
177
+ slices: SliceMeasure[];
178
+ }
179
+ /** What `perf.budgets` returns. */
180
+ export interface BudgetsDescription {
181
+ /** The named machine, or null when this host can't tell (`closest` is then what numbers use). */
182
+ machine: string | null;
183
+ source: MachineDetection['source'];
184
+ closest?: string | null;
185
+ /** Where the budgets came from. */
186
+ from?: string;
187
+ /** The scenario measured (the app's, or the one asked for); null without one. */
188
+ scenario: ScenarioMeasure | null;
189
+ warnings: PerfWarning[];
190
+ budgets: BudgetMeasure[];
191
+ }
192
+ /**
193
+ * `perf.budgets` (0075): the detected machine, every budget resolved for it, and the latest
194
+ * measurements against each from the profiler's aggregates (span keys by their p95; the scenario's
195
+ * slices as shares of the frame), over budget first.
196
+ */
197
+ export declare function describeBudgets(world: World, options?: DescribeBudgetsOptions): BudgetsDescription;