@aethervtt/shard-runtime 0.1.0 → 0.2.1

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.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type ComponentDef, type ResourceDef, type ScheduleLabel, type SystemConfig, type SystemDef, type SystemDescription, type SystemSetConfig, World } from '@aethervtt/shard-core';
1
+ import { type ComponentDef, type ProfilerSettingsData, type ResourceDef, type ScheduleLabel, type SystemConfig, type SystemDef, type SystemDescription, type SystemSetConfig, World } from '@aethervtt/shard-core';
2
2
  import type { Plugin } from './plugin.js';
3
3
  import type { Runner } from './runners.js';
4
4
  import { type StateDef } from './state.js';
@@ -17,6 +17,18 @@ export interface AppOptions {
17
17
  * Default false.
18
18
  */
19
19
  dev?: boolean;
20
+ /**
21
+ * The profiler (0074): `enabled: false` turns spans off for builds that want every microsecond;
22
+ * `window` is how many runs aggregates cover. Also `ProfilerSettings` at runtime. Default on, 120.
23
+ */
24
+ profiler?: Partial<ProfilerSettingsData>;
25
+ /**
26
+ * The budget scenario the app runs as (0075, `bench/perf/budgets.json`): features that adapt
27
+ * (0045's foliage) default their target to their slice of its frame, and the `perf` overlay shows
28
+ * its slices. Default none: nothing adapts. Also `App.perfScenario` and the `PerfScenario`
29
+ * resource at runtime.
30
+ */
31
+ perfScenario?: string | null;
20
32
  }
