@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/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
|
+
};
|
package/dist/plugin.d.ts
CHANGED
|
@@ -14,5 +14,17 @@ export interface Plugin {
|
|
|
14
14
|
build(app: App): void;
|
|
15
15
|
/** Optional async setup (e.g. loading WASM), awaited before Startup. */
|
|
16
16
|
ready?(app: App): Promise<void> | void;
|
|
17
|
+
/**
|
|
18
|
+
* Releases what `build` and `ready` created that outlives the app: GPU objects, surfaces, DOM
|
|
19
|
+
* listeners, observers, workers. `app.dispose()` calls it in reverse build order, after the
|
|
20
|
+
* runner stopped (0052).
|
|
21
|
+
*/
|
|
22
|
+
dispose?(app: App): Promise<void> | void;
|
|
23
|
+
/**
|
|
24
|
+
* Waits for work the plugin started that is still in flight (GPU commands submitted but not
|
|
25
|
+
* done). `app.dispose()` awaits it on every plugin before any plugin's `dispose`, so nothing is
|
|
26
|
+
* released while still in use: render's dispose runs last, after others freed their buffers.
|
|
27
|
+
*/
|
|
28
|
+
beforeDispose?(app: App): Promise<void> | void;
|
|
17
29
|
}
|
|
18
30
|
export declare function definePlugin(plugin: Plugin): Plugin;
|
package/dist/runners.d.ts
CHANGED
|
@@ -11,13 +11,28 @@ export declare function headlessRunner(options: HeadlessOptions): Runner;
|
|
|
11
11
|
export interface AnimationFrameOptions {
|
|
12
12
|
/** Longest delta passed to update, in seconds, e.g. after the tab was hidden. Default 0.25. */
|
|
13
13
|
maxDelta?: number;
|
|
14
|
-
/** Stops the loop when aborted. */
|
|
14
|
+
/** Stops the loop when aborted. `app.dispose()` stops it too. */
|
|
15
15
|
signal?: AbortSignal;
|
|
16
16
|
/**
|
|
17
17
|
* Time a few idle frames before the first update to measure the display's refresh rate into
|
|
18
18
|
* DisplayRate (about 12 refreshes: 0.1 s at 120 Hz). Default true.
|
|
19
19
|
*/
|
|
20
20
|
measureRefresh?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* continuous (default): a frame every display refresh. on-demand: a frame only when something
|
|
23
|
+
* changed (0052): a world write, a FrameDemand holder or timer, input, a surface resize, queued
|
|
24
|
+
* steps, or `app.requestFrame()`. Idle, it requests no animation frames at all.
|
|
25
|
+
*/
|
|
26
|
+
mode?: 'continuous' | 'on-demand';
|
|
27
|
+
/**
|
|
28
|
+
* On-demand, for dev builds: every second while idle, compare the plain-object resources marked
|
|
29
|
+
* `hostWritable` with their values when the app went idle, and log
|
|
30
|
+
* `runtime/unmarked-resource-write` for one that changed without waking it. Default false.
|
|
31
|
+
*/
|
|
32
|
+
checkResourceWrites?: boolean;
|
|
21
33
|
}
|
|
22
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Runs frames on `requestAnimationFrame`, for the browser and Studio: one per display refresh, or
|
|
36
|
+
* in `mode: 'on-demand'` only while something needs them (0052).
|
|
37
|
+
*/
|
|
23
38
|
export declare function animationFrameRunner(options?: AnimationFrameOptions): Runner;
|
package/dist/runners.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AppControlResource } from "./control.js";
|
|
2
|
+
import { FrameDemand } from "./demand.js";
|
|
2
3
|
import { RefreshMeter, rateFromIntervals } from "./display.js";
|
|
3
4
|
import { LogResource } from "./log.js";
|
|
4
5
|
import { DisplayRate } from "./time.js";
|
|
@@ -9,76 +10,157 @@ function headlessRunner(options) {
|
|
|
9
10
|
};
|
|
10
11
|
}
|
|
11
12
|
const PROBE_INTERVALS = 12;
|
|
12
|
-
function probeRefresh() {
|
|
13
|
+
function probeRefresh(probe) {
|
|
13
14
|
return new Promise((resolve) => {
|
|
14
15
|
if (document.hidden) return resolve(0);
|
|
15
16
|
const intervals = new Float64Array(PROBE_INTERVALS);
|
|
16
17
|
let last = -1;
|
|
17
18
|
let n = 0;
|
|
18
19
|
const tick = (t) => {
|
|
19
|
-
if (document.hidden) return resolve(0);
|
|
20
|
+
if (probe.stopped || document.hidden) return resolve(0);
|
|
20
21
|
if (last >= 0) intervals[n++] = t - last;
|
|
21
22
|
last = t;
|
|
22
|
-
if (n < PROBE_INTERVALS) requestAnimationFrame(tick);
|
|
23
|
+
if (n < PROBE_INTERVALS) probe.handle = requestAnimationFrame(tick);
|
|
23
24
|
else resolve(rateFromIntervals(intervals));
|
|
24
25
|
};
|
|
25
|
-
requestAnimationFrame(tick);
|
|
26
|
+
probe.handle = requestAnimationFrame(tick);
|
|
26
27
|
});
|
|
27
28
|
}
|
|
28
29
|
function animationFrameRunner(options = {}) {
|
|
29
30
|
const maxDelta = options.maxDelta ?? 0.25;
|
|
31
|
+
const onDemand = options.mode === "on-demand";
|
|
30
32
|
return async (app) => {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
const world = app.world;
|
|
34
|
+
const demand = world.resource(FrameDemand);
|
|
35
|
+
const control = world.resource(AppControlResource);
|
|
36
|
+
const display = world.tryResource(DisplayRate);
|
|
37
|
+
const probe = { handle: 0, stopped: false };
|
|
38
|
+
let handle = 0;
|
|
39
|
+
let pending = false;
|
|
40
|
+
let idle = true;
|
|
41
|
+
let last;
|
|
42
|
+
let timer;
|
|
43
|
+
let writes;
|
|
44
|
+
let finish = () => {
|
|
45
|
+
};
|
|
46
|
+
const finished = new Promise((resolve) => {
|
|
47
|
+
finish = resolve;
|
|
48
|
+
});
|
|
49
|
+
const schedule = () => {
|
|
50
|
+
if (pending || probe.stopped) return;
|
|
51
|
+
pending = true;
|
|
52
|
+
handle = requestAnimationFrame(frame);
|
|
53
|
+
};
|
|
54
|
+
const onTimer = () => {
|
|
55
|
+
timer = void 0;
|
|
56
|
+
schedule();
|
|
57
|
+
};
|
|
58
|
+
const armTimer = () => {
|
|
59
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
60
|
+
timer = void 0;
|
|
61
|
+
const due = demand.dueIn();
|
|
62
|
+
if (due !== Number.POSITIVE_INFINITY) timer = setTimeout(onTimer, due);
|
|
63
|
+
};
|
|
64
|
+
const goIdle = () => {
|
|
65
|
+
idle = true;
|
|
66
|
+
world.asleep = true;
|
|
67
|
+
armTimer();
|
|
68
|
+
writes?.start();
|
|
69
|
+
};
|
|
70
|
+
const onVisibility = () => {
|
|
71
|
+
if (document.hidden) last = void 0;
|
|
72
|
+
};
|
|
73
|
+
const stop = () => {
|
|
74
|
+
if (probe.stopped) return;
|
|
75
|
+
probe.stopped = true;
|
|
76
|
+
cancelAnimationFrame(probe.handle);
|
|
77
|
+
if (pending) cancelAnimationFrame(handle);
|
|
78
|
+
pending = false;
|
|
79
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
80
|
+
timer = void 0;
|
|
81
|
+
writes?.stop();
|
|
82
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
83
|
+
options.signal?.removeEventListener("abort", stop);
|
|
84
|
+
if (onDemand) {
|
|
85
|
+
world.onWake = void 0;
|
|
86
|
+
world.asleep = false;
|
|
87
|
+
demand.onDemand = void 0;
|
|
88
|
+
}
|
|
89
|
+
demand.mode = "manual";
|
|
90
|
+
finish();
|
|
91
|
+
};
|
|
92
|
+
const meter = new RefreshMeter(display?.periodMs ?? 1e3 / 60);
|
|
93
|
+
const frame = (timestamp) => {
|
|
94
|
+
pending = false;
|
|
95
|
+
if (probe.stopped) return;
|
|
96
|
+
const woke = idle;
|
|
97
|
+
idle = false;
|
|
98
|
+
writes?.stop();
|
|
99
|
+
let delta = last === void 0 ? 0 : Math.min((timestamp - last) / 1e3, maxDelta);
|
|
100
|
+
if (onDemand) {
|
|
101
|
+
world.asleep = false;
|
|
102
|
+
demand.takeDue();
|
|
103
|
+
if (woke && last !== void 0) {
|
|
104
|
+
delta = 1 / app.fixedHz;
|
|
105
|
+
app.resetFixedTime();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (display && last !== void 0 && !woke) {
|
|
109
|
+
const hz = meter.sample(timestamp - last);
|
|
110
|
+
if (hz > 0) {
|
|
111
|
+
display.hz = hz;
|
|
112
|
+
display.periodMs = 1e3 / hz;
|
|
113
|
+
display.source = "measured";
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
last = timestamp;
|
|
117
|
+
try {
|
|
118
|
+
if (!control.paused) app.update(delta);
|
|
119
|
+
else if (control.pendingSteps > 0) {
|
|
120
|
+
app.update(1 / app.fixedHz);
|
|
121
|
+
control.stepped();
|
|
122
|
+
}
|
|
123
|
+
} catch (err) {
|
|
124
|
+
world.resource(LogResource).error(err);
|
|
125
|
+
control.paused = true;
|
|
126
|
+
control.pausedByError = true;
|
|
127
|
+
control.abort(err);
|
|
128
|
+
console.error(err);
|
|
129
|
+
}
|
|
130
|
+
if (probe.stopped) return;
|
|
131
|
+
if (!onDemand || demand.active || control.pendingSteps > 0) schedule();
|
|
132
|
+
if (!pending) goIdle();
|
|
133
|
+
};
|
|
134
|
+
options.signal?.addEventListener("abort", stop);
|
|
135
|
+
app.attachDriver({ requestFrame: onDemand ? schedule : () => {
|
|
136
|
+
}, stop });
|
|
137
|
+
demand.mode = onDemand ? "on-demand" : "continuous";
|
|
138
|
+
if (options.signal?.aborted) stop();
|
|
139
|
+
if (display && options.measureRefresh !== false && !probe.stopped) {
|
|
140
|
+
const hz = await probeRefresh(probe);
|
|
34
141
|
if (hz > 0) {
|
|
35
142
|
display.hz = hz;
|
|
36
143
|
display.periodMs = 1e3 / hz;
|
|
37
144
|
display.source = "measured";
|
|
145
|
+
meter.periodMs = display.periodMs;
|
|
38
146
|
}
|
|
39
147
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
if (hz > 0) {
|
|
52
|
-
display.hz = hz;
|
|
53
|
-
display.periodMs = 1e3 / hz;
|
|
54
|
-
display.source = "measured";
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
last = timestamp;
|
|
58
|
-
const control = app.world.resource(AppControlResource);
|
|
59
|
-
try {
|
|
60
|
-
if (!control.paused) app.update(delta);
|
|
61
|
-
else if (control.pendingSteps > 0) {
|
|
62
|
-
app.update(1 / app.fixedHz);
|
|
63
|
-
control.stepped();
|
|
64
|
-
}
|
|
65
|
-
} catch (err) {
|
|
66
|
-
app.world.resource(LogResource).error(err);
|
|
67
|
-
control.paused = true;
|
|
68
|
-
control.pausedByError = true;
|
|
69
|
-
control.abort(err);
|
|
70
|
-
console.error(err);
|
|
71
|
-
}
|
|
72
|
-
handle = requestAnimationFrame(frame);
|
|
148
|
+
if (onDemand && options.checkResourceWrites) {
|
|
149
|
+
const { ResourceWriteCheck } = await import("./write-check.js");
|
|
150
|
+
writes = new ResourceWriteCheck(world, world.tryResource(LogResource), () => idle && !pending);
|
|
151
|
+
}
|
|
152
|
+
if (probe.stopped) return;
|
|
153
|
+
if (onDemand) {
|
|
154
|
+
world.onWake = schedule;
|
|
155
|
+
demand.onDemand = () => {
|
|
156
|
+
if (!idle || probe.stopped) return;
|
|
157
|
+
if (demand.active) schedule();
|
|
158
|
+
else armTimer();
|
|
73
159
|
};
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
resolve();
|
|
79
|
-
});
|
|
80
|
-
handle = requestAnimationFrame(frame);
|
|
81
|
-
});
|
|
160
|
+
}
|
|
161
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
162
|
+
schedule();
|
|
163
|
+
return finished;
|
|
82
164
|
};
|
|
83
165
|
}
|
|
84
166
|
export {
|
|
@@ -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>;
|
package/dist/scenario.js
ADDED
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stand-in for the browser's frame loop, so `animationFrameRunner` runs headless in tests: it
|
|
3
|
+
* installs `requestAnimationFrame`, `cancelAnimationFrame`, and (when there's none) a visible
|
|
4
|
+
* `document` on globalThis. Frames only run when the test ticks.
|
|
5
|
+
*/
|
|
6
|
+
export interface FakeFrames {
|
|
7
|
+
/** Animation frames requested so far. */
|
|
8
|
+
readonly requested: number;
|
|
9
|
+
/** Callbacks waiting for the next frame. */
|
|
10
|
+
readonly pending: number;
|
|
11
|
+
/** The timestamp the last tick passed, in ms. */
|
|
12
|
+
readonly now: number;
|
|
13
|
+
/** `visibilitychange` listeners on the fake document. */
|
|
14
|
+
readonly listeners: number;
|
|
15
|
+
/** Advances the clock by `ms` (default one 60 Hz refresh) and runs what was waiting. */
|
|
16
|
+
tick(ms?: number): number;
|
|
17
|
+
/** Ticks until nothing waits or `max` frames ran; returns the frames run. */
|
|
18
|
+
runUntilIdle(max?: number, ms?: number): number;
|
|
19
|
+
/** Puts the real globals back. */
|
|
20
|
+
restore(): void;
|
|
21
|
+
}
|
|
22
|
+
export declare function fakeAnimationFrames(): FakeFrames;
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
function fakeAnimationFrames() {
|
|
2
|
+
const g = globalThis;
|
|
3
|
+
const saved = {
|
|
4
|
+
raf: g.requestAnimationFrame,
|
|
5
|
+
caf: g.cancelAnimationFrame,
|
|
6
|
+
document: g.document
|
|
7
|
+
};
|
|
8
|
+
let waiting = /* @__PURE__ */ new Map();
|
|
9
|
+
let nextId = 1;
|
|
10
|
+
let requested = 0;
|
|
11
|
+
let now = 0;
|
|
12
|
+
let listeners = 0;
|
|
13
|
+
g.requestAnimationFrame = (fn) => {
|
|
14
|
+
const id = nextId++;
|
|
15
|
+
requested++;
|
|
16
|
+
waiting.set(id, fn);
|
|
17
|
+
return id;
|
|
18
|
+
};
|
|
19
|
+
g.cancelAnimationFrame = (id) => {
|
|
20
|
+
waiting.delete(id);
|
|
21
|
+
};
|
|
22
|
+
if (saved.document === void 0) {
|
|
23
|
+
g.document = {
|
|
24
|
+
hidden: false,
|
|
25
|
+
addEventListener: () => void listeners++,
|
|
26
|
+
removeEventListener: () => void listeners--
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const tick = (ms = 1e3 / 60) => {
|
|
30
|
+
now += ms;
|
|
31
|
+
const run = waiting;
|
|
32
|
+
waiting = /* @__PURE__ */ new Map();
|
|
33
|
+
for (const fn of run.values()) fn(now);
|
|
34
|
+
return run.size;
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
get requested() {
|
|
38
|
+
return requested;
|
|
39
|
+
},
|
|
40
|
+
get pending() {
|
|
41
|
+
return waiting.size;
|
|
42
|
+
},
|
|
43
|
+
get now() {
|
|
44
|
+
return now;
|
|
45
|
+
},
|
|
46
|
+
get listeners() {
|
|
47
|
+
return listeners;
|
|
48
|
+
},
|
|
49
|
+
tick,
|
|
50
|
+
runUntilIdle(max = 1e4, ms) {
|
|
51
|
+
let frames = 0;
|
|
52
|
+
while (waiting.size > 0 && frames < max) frames += tick(ms);
|
|
53
|
+
return frames;
|
|
54
|
+
},
|
|
55
|
+
restore() {
|
|
56
|
+
g.requestAnimationFrame = saved.raf;
|
|
57
|
+
g.cancelAnimationFrame = saved.caf;
|
|
58
|
+
g.document = saved.document;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export {
|
|
63
|
+
fakeAnimationFrames
|
|
64
|
+
};
|
package/dist/time.d.ts
CHANGED
|
@@ -32,3 +32,7 @@ export interface DisplayRateData {
|
|
|
32
32
|
}
|
|
33
33
|
export declare const DisplayRate: import("@aethervtt/shard-core").ResourceDef<DisplayRateData>;
|
|
34
34
|
export declare const GlobalRng: import("@aethervtt/shard-core").ResourceDef<Rng>;
|
|
35
|
+
/** Whether this is a development build (`new App({ dev: true })`). */
|
|
36
|
+
export declare const DevMode: import("@aethervtt/shard-core").ResourceDef<{
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
}>;
|
package/dist/time.js
CHANGED
|
@@ -11,7 +11,12 @@ const DisplayRate = defineResource("core/DisplayRate", {
|
|
|
11
11
|
const GlobalRng = defineResource("core/GlobalRng", {
|
|
12
12
|
description: "The app root random stream, seeded from AppOptions.seed. Fork it per system."
|
|
13
13
|
});
|
|
14
|
+
const DevMode = defineResource("runtime/DevMode", {
|
|
15
|
+
description: "Development build: loud fallbacks and dev-only checks.",
|
|
16
|
+
init: () => ({ enabled: false })
|
|
17
|
+
});
|
|
14
18
|
export {
|
|
19
|
+
DevMode,
|
|
15
20
|
DisplayRate,
|
|
16
21
|
FixedTime,
|
|
17
22
|
GlobalRng,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type World } from '@aethervtt/shard-core';
|
|
2
|
+
import type { Log } from './log.js';
|
|
3
|
+
/**
|
|
4
|
+
* The on-demand runner's dev check (0052): while the app is idle, every second, compare the
|
|
5
|
+
* plain-object resources marked `hostWritable` with their values when it went idle, and log
|
|
6
|
+
* `runtime/unmarked-resource-write` for one that changed without waking the app. Loaded only when
|
|
7
|
+
* `checkResourceWrites` is on, so production builds don't carry it.
|
|
8
|
+
*/
|
|
9
|
+
export declare class ResourceWriteCheck {
|
|
10
|
+
private readonly snapshots;
|
|
11
|
+
private timer;
|
|
12
|
+
private readonly world;
|
|
13
|
+
private readonly log;
|
|
14
|
+
private readonly quiet;
|
|
15
|
+
/** `quiet`: still idle with no frame requested, so a change nobody announced is a bug. */
|
|
16
|
+
constructor(world: World, log: Log | undefined, quiet: () => boolean);
|
|
17
|
+
/** The app went idle: remember what the resources hold, and check them every second. */
|
|
18
|
+
start(): void;
|
|
19
|
+
/** A frame is running (or the loop stopped): writes are fine again. */
|
|
20
|
+
stop(): void;
|
|
21
|
+
private verify;
|
|
22
|
+
}
|