@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.d.ts +145 -1
- package/dist/app.js +333 -17
- package/dist/budgets.d.ts +197 -0
- package/dist/budgets.js +256 -0
- package/dist/control.d.ts +2 -0
- package/dist/control.js +7 -1
- package/dist/demand.d.ts +47 -0
- package/dist/demand.js +68 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +55 -1
- package/dist/perf.d.ts +157 -0
- package/dist/perf.js +245 -0
- package/dist/plugin.d.ts +12 -0
- package/dist/runners.d.ts +17 -2
- package/dist/runners.js +130 -48
- package/dist/scenario.d.ts +9 -0
- package/dist/scenario.js +9 -0
- package/dist/testing.d.ts +22 -0
- package/dist/testing.js +64 -0
- package/dist/time.d.ts +4 -0
- package/dist/time.js +5 -0
- package/dist/write-check.d.ts +22 -0
- package/dist/write-check.js +54 -0
- package/package.json +7 -3
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 {
|
|
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
|
|
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.
|
|
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.
|
|
169
|
-
|
|
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.
|
|
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
|
|
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;
|