21
33
  export interface AppDescription {
22
34
  plugins: {
@@ -118,6 +130,12 @@ export declare class App {
118
130
  addPlugin(...plugins: Plugin[]): this;
119
131
  /** Adds protocol methods (see `AppMethod`). A second method with a taken name throws. */
120
132
  addMethod(...methods: AppMethod[]): this;
133
+ /**
134
+ * The budget scenario the app declares (0075): features with a budget resource adapt to their
135
+ * slice of its frame, and the `perf` overlay shows its slices. null (the default): none.
136
+ */
137
+ get perfScenario(): string | null;
138
+ set perfScenario(name: string | null);
121
139
  /** Methods plugins added, in the order they were added. */
122
140
  get methods(): readonly AppMethod[];
123
141
  addSystems(schedule: ScheduleLabel, ...systems: SystemInput[]): this;
@@ -204,7 +222,9 @@ export declare class App {
204
222
  resetFixedTime(): void;
205
223
  /** Runs one frame. `delta` is in seconds. */
206
224
  update(delta: number): void;
225
+ /** One frame, timed as the `frame` span (0074). */
207
226
  private frame;
227
+ private frameBody;
208
228
  /** Resolves traces and the usable mark once this frame is on screen. */
209
229
  private present;
210
230
  /** Called after every frame with the number of frames completed. Returns an unsubscribe function. */
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,
@@ -16,6 +17,7 @@ import {
16
17
  import { AppControl, AppControlResource } from "./control.js";
17
18
  import { FrameDemand, FrameDemandState, LOADING_DEMAND } from "./demand.js";
18
19
  import { Log, LogResource } from "./log.js";
20
+ import { PerfScenario } from "./scenario.js";
19
21
  import { OnEnter, OnExit } from "./state.js";
20
22
  import {
21
23
  DevMode,
@@ -49,7 +51,7 @@ class App {
49
51
  schedules = /* @__PURE__ */ new Map();
50
52
  systemNames = /* @__PURE__ */ new Set();
51
53
  states = [];
52
- profiler = new Profiler();
54
+ profiler;
53
55
  runner;
54
56
  initialized = false;
55
57
  startupDone = false;
@@ -74,12 +76,17 @@ class App {
74
76
  this.fixedHz = options.fixedHz ?? 60;
75
77
  this.maxFixedSteps = options.maxFixedSteps ?? 5;
76
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);
77
82
  this.world.insertResource(ProfilerResource, this.profiler);
78
83
  this.world.insertResource(GlobalRng, new Rng(options.seed ?? 0));
79
84
  this.world.insertResource(DevMode, { enabled: options.dev ?? false });
85
+ this.world.insertResource(PerfScenario, { name: options.perfScenario ?? null });
80
86
  const log = new Log();
81
87
  log.now = () => this.world.tryResource(Time)?.elapsed ?? 0;
82
88
  this.world.insertResource(LogResource, log);
89
+ this.profiler.onWarning = (w) => log.log("warn", w.message, { code: w.code, hint: w.hint, path: w.path });
83
90
  const control = new AppControl();
84
91
  control.onRequest = () => this.driver?.requestFrame();
85
92
  this.world.insertResource(AppControlResource, control);
@@ -110,6 +117,16 @@ class App {
110
117
  }
111
118
  return this;
112
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
+ }
113
130
  /** Methods plugins added, in the order they were added. */
114
131
  get methods() {
115
132
  return [...this.appMethods.values()];
@@ -369,7 +386,19 @@ class App {
369
386
  this.exit(scoped);
370
387
  }
371
388
  }
389
+ /** One frame, timed as the `frame` span (0074). */
372
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) {
373
402
  const world = this.world;
374
403
  const traces = this.traces.length > 0 ? this.traces.splice(0) : void 0;
375
404
  const usable = Number.isNaN(this.startup_.usable) && !Number.isNaN(this.startup_.usableMarked);
@@ -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;
@@ -0,0 +1,256 @@
1
+ import {
2
+ defineResource,
3
+ ProfilerResource,
4
+ ShardError,
5
+ spanCovers,
6
+ spanId,
7
+ spanName,
8
+ spanStats
9
+ } from "@aethervtt/shard-core";
10
+ import { PerfProviders } from "./perf.js";
11
+ import { PerfScenario } from "./scenario.js";
12
+ const PERF_TRACKS = ["gpu", "cpu"];
13
+ const PerfBudgets = defineResource("runtime/PerfBudgets", {
14
+ description: "budgets.json and machines.json (0075), as the host loaded them, with the CPU model and a machine override."
15
+ });
16
+ const has = (text, part) => !!part && (text ?? "").toLowerCase().includes(part.toLowerCase());
17
+ function backendOf(description = "") {
18
+ const d = description.toLowerCase();
19
+ if (d.includes("metal")) return "metal";
20
+ if (d.includes("d3d12")) return "d3d12";
21
+ if (d.includes("d3d11")) return "d3d11";
22
+ if (d.includes("vulkan")) return "vulkan";
23
+ if (d.includes("opengl")) return "opengl";
24
+ return void 0;
25
+ }
26
+ function detectPerfMachine(machines, o = {}) {
27
+ const names = Object.keys(machines.machines);
28
+ const gpuText = o.adapter ? [o.adapter.vendor, o.adapter.architecture, o.adapter.device, o.adapter.description].join(" ") : "";
29
+ const backend = backendOf(o.adapter?.description);
30
+ let best = names[0] ?? null;
31
+ let bestScore = -1;
32
+ let detected = null;
33
+ for (const name of names) {
34
+ const m = machines.machines[name];
35
+ const cpu = has(o.cpu, m.cpu);
36
+ const gpu = has(gpuText, m.gpu);
37
+ const score = (cpu ? 2 : 0) + (gpu ? 2 : 0) + (backend && backend === m.backend ? 1 : 0);
38
+ if (score > bestScore) {
39
+ bestScore = score;
40
+ best = name;
41
+ }
42
+ if (cpu && gpu && detected === null) detected = name;
43
+ }
44
+ const warnings = [];
45
+ if (o.override) {
46
+ if (!names.includes(o.override)) {
47
+ warnings.push({
48
+ code: "perf/unknown-machine",
49
+ message: `Machine "${o.override}" isn't in machines.json (${names.join(", ")})`
50
+ });
51
+ return { machine: null, source: "unknown", closest: best, warnings };
52
+ }
53
+ if (detected && detected !== o.override) {
54
+ warnings.push({
55
+ code: "perf/machine-mismatch",
56
+ message: `The override says "${o.override}", but this looks like "${detected}"`
57
+ });
58
+ }
59
+ return { machine: o.override, source: "override", closest: o.override, warnings };
60
+ }
61
+ if (detected) return { machine: detected, source: "detected", closest: detected, warnings };
62
+ warnings.push({
63
+ code: "perf/unknown-machine",
64
+ message: o.cpu ? `No machine in machines.json matches (CPU "${o.cpu}", GPU "${gpuText.trim() || "unknown"}")` : `This host doesn't expose its CPU model, so it can't tell which machine it is (GPU "${gpuText.trim() || "unknown"}")`,
65
+ hint: "Name it: PerfBudgets.machine (SHARD_MACHINE in Node, ?machine= on playground pages)."
66
+ });
67
+ return { machine: null, source: "unknown", closest: best, warnings };
68
+ }
69
+ function resolvePerfScenario(scenario, machine) {
70
+ const frame = { gpu: null, cpu: null };
71
+ const slices = { gpu: {}, cpu: {} };
72
+ const shares = { gpu: {}, cpu: {} };
73
+ for (const track of PERF_TRACKS) {
74
+ const ms = machine !== null ? scenario.frame?.[track]?.[machine] : void 0;
75
+ frame[track] = typeof ms === "number" ? ms : null;
76
+ const overrides = machine !== null ? scenario.overrides?.[machine]?.[track] : void 0;
77
+ for (const [slice, share] of Object.entries(scenario.slices?.[track] ?? {})) {
78
+ shares[track][slice] = share;
79
+ const absolute = overrides?.[slice];
80
+ slices[track][slice] = typeof absolute === "number" ? absolute : frame[track] === null ? null : frame[track] * share;
81
+ }
82
+ }
83
+ return { frame, slices, shares };
84
+ }
85
+ const stateCache = /* @__PURE__ */ new WeakMap();
86
+ function perfBudgetState(world, adapter) {
87
+ const data = world.tryResource(PerfBudgets);
88
+ if (!data?.budgets || !data.machines) return void 0;
89
+ adapter ??= world.tryResource(PerfProviders)?.adapter?.(world);
90
+ const key = `${data.cpu ?? ""}|${data.machine ?? ""}|${adapter ? JSON.stringify(adapter) : ""}`;
91
+ const cached = stateCache.get(world);
92
+ if (cached && cached.data === data && cached.key === key) return cached.state;
93
+ const detection = detectPerfMachine(data.machines, {
94
+ cpu: data.cpu,
95
+ adapter,
96
+ override: data.machine
97
+ });
98
+ const state = { data, detection };
99
+ stateCache.set(world, { key, data, state });
100
+ return state;
101
+ }
102
+ function scenarioSliceMs(world, scenario, track, slice, adapter) {
103
+ const state = perfBudgetState(world, adapter);
104
+ const entry = state?.data.budgets.scenarios[scenario];
105
+ if (!state || !entry || state.detection.machine === null) return void 0;
106
+ const ms = resolvePerfScenario(entry, state.detection.machine).slices[track][slice];
107
+ return ms ?? void 0;
108
+ }
109
+ function coveredMs(profiler, key) {
110
+ let ms = 0;
111
+ let any = false;
112
+ for (const name of profiler.names()) {
113
+ if (!spanCovers(key, name)) continue;
114
+ const timing = profiler.timing(name);
115
+ if (!timing) continue;
116
+ any = true;
117
+ let parent = profiler.parentOf(spanId(name));
118
+ let inside = false;
119
+ for (let depth = 0; parent >= 0 && depth < 64; depth++) {
120
+ if (spanCovers(key, spanName(parent))) {
121
+ inside = true;
122
+ break;
123
+ }
124
+ parent = profiler.parentOf(parent);
125
+ }
126
+ if (!inside) ms += timing.avg;
127
+ }
128
+ return any ? ms : void 0;
129
+ }
130
+ function measureSlices(profiler, scenario, machine) {
131
+ const resolved = resolvePerfScenario(scenario, machine);
132
+ const r3 = (ms) => Math.round(ms * 1e3) / 1e3;
133
+ const frame = {};
134
+ const slices = [];
135
+ for (const track of PERF_TRACKS) {
136
+ const frameMs = profiler.timing(track === "gpu" ? "gpu:frame" : "frame")?.avg ?? null;
137
+ frame[track] = {
138
+ budgetMs: resolved.frame[track],
139
+ measuredMs: frameMs === null ? null : r3(frameMs)
140
+ };
141
+ let used = 0;
142
+ const keys = Object.keys(resolved.shares[track]);
143
+ for (const slice of keys) {
144
+ if (slice === "headroom") continue;
145
+ const ms = coveredMs(profiler, slice) ?? null;
146
+ if (ms !== null) used += ms;
147
+ slices.push(sliceMeasure(slice, track, resolved, ms, frameMs));
148
+ }
149
+ if ("headroom" in resolved.shares[track]) {
150
+ const left = frameMs === null ? null : Math.max(0, frameMs - used);
151
+ const m = sliceMeasure("headroom", track, resolved, left, frameMs);
152
+ m.verdict = m.measuredShare === null ? "unmeasured" : m.measuredShare < m.share ? "over" : "pass";
153
+ slices.push(m);
154
+ }
155
+ }
156
+ return { frame, slices };
157
+ }
158
+ function sliceMeasure(slice, track, resolved, ms, frameMs) {
159
+ const share = resolved.shares[track][slice];
160
+ const measuredShare = ms !== null && frameMs !== null && frameMs > 0 ? ms / frameMs : null;
161
+ const r3 = (v) => Math.round(v * 1e3) / 1e3;
162
+ return {
163
+ slice,
164
+ track,
165
+ share,
166
+ budgetMs: resolved.slices[track][slice] ?? null,
167
+ measuredMs: ms === null ? null : r3(ms),
168
+ measuredShare: measuredShare === null ? null : r3(measuredShare),
169
+ verdict: measuredShare === null ? "unmeasured" : measuredShare > share ? "over" : "pass"
170
+ };
171
+ }
172
+ function describeBudgets(world, options = {}) {
173
+ const state = perfBudgetState(world, options.adapter);
174
+ const scenarioName = options.scenario ?? world.tryResource(PerfScenario)?.name ?? null;
175
+ if (!state) {
176
+ return {
177
+ machine: null,
178
+ source: "unknown",
179
+ scenario: null,
180
+ warnings: [
181
+ {
182
+ code: "perf/no-budgets",
183
+ message: "This app has no budgets: the host gave it no budgets.json",
184
+ hint: "The CLI loads bench/perf/budgets.json from the repo (or perf/budgets.json in the project); shard dev serves it to its page."
185
+ }
186
+ ],
187
+ budgets: []
188
+ };
189
+ }
190
+ const { data, detection } = state;
191
+ const machine = detection.machine;
192
+ const on = machine ?? detection.closest;
193
+ const profiler = world.resource(ProfilerResource);
194
+ const budgets = [];
195
+ for (const [key, entry] of Object.entries(data.budgets.spans ?? {})) {
196
+ const value = on !== null && typeof entry[on] === "number" ? entry[on] : null;
197
+ const limit = value === null ? null : entry.kind === "guard" ? value * (1 + (entry.margin ?? 0)) : value;
198
+ const stats = entry.kind === "unit" || entry.kind === "rate" || entry.in ? void 0 : spanStats(profiler, key);
199
+ const measured = stats ? Math.round(stats.p95 * 1e3) / 1e3 : null;
200
+ budgets.push({
201
+ key,
202
+ kind: entry.kind,
203
+ ...entry.per ? { per: entry.per } : {},
204
+ ...entry.in ? { in: entry.in } : {},
205
+ budget: value,
206
+ limit,
207
+ measured,
208
+ verdict: measured === null ? "unmeasured" : limit === null || machine === null ? "unbudgeted" : measured > limit ? "over" : "pass"
209
+ });
210
+ }
211
+ const rank = { over: 0, pass: 1, unbudgeted: 2, unmeasured: 3 };
212
+ budgets.sort((a, b) => rank[a.verdict] - rank[b.verdict] || (a.key < b.key ? -1 : 1));
213
+ let scenario = null;
214
+ if (scenarioName !== null) {
215
+ const entry = data.budgets.scenarios[scenarioName];
216
+ if (!entry) {
217
+ throw new ShardError("perf/unknown-budget", `No scenario "${scenarioName}" in budgets.json`, {
218
+ path: scenarioName,
219
+ hint: `Scenarios: ${Object.keys(data.budgets.scenarios).join(", ")}.`
220
+ });
221
+ }
222
+ const measured = measureSlices(profiler, entry, machine ?? detection.closest);
223
+ const order = { over: 0, pass: 1, unmeasured: 2 };
224
+ measured.slices.sort((a, b) => order[a.verdict] - order[b.verdict]);
225
+ scenario = {
226
+ name: scenarioName,
227
+ what: entry.what,
228
+ resolution: entry.resolution,
229
+ renderScale: entry.renderScale,
230
+ fps: entry.fps,
231
+ frame: measured.frame,
232
+ slices: measured.slices
233
+ };
234
+ }
235
+ return {
236
+ machine,
237
+ source: detection.source,
238
+ ...machine === null ? { closest: detection.closest } : {},
239
+ ...data.source ? { from: data.source } : {},
240
+ scenario,
241
+ warnings: detection.warnings,
242
+ budgets
243
+ };
244
+ }
245
+ export {
246
+ PERF_TRACKS,
247
+ PerfBudgets,
248
+ backendOf,
249
+ coveredMs,
250
+ describeBudgets,
251
+ detectPerfMachine,
252
+ measureSlices,
253
+ perfBudgetState,
254
+ resolvePerfScenario,
255
+ scenarioSliceMs
256
+ };
package/dist/index.d.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  export { App, type AppDescription, type AppMethod, type AppOptions, type AppScope, type FrameDriver, type FramePresenter, type StartupTimings, } from './app.js';
2
+ export { type BudgetMeasure, type BudgetsDescription, backendOf, coveredMs, type DescribeBudgetsOptions, describeBudgets, detectPerfMachine, type MachineDetection, measureSlices, PERF_TRACKS, type PerfAdapterInfo, type PerfBudgetEntry, type PerfBudgetState, PerfBudgets, type PerfBudgetsData, type PerfBudgetsFile, type PerfMachine, type PerfMachinesFile, type PerfScenarioEntry, type PerfTrack, type PerfWarning, perfBudgetState, type ResolvedPerfScenario, resolvePerfScenario, type ScenarioMeasure, type SliceMeasure, scenarioSliceMs, } from './budgets.js';
2
3
  export { AppControl, AppControlResource } from './control.js';
3
4
  export { FrameDemand, FrameDemandState, type FrameMode, LOADING_DEMAND } from './demand.js';
4
5
  export { COMMON_RATES, RefreshMeter, rateFromIntervals, snapRate } from './display.js';
5
6
  export { Log, type LogEntry, type LogLevel, LogResource } from './log.js';
7
+ export { capturePerf, captureStamp, describePerf, gpuPassOverlap, isGpuPass, OVERLAP_RATIO, type PerfCaptureOptions, type PerfCaptureResult, type PerfDescribeOptions, type PerfHost, PerfHostResource, PerfProviders, type PerfProvidersData, type PerfSampler, type PerfSamples, perfBreakdown, perfMemory, } from './perf.js';
6
8
  export { definePlugin, type Plugin } from './plugin.js';
7
9
  export { type AnimationFrameOptions, animationFrameRunner, type HeadlessOptions, headlessRunner, type Runner, } from './runners.js';
10
+ export { PerfScenario, type PerfScenarioData } from './scenario.js';
8
11
  export { defineState, inState, OnEnter, OnExit, type StateDef, type StateValue, setState, } from './state.js';
9
12
  export { DevMode, DisplayRate, type DisplayRateData, FixedTime, type FixedTimeData, GlobalRng, Time, type TimeData, } from './time.js';
package/dist/index.js CHANGED
@@ -1,15 +1,40 @@
1
1
  import {
2
2
  App
3
3
  } from "./app.js";
4
+ import {
5
+ backendOf,
6
+ coveredMs,
7
+ describeBudgets,
8
+ detectPerfMachine,
9
+ measureSlices,
10
+ PERF_TRACKS,
11
+ PerfBudgets,
12
+ perfBudgetState,
13
+ resolvePerfScenario,
14
+ scenarioSliceMs
15
+ } from "./budgets.js";
4
16
  import { AppControl, AppControlResource } from "./control.js";
5
17
  import { FrameDemand, FrameDemandState, LOADING_DEMAND } from "./demand.js";
6
18
  import { COMMON_RATES, RefreshMeter, rateFromIntervals, snapRate } from "./display.js";
7
19
  import { Log, LogResource } from "./log.js";
20
+ import {
21
+ capturePerf,
22
+ captureStamp,
23
+ describePerf,
24
+ gpuPassOverlap,
25
+ isGpuPass,
26
+ OVERLAP_RATIO,
27
+ PerfHostResource,
28
+ PerfProviders,
29
+ perfBreakdown,
30
+ perfMemory
31
+ } from "./perf.js";
8
32
  import { definePlugin } from "./plugin.js";
9
33
  import {
10
34
  animationFrameRunner,
11
35
  headlessRunner
12
36
  } from "./runners.js";
37
+ import { PerfScenario } from "./scenario.js";
13
38
  import {
14
39
  defineState,
15
40
  inState,
@@ -38,16 +63,37 @@ export {
38
63
  LOADING_DEMAND,
39
64
  Log,
40
65
  LogResource,
66
+ OVERLAP_RATIO,
41
67
  OnEnter,
42
68
  OnExit,
69
+ PERF_TRACKS,
70
+ PerfBudgets,
71
+ PerfHostResource,
72
+ PerfProviders,
73
+ PerfScenario,
43
74
  RefreshMeter,
44
75
  Time,
45
76
  animationFrameRunner,
77
+ backendOf,
78
+ capturePerf,
79
+ captureStamp,
80
+ coveredMs,
46
81
  definePlugin,
47
82
  defineState,
83
+ describeBudgets,
84
+ describePerf,
85
+ detectPerfMachine,
86
+ gpuPassOverlap,
48
87
  headlessRunner,
49
88
  inState,
89
+ isGpuPass,
90
+ measureSlices,
91
+ perfBreakdown,
92
+ perfBudgetState,
93
+ perfMemory,
50
94
  rateFromIntervals,
95
+ resolvePerfScenario,
96
+ scenarioSliceMs,
51
97
  setState,
52
98
  snapRate
53
99
  };
package/dist/perf.d.ts ADDED
@@ -0,0 +1,157 @@
1
+ import { type CaptureOptions, type CaptureSummary, type ChromeTrace, type HeapStats, type HotFunction, type Profiler, type SpanStats, type World } from '@aethervtt/shard-core';
2
+ import type { Capture } from '@aethervtt/shard-core/capture';
3
+ /** A sampling profiler the host runs for a capture's window. */
4
+ export interface PerfSampler {
5
+ stop(): Promise<PerfSamples>;
6
+ }
7
+ export interface PerfSamples {
8
+ hottest: HotFunction[];
9
+ /** Stretches of the same top function, for the trace's samples track (browsers). */
10
+ samples?: {
11
+ name: string;
12
+ start: number;
13
+ ms: number;
14
+ }[];
15
+ /** A V8 `.cpuprofile` (Node), written next to the trace. */
16
+ cpuprofile?: unknown;
17
+ }
18
+ /**
19
+ * What the host can measure for the profiler. The asset server fills it from the platform
20
+ * (`HostPerformance` and the file system) when it's configured.
21
+ */
22
+ export interface PerfHost {
23
+ /** JS heap: `v8.getHeapStatistics()` in Node, the page's measurements in browsers. */
24
+ heap?(): HeapStats | undefined;
25
+ /** Garbage collections as they happen (Node): start on the app's clock, pause, and kind. */
26
+ onGc?(listener: (start: number, ms: number, kind: string) => void): () => void;
27
+ /** Starts sampling; undefined where the host can't (`perf/sampling-unavailable`). */
28
+ startSampler?(): Promise<PerfSampler | undefined>;
29
+ /** Emits a span to the host's own profiler (Chrome's Performance panel). */
30
+ measure?(name: string, track: string, start: number, ms: number): void;
31
+ /** Writes a project file (traces go to `.shard/captures/`). Absent on read-only hosts. */
32
+ writeText?(path: string, text: string): Promise<void>;
33
+ }
34
+ export declare const PerfHostResource: import("@aethervtt/shard-core").ResourceDef<PerfHost>;
35
+ export interface PerfProvidersData {
36
+ /** GPU memory by category (the render plugin's ledger). */
37
+ gpuMemory?: (world: World) => {
38
+ bytes: number;
39
+ byCategory: Record<string, number>;
40
+ };
41
+ /** More `perf.describe` sections by name (0075 adds budgets). */
42
+ sections: Map<string, (world: World) => unknown>;
43
+ /** The GPU adapter's identity (the render plugin), for telling which machine this is (0075). */
44
+ adapter?: (world: World) => {
45
+ vendor?: string;
46
+ architecture?: string;
47
+ device?: string;
48
+ description?: string;
49
+ } | undefined;
50
+ }
51
+ export declare const PerfProviders: import("@aethervtt/shard-core").ResourceDef<PerfProvidersData>;
52
+ export interface PerfDescribeOptions {
53
+ /** Only spans these keys cover (`spanCovers`: equal, or a prefix ending at "/"). */
54
+ spans?: readonly string[];
55
+ /** Most spans per track. Default 30. */
56
+ top?: number;
57
+ }
58
+ /** GPU memory from the ledger, and the JS heap. */
59
+ export declare function perfMemory(world: World): CaptureSummary['memory'];
60
+ /** Per-pass GPU times summed past this share of `gpu:frame` mean the passes overlap (0075). */
61
+ export declare const OVERLAP_RATIO = 1.1;
62
+ /** Whether a span is a single GPU pass (`gpu:<node>`), not the frame or a group of passes. */
63
+ export declare function isGpuPass(name: string): boolean;
64
+ /**
65
+ * Whether per-pass GPU times overlap (0075): their averages sum to more than 110% of `gpu:frame`'s.
66
+ * On tile-based GPUs (Apple, most mobile) passes run concurrently and a pass's timestamps include
67
+ * waiting for earlier work, so only `gpu:frame` is reliable there, and pass costs come from
68
+ * ablation (`perf.ablate`). Undefined without GPU timings.
69
+ */
70
+ export declare function gpuPassOverlap(profiler: Profiler): {
71
+ passesMs: number;
72
+ frameMs: number;
73
+ overlapping: boolean;
74
+ } | undefined;
75
+ /**
76
+ * `perf.describe` (0074): per-frame, per-schedule, per-system CPU time, GPU passes, worker and
77
+ * async spans, memory (GPU ledger, JS heap, ECS tables), and the clock.
78
+ */
79
+ export declare function describePerf(world: World, options?: PerfDescribeOptions): {
80
+ enabled: boolean;
81
+ window: number;
82
+ clock: {
83
+ resolutionMs: number;
84
+ isolated: boolean;
85
+ /** Spans that average under this read mostly the clock's step (`coarse`). */
86
+ coarseBelowMs: number;
87
+ };
88
+ frame: Record<string, unknown> | null;
89
+ schedules: Record<string, unknown>;
90
+ commands: Record<string, unknown>;
91
+ systems: Record<string, unknown>[];
92
+ gpu: "unavailable" | {
93
+ frame: Record<string, unknown> | null;
94
+ quantized: boolean;
95
+ overlapping: boolean;
96
+ passesMs?: number | undefined;
97
+ note?: string | undefined;
98
+ passes: Record<string, unknown>[];
99
+ };
100
+ workers: Record<string, unknown>[];
101
+ async: Record<string, unknown>[];
102
+ memory: {
103
+ gpu: {
104
+ bytes: number;
105
+ byCategory: Record<string, number>;
106
+ };
107
+ heap?: HeapStats;
108
+ ecs: {
109
+ entities: number;
110
+ archetypes: number;
111
+ bytes: number;
112
+ };
113
+ };
114
+ };
115
+ export interface PerfCaptureOptions extends Omit<CaptureOptions, 'devtools'> {
116
+ /** Samples with the host's profiler (V8's in Node, JS Self-Profiling in browsers). */
117
+ sample?: boolean;
118
+ /** Also shows spans in Chrome's Performance panel (`performance.measure`). Allocates. */
119
+ devtools?: boolean;
120
+ /** Writes the trace to `.shard/captures/` when the host can (default true); else it's inline. */
121
+ write?: boolean;
122
+ /**
123
+ * Runs one frame, for hosts without a frame loop (headless, tests). Without it the capture waits
124
+ * for the loop's frames.
125
+ */
126
+ step?: () => void | Promise<void>;
127
+ /** Builds the Chrome trace (default true). Scenario tests (0075) read the capture instead. */
128
+ trace?: boolean;
129
+ /** Returns the capture itself (`result.capture`), for `spanTime` (0075). In-process only. */
130
+ keep?: boolean;
131
+ }
132
+ export interface PerfCaptureResult {
133
+ summary: CaptureSummary;
134
+ /** The capture, with `keep`: per-frame time a budget key covers (`spanTime`), its events. */
135
+ capture?: Capture;
136
+ /** Where the trace went, project-relative. */
137
+ tracePath?: string;
138
+ /** The trace itself, when it wasn't written. */
139
+ trace?: ChromeTrace;
140
+ /** The sampling profile (Node), next to the trace. */
141
+ profilePath?: string;
142
+ }
143
+ /** A file-name stamp: 2026-10-07T12-30-05-123Z. */
144
+ export declare function captureStamp(date?: Date): string;
145
+ /**
146
+ * The spans with the highest p95 over the profiler's window, for perf records (0062's version 2):
147
+ * CPU (main thread, workers, async) and GPU, `frame` and `gpu:frame` aside.
148
+ */
149
+ export declare function perfBreakdown(world: World, limit?: number): {
150
+ cpu: SpanStats[];
151
+ gpu: SpanStats[];
152
+ };
153
+ /**
154
+ * Captures N frames, or a flight recorder's window around a slow frame (0074). Resolves with the
155
+ * summary, and the trace's path (or the trace) once it has them.
156
+ */
157
+ export declare function capturePerf(world: World, options?: PerfCaptureOptions): Promise<PerfCaptureResult>;
package/dist/perf.js ADDED
@@ -0,0 +1,245 @@
1
+ import {
2
+ clockInfo,
3
+ defineResource,
4
+ ProfilerResource,
5
+ ShardError,
6
+ spanCovers,
7
+ spanStats,
8
+ TRACK,
9
+ trackName
10
+ } from "@aethervtt/shard-core";
11
+ const PerfHostResource = defineResource("runtime/PerfHost", {
12
+ description: "The host's profiling instruments (0074): heap, GC, sampling, and where traces go."
13
+ });
14
+ const PerfProviders = defineResource("runtime/PerfProviders", {
15
+ description: "What packages add to perf.describe and capture summaries: GPU memory, sections.",
16
+ init: () => ({ sections: /* @__PURE__ */ new Map() })
17
+ });
18
+ const r3 = (ms) => Math.round(ms * 1e3) / 1e3;
19
+ function rounded(a) {
20
+ const out = {
21
+ span: a.span,
22
+ last: r3(a.last),
23
+ avg: r3(a.avg),
24
+ p50: r3(a.p50),
25
+ p95: r3(a.p95),
26
+ max: r3(a.max),
27
+ samples: a.samples
28
+ };
29
+ if (a.coarse) out.coarse = true;
30
+ return out;
31
+ }
32
+ function heapOf(world) {
33
+ const fromHost = world.tryResource(PerfHostResource)?.heap?.();
34
+ if (fromHost) return fromHost;
35
+ const g = globalThis;
36
+ const usage = g.process?.memoryUsage?.();
37
+ if (usage) return { usedBytes: usage.heapUsed, totalBytes: usage.heapTotal, source: "v8" };
38
+ const memory = g.performance?.memory;
39
+ if (memory) {
40
+ return {
41
+ usedBytes: memory.usedJSHeapSize,
42
+ totalBytes: memory.totalJSHeapSize,
43
+ limitBytes: memory.jsHeapSizeLimit,
44
+ source: "performance.memory"
45
+ };
46
+ }
47
+ return void 0;
48
+ }
49
+ function perfMemory(world) {
50
+ const gpu = world.tryResource(PerfProviders)?.gpuMemory?.(world) ?? { bytes: 0, byCategory: {} };
51
+ const heap = heapOf(world);
52
+ return heap ? { gpu, heap } : { gpu };
53
+ }
54
+ const OVERLAP_RATIO = 1.1;
55
+ function isGpuPass(name) {
56
+ return name.startsWith("gpu:") && name !== "gpu:frame" && !name.startsWith("gpu:span/");
57
+ }
58
+ function gpuPassOverlap(profiler) {
59
+ const frame = profiler.timing("gpu:frame");
60
+ if (!frame || frame.avg <= 0) return void 0;
61
+ let passes = 0;
62
+ for (const name of profiler.names()) {
63
+ if (isGpuPass(name)) passes += profiler.timing(name).avg;
64
+ }
65
+ return {
66
+ passesMs: r3(passes),
67
+ frameMs: r3(frame.avg),
68
+ overlapping: passes > frame.avg * OVERLAP_RATIO
69
+ };
70
+ }
71
+ function describePerf(world, options = {}) {
72
+ const profiler = world.resource(ProfilerResource);
73
+ const keys = options.spans;
74
+ const top = options.top ?? 30;
75
+ const wanted = (name) => !keys || keys.some((k) => spanCovers(k, name));
76
+ const byTrack = { main: [], gpu: [], worker: [], async: [] };
77
+ const schedules = {};
78
+ const commands = {};
79
+ for (const name of profiler.names()) {
80
+ if (!wanted(name)) continue;
81
+ const stats = spanStats(profiler, name);
82
+ if (name.startsWith("schedule/")) schedules[name] = rounded(stats);
83
+ else if (name.startsWith("commands/")) commands[name] = rounded(stats);
84
+ else if (name !== "frame" && name !== "gpu:frame") byTrack[stats.track]?.push(stats);
85
+ }
86
+ const list = (track) => (byTrack[track] ?? []).sort((a, b) => b.avg - a.avg || (a.span < b.span ? -1 : 1)).slice(0, top).map(rounded);
87
+ const frame = spanStats(profiler, "frame");
88
+ const gpuFrame = spanStats(profiler, "gpu:frame");
89
+ const overlap = gpuPassOverlap(profiler);
90
+ const clock = clockInfo();
91
+ const ecs = world.stats();
92
+ let ecsBytes = 0;
93
+ for (const t of ecs.tables) ecsBytes += t.bytes;
94
+ const providers = world.tryResource(PerfProviders);
95
+ const sections = {};
96
+ for (const [name, fn] of providers?.sections ?? []) sections[name] = fn(world);
97
+ return {
98
+ enabled: profiler.enabled,
99
+ window: profiler.window,
100
+ clock: {
101
+ resolutionMs: clock.resolutionMs,
102
+ isolated: clock.isolated,
103
+ /** Spans that average under this read mostly the clock's step (`coarse`). */
104
+ coarseBelowMs: r3(clock.resolutionMs * 4)
105
+ },
106
+ frame: frame ? rounded(frame) : null,
107
+ schedules,
108
+ commands,
109
+ systems: list("main"),
110
+ gpu: profiler.gpu.status === "unavailable" ? "unavailable" : {
111
+ frame: gpuFrame ? rounded(gpuFrame) : null,
112
+ quantized: profiler.gpu.quantized,
113
+ // 0075: pass times that add up to more than the frame overlap, and don't rank passes.
114
+ overlapping: overlap?.overlapping ?? false,
115
+ ...overlap?.overlapping ? {
116
+ passesMs: overlap.passesMs,
117
+ note: "Pass times overlap (a tile-based GPU): each includes waiting for earlier work, so they sum past gpu:frame and do not rank passes. perf.ablate measures what each pass costs."
118
+ } : {},
119
+ passes: list("gpu")
120
+ },
121
+ workers: list("worker"),
122
+ async: list("async"),
123
+ memory: {
124
+ ...perfMemory(world),
125
+ ecs: { entities: ecs.entities, archetypes: ecs.archetypes, bytes: ecsBytes }
126
+ },
127
+ ...sections
128
+ };
129
+ }
130
+ function captureStamp(date = /* @__PURE__ */ new Date()) {
131
+ return date.toISOString().replace(/[:.]/g, "-");
132
+ }
133
+ function perfBreakdown(world, limit = 10) {
134
+ const profiler = world.resource(ProfilerResource);
135
+ const cpu = [];
136
+ const gpu = [];
137
+ for (const name of profiler.names()) {
138
+ if (name === "frame" || name === "gpu:frame") continue;
139
+ const a = spanStats(profiler, name);
140
+ const stats = {
141
+ span: name,
142
+ track: a.track,
143
+ calls: a.samples,
144
+ total: r3(a.avg * a.samples),
145
+ p50: r3(a.p50),
146
+ p95: r3(a.p95),
147
+ max: r3(a.max)
148
+ };
149
+ (a.track === "gpu" ? gpu : cpu).push(stats);
150
+ }
151
+ const order = (x, y) => y.p95 - x.p95 || (x.span < y.span ? -1 : 1);
152
+ return { cpu: cpu.sort(order).slice(0, limit), gpu: gpu.sort(order).slice(0, limit) };
153
+ }
154
+ const gcNames = /* @__PURE__ */ new Map();
155
+ async function capturePerf(world, options = {}) {
156
+ const profiler = world.resource(ProfilerResource);
157
+ if (profiler.sink) {
158
+ throw new ShardError("perf/capture-running", "A capture is already running on this app", {
159
+ hint: "Wait for it to finish, then capture again."
160
+ });
161
+ }
162
+ const { startCapture } = await import("@aethervtt/shard-core/capture");
163
+ const host = world.tryResource(PerfHostResource);
164
+ const warnings = [];
165
+ let sampler;
166
+ if (options.sample) {
167
+ sampler = await host?.startSampler?.();
168
+ if (!sampler) {
169
+ warnings.push(
170
+ new ShardError("perf/sampling-unavailable", "This host can't sample JavaScript", {
171
+ hint: "Node samples with node:inspector; browsers need the JS Self-Profiling API and a Document-Policy: js-profiling header (shard dev and the playground send it)."
172
+ }).toJSON()
173
+ );
174
+ }
175
+ }
176
+ const measure = host?.measure;
177
+ const { step, sample: _, devtools, write, trace: wantTrace, keep, ...captureOptions } = options;
178
+ const recorder = startCapture(profiler, {
179
+ ...captureOptions,
180
+ devtools: devtools && measure ? (name, track, start, ms) => measure(name, trackName(track), start, ms) : void 0
181
+ });
182
+ const offGc = host?.onGc?.((start, ms, kind) => {
183
+ let name = gcNames.get(kind);
184
+ if (!name) {
185
+ name = `gc/${kind}`;
186
+ gcNames.set(kind, name);
187
+ }
188
+ profiler.event(name, TRACK.gc, start, ms);
189
+ });
190
+ let capture;
191
+ try {
192
+ if (step) {
193
+ while (recorder.running) await step();
194
+ } else {
195
+ const limitMs = ((options.timeout ?? 60) + (options.frames ?? 300) / 10) * 1e3;
196
+ const timer = setTimeout(() => recorder.stop(), limitMs);
197
+ try {
198
+ await recorder.done;
199
+ } finally {
200
+ clearTimeout(timer);
201
+ }
202
+ }
203
+ capture = await recorder.done;
204
+ } catch (err) {
205
+ recorder.stop();
206
+ throw err;
207
+ } finally {
208
+ offGc?.();
209
+ }
210
+ const sampled = sampler ? await sampler.stop() : void 0;
211
+ const summary = capture.summary({
212
+ clock: { ...clockInfo(), gpuQuantized: profiler.gpu.quantized },
213
+ memory: perfMemory(world),
214
+ hottest: sampled?.hottest,
215
+ warnings
216
+ });
217
+ const result = { summary };
218
+ if (keep) result.capture = capture;
219
+ if (wantTrace === false) return result;
220
+ const trace = capture.trace({ samples: sampled?.samples });
221
+ if (write !== false && host?.writeText) {
222
+ const stamp = captureStamp();
223
+ result.tracePath = `.shard/captures/${stamp}.trace.json`;
224
+ await host.writeText(result.tracePath, JSON.stringify(trace));
225
+ if (sampled?.cpuprofile) {
226
+ result.profilePath = `.shard/captures/${stamp}.cpuprofile`;
227
+ await host.writeText(result.profilePath, JSON.stringify(sampled.cpuprofile));
228
+ }
229
+ } else {
230
+ result.trace = trace;
231
+ }
232
+ return result;
233
+ }
234
+ export {
235
+ OVERLAP_RATIO,
236
+ PerfHostResource,
237
+ PerfProviders,
238
+ capturePerf,
239
+ captureStamp,
240
+ describePerf,
241
+ gpuPassOverlap,
242
+ isGpuPass,
243
+ perfBreakdown,
244
+ perfMemory
245
+ };
@@ -0,0 +1,9 @@
1
+ export interface PerfScenarioData {
2
+ /** A scenario of budgets.json, or null: no scenario, so nothing adapts to a slice. */
3
+ name: string | null;
4
+ }
5
+ /**
6
+ * The scenario the app declares (`App.perfScenario`): features that adapt (0045's foliage) take
7
+ * their slice of its frame as their target, and the `perf` overlay shows its slices.
8
+ */
9
+ export declare const PerfScenario: import("@aethervtt/shard-core").ResourceDef<PerfScenarioData>;
@@ -0,0 +1,9 @@
1
+ import { defineResource } from "@aethervtt/shard-core";
2
+ const PerfScenario = defineResource("runtime/PerfScenario", {
3
+ description: "The budget scenario the app runs as (0075): features adapt to its slices, and the perf overlay shows them. null: none.",
4
+ hostWritable: true,
5
+ init: () => ({ name: null })
6
+ });
7
+ export {
8
+ PerfScenario
9
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aethervtt/shard-runtime",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -20,7 +20,7 @@
20
20
  "README.md"
21
21
  ],
22
22
  "dependencies": {
23
- "@aethervtt/shard-core": "0.1.0"
23
+ "@aethervtt/shard-core": "0.2.1"
24
24
  },
25
25
  "repository": {
26
26
  "type": "git",
@@ -32,6 +32,6 @@
32
32
  "provenance": true
33
33
  },
34
34
  "shard": {
35
- "buildSha": "8e14c9896f82a3554b156f1ffdd68f2cc3e7d78a"
35
+ "buildSha": "772eb0d2b8a5fc499105708f4ae92c58788cc9ea"
36
36
  }
37
37
  }