game-koi 0.1.2 → 0.3.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/README.md +82 -4
- package/dist/events.d.ts +63 -0
- package/dist/events.js +59 -0
- package/dist/gamepad.d.ts +85 -0
- package/dist/gamepad.js +158 -0
- package/dist/index.d.ts +89 -0
- package/dist/index.js +184 -6
- package/dist/overlay.d.ts +87 -0
- package/dist/overlay.js +245 -0
- package/dist/stats.d.ts +116 -0
- package/dist/stats.js +120 -0
- package/dist/worklet.d.ts +1 -1
- package/dist/worklet.js +19 -2
- package/package.json +2 -1
- package/wasm/game_koi_web_bg.wasm +0 -0
package/dist/index.js
CHANGED
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import init, { Emulator } from "../wasm/game_koi_web.js";
|
|
11
11
|
import { WORKLET_SOURCE } from "./worklet.js";
|
|
12
|
+
import { DEFAULT_GAMEPAD_MAP, GamepadInput } from "./gamepad.js";
|
|
13
|
+
import { Emitter } from "./events.js";
|
|
14
|
+
import { FrameStats } from "./stats.js";
|
|
15
|
+
import { StatsOverlay } from "./overlay.js";
|
|
12
16
|
const DEFAULT_KEYMAP = {
|
|
13
17
|
ArrowRight: "right",
|
|
14
18
|
ArrowLeft: "left",
|
|
@@ -35,29 +39,46 @@ export class GameKoi {
|
|
|
35
39
|
wasmMemory;
|
|
36
40
|
imageData;
|
|
37
41
|
keymap;
|
|
42
|
+
gamepad;
|
|
38
43
|
targetBuffer;
|
|
39
44
|
maxFramesPerWake;
|
|
45
|
+
stats;
|
|
46
|
+
emitter = new Emitter();
|
|
47
|
+
/** What the joypad currently has down, whichever device put it there. */
|
|
48
|
+
held = new Set();
|
|
40
49
|
emulator;
|
|
41
50
|
buffered = 0;
|
|
51
|
+
/** Cumulative worklet counters, as last reported. See `worklet.ts`. */
|
|
52
|
+
underruns = 0;
|
|
53
|
+
dropped = 0;
|
|
54
|
+
/** Built on first toggle, so a page that never opens it gets no element. */
|
|
55
|
+
overlay = null;
|
|
42
56
|
running = false;
|
|
43
57
|
rafHandle = 0;
|
|
44
58
|
keydownListener;
|
|
45
59
|
keyupListener;
|
|
46
|
-
|
|
60
|
+
overlayListener;
|
|
61
|
+
constructor(emulator, wasmMemory, ctx, audioContext, worklet, keymap, gamepadMap, overlayKey, targetBuffer, maxFramesPerWake) {
|
|
47
62
|
this.emulator = emulator;
|
|
48
63
|
this.wasmMemory = wasmMemory;
|
|
49
64
|
this.ctx = ctx;
|
|
50
65
|
this.audioContext = audioContext;
|
|
51
66
|
this.worklet = worklet;
|
|
52
67
|
this.keymap = keymap;
|
|
68
|
+
this.gamepad = gamepadMap ? new GamepadInput(gamepadMap) : null;
|
|
53
69
|
this.targetBuffer = targetBuffer;
|
|
54
70
|
this.maxFramesPerWake = maxFramesPerWake;
|
|
55
71
|
this.imageData = ctx.createImageData(Emulator.width(), Emulator.height());
|
|
72
|
+
this.stats = new FrameStats(performance.now());
|
|
56
73
|
this.worklet.port.onmessage = (event) => {
|
|
57
74
|
this.buffered = event.data.buffered;
|
|
75
|
+
this.underruns = event.data.underruns;
|
|
76
|
+
this.dropped = event.data.dropped;
|
|
58
77
|
};
|
|
59
78
|
if (keymap)
|
|
60
79
|
this.attachKeyboard();
|
|
80
|
+
if (overlayKey !== null)
|
|
81
|
+
this.attachOverlayKey(overlayKey);
|
|
61
82
|
}
|
|
62
83
|
/**
|
|
63
84
|
* Builds and starts a running emulator.
|
|
@@ -87,25 +108,105 @@ export class GameKoi {
|
|
|
87
108
|
worklet.connect(audioContext.destination);
|
|
88
109
|
const emulator = new Emulator(options.rom, audioContext.sampleRate);
|
|
89
110
|
const keymap = options.keymap === null ? null : options.keymap ?? DEFAULT_KEYMAP;
|
|
90
|
-
const
|
|
111
|
+
const gamepadMap = options.gamepadMap === null ? null : options.gamepadMap ?? DEFAULT_GAMEPAD_MAP;
|
|
112
|
+
const koi = new GameKoi(emulator, wasm.memory, ctx, audioContext, worklet, keymap, gamepadMap, options.overlayKey === null ? null : options.overlayKey ?? "Backquote", options.targetBuffer ?? 1600, options.maxFramesPerWake ?? 4);
|
|
91
113
|
koi.running = true;
|
|
92
114
|
koi.loop();
|
|
93
115
|
return koi;
|
|
94
116
|
}
|
|
95
117
|
/** Swaps in a new ROM, keeping the same canvas and audio graph. */
|
|
96
118
|
loadRom(rom) {
|
|
119
|
+
// The new machine's joypad starts with nothing held, so what this side believes is
|
|
120
|
+
// held has to start over too — both the gamepad's snapshot (or the first poll would
|
|
121
|
+
// emit no press for a direction that was already down) and the set the events are
|
|
122
|
+
// edges against (or a button "already held" would never be pressed on the new
|
|
123
|
+
// machine, and a display would show it stuck down).
|
|
124
|
+
this.gamepad?.releaseAll();
|
|
125
|
+
this.releaseAll();
|
|
97
126
|
this.emulator.free();
|
|
98
127
|
this.emulator = new Emulator(rom, this.audioContext.sampleRate);
|
|
99
128
|
}
|
|
100
129
|
press(button) {
|
|
101
|
-
this.
|
|
130
|
+
this.setButton(button, true, "api");
|
|
102
131
|
}
|
|
103
132
|
release(button) {
|
|
104
|
-
this.
|
|
133
|
+
this.setButton(button, false, "api");
|
|
134
|
+
}
|
|
135
|
+
/** Everything the joypad has down right now — a snapshot, safe to keep. */
|
|
136
|
+
get pressed() {
|
|
137
|
+
return new Set(this.held);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Listens for button edges, from any input path — the built-in keyboard and gamepad
|
|
141
|
+
* handling as well as `press`/`release` calls.
|
|
142
|
+
*
|
|
143
|
+
* Returns a function that removes the listener again:
|
|
144
|
+
*
|
|
145
|
+
* ```ts
|
|
146
|
+
* const stop = koi.on("press", ({ button, pressed }) => render(pressed));
|
|
147
|
+
* koi.on("release", ({ button, pressed }) => render(pressed));
|
|
148
|
+
* ```
|
|
149
|
+
*
|
|
150
|
+
* Only *changes* are reported: a key held down with autorepeat, a stick resting past
|
|
151
|
+
* the threshold, or `press("a")` twice in a row each produce one `press` event and no
|
|
152
|
+
* `release` until the button actually comes up.
|
|
153
|
+
*/
|
|
154
|
+
on(type, listener) {
|
|
155
|
+
return this.emitter.on(type, listener);
|
|
156
|
+
}
|
|
157
|
+
/** Removes a listener registered with {@link on}. */
|
|
158
|
+
off(type, listener) {
|
|
159
|
+
this.emitter.off(type, listener);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The one path to the emulated joypad, so every device's edges are counted once.
|
|
163
|
+
*
|
|
164
|
+
* A repeat is dropped here rather than forwarded: `Joypad::press` is idempotent, so
|
|
165
|
+
* skipping it changes nothing for the machine, and it is what makes the events edges
|
|
166
|
+
* instead of a restatement of whatever the browser felt like repeating.
|
|
167
|
+
*/
|
|
168
|
+
setButton(button, pressed, source) {
|
|
169
|
+
if (this.held.has(button) === pressed)
|
|
170
|
+
return;
|
|
171
|
+
if (pressed) {
|
|
172
|
+
this.held.add(button);
|
|
173
|
+
this.emulator.press(button);
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
this.held.delete(button);
|
|
177
|
+
this.emulator.release(button);
|
|
178
|
+
}
|
|
179
|
+
this.emitter.emit(pressed ? "press" : "release", {
|
|
180
|
+
button,
|
|
181
|
+
source,
|
|
182
|
+
pressed: new Set(this.held),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/** Lets go of everything held, emitting the releases. */
|
|
186
|
+
releaseAll() {
|
|
187
|
+
for (const button of [...this.held])
|
|
188
|
+
this.setButton(button, false, "api");
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Shows or hides the debug stats panel — the same thing the ` key does.
|
|
192
|
+
*
|
|
193
|
+
* The panel is built the first time this is called, so a page that never opens it
|
|
194
|
+
* never gets an extra element in its DOM.
|
|
195
|
+
*/
|
|
196
|
+
toggleOverlay() {
|
|
197
|
+
this.overlay ??= new StatsOverlay(this.ctx.canvas);
|
|
198
|
+
this.overlay.toggle();
|
|
199
|
+
}
|
|
200
|
+
/** Whether the stats panel is currently showing. */
|
|
201
|
+
get overlayVisible() {
|
|
202
|
+
return this.overlay?.visible ?? false;
|
|
105
203
|
}
|
|
106
204
|
pause() {
|
|
107
205
|
this.running = false;
|
|
108
206
|
cancelAnimationFrame(this.rafHandle);
|
|
207
|
+
// A direction held at the moment of the pause has no poll coming to release it,
|
|
208
|
+
// so it would still be down when the machine resumes.
|
|
209
|
+
this.applyGamepadActions(this.gamepad?.releaseAll());
|
|
109
210
|
void this.audioContext.suspend();
|
|
110
211
|
}
|
|
111
212
|
resume() {
|
|
@@ -120,6 +221,10 @@ export class GameKoi {
|
|
|
120
221
|
this.running = false;
|
|
121
222
|
cancelAnimationFrame(this.rafHandle);
|
|
122
223
|
this.detachKeyboard();
|
|
224
|
+
if (this.overlayListener)
|
|
225
|
+
removeEventListener("keydown", this.overlayListener);
|
|
226
|
+
this.emitter.clear();
|
|
227
|
+
this.overlay?.dispose();
|
|
123
228
|
this.emulator.free();
|
|
124
229
|
this.worklet.disconnect();
|
|
125
230
|
void this.audioContext.close();
|
|
@@ -127,6 +232,11 @@ export class GameKoi {
|
|
|
127
232
|
loop = () => {
|
|
128
233
|
if (!this.running)
|
|
129
234
|
return;
|
|
235
|
+
const wokeAt = performance.now();
|
|
236
|
+
this.pollGamepads();
|
|
237
|
+
// Measured from after the input poll: reading a gamepad snapshot is neither
|
|
238
|
+
// emulation nor drawing, and it costs a fraction of a microsecond.
|
|
239
|
+
const emulateStart = performance.now();
|
|
130
240
|
// Emulate however many frames the audio buffer is short by, capped. Audio wins
|
|
131
241
|
// when the display's refresh rate and the Game Boy's 59.73Hz disagree, since a
|
|
132
242
|
// dry buffer is audible and a repeated frame is not.
|
|
@@ -148,10 +258,39 @@ export class GameKoi {
|
|
|
148
258
|
}
|
|
149
259
|
frames++;
|
|
150
260
|
}
|
|
261
|
+
const drawStart = performance.now();
|
|
151
262
|
if (frames > 0)
|
|
152
263
|
this.drawFrame();
|
|
264
|
+
const drawEnd = performance.now();
|
|
265
|
+
this.recordWake(wokeAt, emulateStart, drawStart, drawEnd, frames);
|
|
153
266
|
this.rafHandle = requestAnimationFrame(this.loop);
|
|
154
267
|
};
|
|
268
|
+
/**
|
|
269
|
+
* Feeds the counters and refreshes the panel.
|
|
270
|
+
*
|
|
271
|
+
* Always run, not just while the panel is open: the averages are computed over half a
|
|
272
|
+
* second, so a panel that only started counting when it opened would show nothing for
|
|
273
|
+
* its first window. The cost is four `performance.now()` reads per wake.
|
|
274
|
+
*/
|
|
275
|
+
recordWake(wokeAt, emulateStart, drawStart, drawEnd, frames) {
|
|
276
|
+
this.stats.wake({
|
|
277
|
+
emulate: drawStart - emulateStart,
|
|
278
|
+
draw: frames > 0 ? drawEnd - drawStart : 0,
|
|
279
|
+
frames,
|
|
280
|
+
// The catch-up was clamped and the buffer is still short, so a deficit is
|
|
281
|
+
// being carried into the next wake.
|
|
282
|
+
capped: frames >= this.maxFramesPerWake && this.buffered < this.targetBuffer,
|
|
283
|
+
}, wokeAt);
|
|
284
|
+
this.stats.setAudio({
|
|
285
|
+
buffered: this.buffered,
|
|
286
|
+
target: this.targetBuffer,
|
|
287
|
+
underruns: this.underruns,
|
|
288
|
+
dropped: this.dropped,
|
|
289
|
+
sampleRate: this.audioContext.sampleRate,
|
|
290
|
+
state: this.audioContext.state,
|
|
291
|
+
});
|
|
292
|
+
this.overlay?.update(this.stats.snapshot(), wokeAt);
|
|
293
|
+
}
|
|
155
294
|
drawFrame() {
|
|
156
295
|
// Read `wasmMemory.buffer` fresh rather than caching it: any wasm allocation can
|
|
157
296
|
// grow linear memory, which allocates a new ArrayBuffer and silently detaches
|
|
@@ -161,18 +300,41 @@ export class GameKoi {
|
|
|
161
300
|
this.imageData.data.set(bytes);
|
|
162
301
|
this.ctx.putImageData(this.imageData, 0, 0);
|
|
163
302
|
}
|
|
303
|
+
/**
|
|
304
|
+
* Reads every connected pad and applies whatever changed since the last wake.
|
|
305
|
+
*
|
|
306
|
+
* Done here rather than from a listener because the Gamepad API fires no event for a
|
|
307
|
+
* button: `getGamepads()` is a snapshot and polling it is the only way to see one.
|
|
308
|
+
*/
|
|
309
|
+
pollGamepads() {
|
|
310
|
+
if (!this.gamepad)
|
|
311
|
+
return;
|
|
312
|
+
// Guarded because the API is absent outside a secure context, and in a few
|
|
313
|
+
// embedded webviews that still have no gamepad support at all.
|
|
314
|
+
const pads = navigator.getGamepads?.();
|
|
315
|
+
if (!pads)
|
|
316
|
+
return;
|
|
317
|
+
this.applyGamepadActions(this.gamepad.poll(pads));
|
|
318
|
+
}
|
|
319
|
+
applyGamepadActions(actions) {
|
|
320
|
+
for (const action of actions ?? []) {
|
|
321
|
+
this.setButton(action.button, action.type === "press", "gamepad");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
164
324
|
attachKeyboard() {
|
|
165
325
|
this.keydownListener = (event) => {
|
|
166
326
|
const button = this.keymap?.[event.code];
|
|
167
327
|
if (button) {
|
|
168
|
-
|
|
328
|
+
// Autorepeat still arrives here: `preventDefault` has to run on every repeat,
|
|
329
|
+
// and `setButton` is what collapses them into the one press edge.
|
|
330
|
+
this.setButton(button, true, "keyboard");
|
|
169
331
|
event.preventDefault();
|
|
170
332
|
}
|
|
171
333
|
};
|
|
172
334
|
this.keyupListener = (event) => {
|
|
173
335
|
const button = this.keymap?.[event.code];
|
|
174
336
|
if (button) {
|
|
175
|
-
this.
|
|
337
|
+
this.setButton(button, false, "keyboard");
|
|
176
338
|
event.preventDefault();
|
|
177
339
|
}
|
|
178
340
|
};
|
|
@@ -185,4 +347,20 @@ export class GameKoi {
|
|
|
185
347
|
if (this.keyupListener)
|
|
186
348
|
removeEventListener("keyup", this.keyupListener);
|
|
187
349
|
}
|
|
350
|
+
/**
|
|
351
|
+
* Binds the panel's toggle key.
|
|
352
|
+
*
|
|
353
|
+
* Its own listener rather than a case inside the keymap one, because the two are
|
|
354
|
+
* independent: a page that drives the joypad itself (`keymap: null`) can still want
|
|
355
|
+
* the panel, and the panel key is not a Game Boy button.
|
|
356
|
+
*/
|
|
357
|
+
attachOverlayKey(code) {
|
|
358
|
+
this.overlayListener = (event) => {
|
|
359
|
+
if (event.code !== code || event.repeat)
|
|
360
|
+
return;
|
|
361
|
+
this.toggleOverlay();
|
|
362
|
+
event.preventDefault();
|
|
363
|
+
};
|
|
364
|
+
addEventListener("keydown", this.overlayListener);
|
|
365
|
+
}
|
|
188
366
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The debug stats panel.
|
|
3
|
+
*
|
|
4
|
+
* This is the browser counterpart of `game-koi-desktop`'s `overlay.rs`, and the split is
|
|
5
|
+
* the same on both sides: `stats.ts` counts, this draws. What differs is the medium.
|
|
6
|
+
* The desktop panel is egui, rendered into the same wgpu pass as the game; egui here
|
|
7
|
+
* would mean bringing wgpu or WebGL into a package whose whole rendering path is one
|
|
8
|
+
* `putImageData` call, and roughly 2 MB of wasm paid by every consumer whether or not
|
|
9
|
+
* they ever press the key. So this is a plain DOM panel with the same content.
|
|
10
|
+
*
|
|
11
|
+
* Three consequences of that choice, all deliberate:
|
|
12
|
+
*
|
|
13
|
+
* - **It is a sibling of the canvas, not a wrapper around it.** The element is appended
|
|
14
|
+
* to `document.body` and positioned over the canvas from its bounding rect. Wrapping
|
|
15
|
+
* the consumer's canvas in a new element would be the tidier CSS, but it would move
|
|
16
|
+
* their node in the document and break any selector or layout rule that depended on
|
|
17
|
+
* where it was.
|
|
18
|
+
* - **`pointer-events: none`.** The panel is read-only, so it must never swallow a click
|
|
19
|
+
* meant for the page underneath. The desktop panel does have widgets, but the two it
|
|
20
|
+
* has — vsync and the CRT mode — are host-render settings that do not exist here.
|
|
21
|
+
* - **Styles are inline.** Nothing to serve, nothing for a bundler to find, and no
|
|
22
|
+
* chance of a page's stylesheet reaching in; the same reasoning that keeps the audio
|
|
23
|
+
* worklet a Blob rather than a file.
|
|
24
|
+
*/
|
|
25
|
+
import type { StatsSnapshot } from "./stats.js";
|
|
26
|
+
/** A read-only panel of counters, floating over the emulator's canvas. */
|
|
27
|
+
export declare class StatsOverlay {
|
|
28
|
+
private readonly canvas;
|
|
29
|
+
private readonly element;
|
|
30
|
+
private readonly grid;
|
|
31
|
+
private readonly values;
|
|
32
|
+
private shown;
|
|
33
|
+
private lastRefresh;
|
|
34
|
+
constructor(canvas: HTMLCanvasElement);
|
|
35
|
+
get visible(): boolean;
|
|
36
|
+
toggle(): void;
|
|
37
|
+
/**
|
|
38
|
+
* Rewrites the panel, at most every {@link REFRESH_MS}.
|
|
39
|
+
*
|
|
40
|
+
* Cheap to call every frame — while hidden it does nothing at all, which is what keeps
|
|
41
|
+
* a switched-off feature costing a branch rather than a layout.
|
|
42
|
+
*/
|
|
43
|
+
update(snapshot: StatsSnapshot, now: number): void;
|
|
44
|
+
dispose(): void;
|
|
45
|
+
/**
|
|
46
|
+
* Anchors the panel to the canvas's current position.
|
|
47
|
+
*
|
|
48
|
+
* Re-read rather than cached because a page can scroll, resize or reflow under us with
|
|
49
|
+
* no event this module sees. `getBoundingClientRect` forces layout, which is why it
|
|
50
|
+
* happens on the refresh tick and only while the panel is open.
|
|
51
|
+
*/
|
|
52
|
+
private position;
|
|
53
|
+
private addRow;
|
|
54
|
+
/**
|
|
55
|
+
* A rule across both columns.
|
|
56
|
+
*
|
|
57
|
+
* `grid-column: 1 / -1` rather than one cell per column: two cells would be broken in
|
|
58
|
+
* the middle by the column gap, which reads as two dashes rather than the single line
|
|
59
|
+
* egui draws.
|
|
60
|
+
*/
|
|
61
|
+
private addSeparator;
|
|
62
|
+
private set;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A duration in milliseconds at a fixed width, so the column does not jitter.
|
|
66
|
+
*
|
|
67
|
+
* The desktop formats these as `{:>6.2} ms` for exactly the same reason.
|
|
68
|
+
*/
|
|
69
|
+
export declare function formatMs(ms: number): string;
|
|
70
|
+
export declare function formatPercent(percent: number): string;
|
|
71
|
+
/** A plain count, right-aligned to the same width as the percentages above it. */
|
|
72
|
+
export declare function count(value: number): string;
|
|
73
|
+
/**
|
|
74
|
+
* Green through to red as a figure approaches and passes its budget.
|
|
75
|
+
*
|
|
76
|
+
* Only worth colouring near the limit: a number that matters at 100% is easy to miss if
|
|
77
|
+
* it spends most of its time at 12%.
|
|
78
|
+
*/
|
|
79
|
+
export declare function loadColour(fraction: number): string;
|
|
80
|
+
/**
|
|
81
|
+
* How alarming the audio buffer's depth is.
|
|
82
|
+
*
|
|
83
|
+
* Zero is not a warning but a failure that was audible: the worklet emitted silence.
|
|
84
|
+
* Below half the target is the warning, because that is the buffer draining rather than
|
|
85
|
+
* sitting where the pacing loop means to hold it.
|
|
86
|
+
*/
|
|
87
|
+
export declare function bufferColour(bufferedMs: number, targetMs: number): string;
|
package/dist/overlay.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The debug stats panel.
|
|
3
|
+
*
|
|
4
|
+
* This is the browser counterpart of `game-koi-desktop`'s `overlay.rs`, and the split is
|
|
5
|
+
* the same on both sides: `stats.ts` counts, this draws. What differs is the medium.
|
|
6
|
+
* The desktop panel is egui, rendered into the same wgpu pass as the game; egui here
|
|
7
|
+
* would mean bringing wgpu or WebGL into a package whose whole rendering path is one
|
|
8
|
+
* `putImageData` call, and roughly 2 MB of wasm paid by every consumer whether or not
|
|
9
|
+
* they ever press the key. So this is a plain DOM panel with the same content.
|
|
10
|
+
*
|
|
11
|
+
* Three consequences of that choice, all deliberate:
|
|
12
|
+
*
|
|
13
|
+
* - **It is a sibling of the canvas, not a wrapper around it.** The element is appended
|
|
14
|
+
* to `document.body` and positioned over the canvas from its bounding rect. Wrapping
|
|
15
|
+
* the consumer's canvas in a new element would be the tidier CSS, but it would move
|
|
16
|
+
* their node in the document and break any selector or layout rule that depended on
|
|
17
|
+
* where it was.
|
|
18
|
+
* - **`pointer-events: none`.** The panel is read-only, so it must never swallow a click
|
|
19
|
+
* meant for the page underneath. The desktop panel does have widgets, but the two it
|
|
20
|
+
* has — vsync and the CRT mode — are host-render settings that do not exist here.
|
|
21
|
+
* - **Styles are inline.** Nothing to serve, nothing for a bundler to find, and no
|
|
22
|
+
* chance of a page's stylesheet reaching in; the same reasoning that keeps the audio
|
|
23
|
+
* worklet a Blob rather than a file.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* How often the text is rewritten.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately slower than the frame rate: the underlying averages only change when a
|
|
29
|
+
* sampling window closes, and text that changes 60 times a second is unreadable anyway.
|
|
30
|
+
* The DOM writes are what this throttle is really for — the measurements carry on every
|
|
31
|
+
* wake regardless.
|
|
32
|
+
*/
|
|
33
|
+
const REFRESH_MS = 250;
|
|
34
|
+
/** Distance from the canvas's top-left corner, matching the desktop panel's [8, 8]. */
|
|
35
|
+
const INSET_PX = 8;
|
|
36
|
+
const TEXT = "#c8d0d8";
|
|
37
|
+
const DIM = "#8896a0";
|
|
38
|
+
const WARN = "#e8c060";
|
|
39
|
+
const BAD = "#e05050";
|
|
40
|
+
const PANEL_STYLE = {
|
|
41
|
+
position: "fixed",
|
|
42
|
+
// Above anything a page is plausibly stacking, since a debug panel hidden behind the
|
|
43
|
+
// page's own chrome would be useless.
|
|
44
|
+
zIndex: "2147483647",
|
|
45
|
+
pointerEvents: "none",
|
|
46
|
+
font: "12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
|
|
47
|
+
color: TEXT,
|
|
48
|
+
background: "rgba(8, 24, 32, 0.86)",
|
|
49
|
+
border: "1px solid rgba(136, 192, 112, 0.4)",
|
|
50
|
+
borderRadius: "4px",
|
|
51
|
+
padding: "6px 8px",
|
|
52
|
+
display: "none",
|
|
53
|
+
};
|
|
54
|
+
/** A read-only panel of counters, floating over the emulator's canvas. */
|
|
55
|
+
export class StatsOverlay {
|
|
56
|
+
canvas;
|
|
57
|
+
element;
|
|
58
|
+
grid;
|
|
59
|
+
values = new Map();
|
|
60
|
+
shown = false;
|
|
61
|
+
lastRefresh = 0;
|
|
62
|
+
constructor(canvas) {
|
|
63
|
+
this.canvas = canvas;
|
|
64
|
+
this.element = document.createElement("div");
|
|
65
|
+
Object.assign(this.element.style, PANEL_STYLE);
|
|
66
|
+
const title = document.createElement("div");
|
|
67
|
+
title.textContent = "Stats";
|
|
68
|
+
title.style.marginBottom = "4px";
|
|
69
|
+
title.style.color = DIM;
|
|
70
|
+
this.element.appendChild(title);
|
|
71
|
+
this.grid = document.createElement("div");
|
|
72
|
+
this.grid.style.display = "grid";
|
|
73
|
+
this.grid.style.gridTemplateColumns = "auto auto";
|
|
74
|
+
this.grid.style.columnGap = "14px";
|
|
75
|
+
this.element.appendChild(this.grid);
|
|
76
|
+
// The same grouping as the desktop panel: how fast, what it cost, how the two
|
|
77
|
+
// clocks relate, and what the audio device is doing.
|
|
78
|
+
this.addRow("FPS");
|
|
79
|
+
this.addRow("Speed");
|
|
80
|
+
this.addSeparator();
|
|
81
|
+
this.addRow(" emulate");
|
|
82
|
+
this.addRow(" draw");
|
|
83
|
+
this.addRow("Work");
|
|
84
|
+
this.addSeparator();
|
|
85
|
+
this.addRow("Frames/wake");
|
|
86
|
+
this.addRow("Refresh");
|
|
87
|
+
this.addRow("Capped");
|
|
88
|
+
this.addSeparator();
|
|
89
|
+
this.addRow("Buffer");
|
|
90
|
+
this.addRow("Underruns");
|
|
91
|
+
this.addRow("Dropped");
|
|
92
|
+
this.addRow("Output");
|
|
93
|
+
document.body.appendChild(this.element);
|
|
94
|
+
}
|
|
95
|
+
get visible() {
|
|
96
|
+
return this.shown;
|
|
97
|
+
}
|
|
98
|
+
toggle() {
|
|
99
|
+
this.shown = !this.shown;
|
|
100
|
+
this.element.style.display = this.shown ? "block" : "none";
|
|
101
|
+
// Force the next update through: the panel should not open showing whatever was
|
|
102
|
+
// last written up to a refresh interval ago.
|
|
103
|
+
this.lastRefresh = 0;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Rewrites the panel, at most every {@link REFRESH_MS}.
|
|
107
|
+
*
|
|
108
|
+
* Cheap to call every frame — while hidden it does nothing at all, which is what keeps
|
|
109
|
+
* a switched-off feature costing a branch rather than a layout.
|
|
110
|
+
*/
|
|
111
|
+
update(snapshot, now) {
|
|
112
|
+
if (!this.shown)
|
|
113
|
+
return;
|
|
114
|
+
if (now - this.lastRefresh < REFRESH_MS)
|
|
115
|
+
return;
|
|
116
|
+
this.lastRefresh = now;
|
|
117
|
+
this.position();
|
|
118
|
+
this.set("FPS", snapshot.fps.toFixed(1));
|
|
119
|
+
this.set("Speed", formatPercent(snapshot.speedPercent));
|
|
120
|
+
this.set(" emulate", formatMs(snapshot.emulate));
|
|
121
|
+
this.set(" draw", formatMs(snapshot.draw));
|
|
122
|
+
// The headline cost figure, coloured the same way the desktop colours its budget:
|
|
123
|
+
// worth noticing near the limit, noise the rest of the time.
|
|
124
|
+
this.set("Work", formatPercent(snapshot.workFraction * 100), loadColour(snapshot.workFraction));
|
|
125
|
+
this.set("Frames/wake", snapshot.framesPerWake.toFixed(2));
|
|
126
|
+
this.set("Refresh", `${snapshot.wakeRate.toFixed(1)} Hz`);
|
|
127
|
+
// A capped wake means the catch-up was clamped and a deficit was left behind —
|
|
128
|
+
// usually a backgrounded tab, occasionally a machine that cannot keep up.
|
|
129
|
+
this.set("Capped", count(snapshot.cappedWakes), snapshot.cappedWakes > 0 ? WARN : TEXT);
|
|
130
|
+
const audio = snapshot.audio;
|
|
131
|
+
if (!audio) {
|
|
132
|
+
// Only reachable in the first moments, before the worklet's first report.
|
|
133
|
+
this.set("Buffer", "—");
|
|
134
|
+
this.set("Underruns", "—");
|
|
135
|
+
this.set("Dropped", "—");
|
|
136
|
+
this.set("Output", "—");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const targetMs = (audio.target / audio.sampleRate) * 1000;
|
|
140
|
+
this.set("Buffer", `${audio.bufferedMs.toFixed(1)} ms`, bufferColour(audio.bufferedMs, targetMs));
|
|
141
|
+
// Cumulative, and the closest thing here to the desktop's late-frame count: an
|
|
142
|
+
// underrun is a moment of silence that was actually heard.
|
|
143
|
+
this.set("Underruns", count(audio.underruns), audio.underruns > 0 ? WARN : TEXT);
|
|
144
|
+
// The opposite failure — the page ran ahead of the sound card and the ring threw
|
|
145
|
+
// samples away. Normal in small numbers after a tab comes back to the foreground.
|
|
146
|
+
this.set("Dropped", count(audio.dropped), audio.dropped > 0 ? WARN : TEXT);
|
|
147
|
+
this.set("Output", `${(audio.sampleRate / 1000).toFixed(1)}k ${audio.state}`,
|
|
148
|
+
// A suspended context is the first thing to check when there is no sound, and
|
|
149
|
+
// nothing else on the panel would say so.
|
|
150
|
+
audio.state === "running" ? TEXT : WARN);
|
|
151
|
+
}
|
|
152
|
+
dispose() {
|
|
153
|
+
this.element.remove();
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Anchors the panel to the canvas's current position.
|
|
157
|
+
*
|
|
158
|
+
* Re-read rather than cached because a page can scroll, resize or reflow under us with
|
|
159
|
+
* no event this module sees. `getBoundingClientRect` forces layout, which is why it
|
|
160
|
+
* happens on the refresh tick and only while the panel is open.
|
|
161
|
+
*/
|
|
162
|
+
position() {
|
|
163
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
164
|
+
this.element.style.left = `${rect.left + INSET_PX}px`;
|
|
165
|
+
this.element.style.top = `${rect.top + INSET_PX}px`;
|
|
166
|
+
}
|
|
167
|
+
addRow(label) {
|
|
168
|
+
const labelCell = document.createElement("span");
|
|
169
|
+
labelCell.textContent = label;
|
|
170
|
+
labelCell.style.color = DIM;
|
|
171
|
+
// Leading spaces mark the sub-rows of the work breakdown, as they do on the
|
|
172
|
+
// desktop; without this the browser collapses them.
|
|
173
|
+
labelCell.style.whiteSpace = "pre";
|
|
174
|
+
const valueCell = document.createElement("span");
|
|
175
|
+
valueCell.textContent = "—";
|
|
176
|
+
valueCell.style.textAlign = "right";
|
|
177
|
+
valueCell.style.whiteSpace = "pre";
|
|
178
|
+
this.grid.appendChild(labelCell);
|
|
179
|
+
this.grid.appendChild(valueCell);
|
|
180
|
+
this.values.set(label, valueCell);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* A rule across both columns.
|
|
184
|
+
*
|
|
185
|
+
* `grid-column: 1 / -1` rather than one cell per column: two cells would be broken in
|
|
186
|
+
* the middle by the column gap, which reads as two dashes rather than the single line
|
|
187
|
+
* egui draws.
|
|
188
|
+
*/
|
|
189
|
+
addSeparator() {
|
|
190
|
+
const rule = document.createElement("div");
|
|
191
|
+
rule.style.gridColumn = "1 / -1";
|
|
192
|
+
rule.style.borderTop = "1px solid rgba(200, 208, 216, 0.15)";
|
|
193
|
+
rule.style.margin = "3px 0";
|
|
194
|
+
this.grid.appendChild(rule);
|
|
195
|
+
}
|
|
196
|
+
set(label, text, colour = TEXT) {
|
|
197
|
+
const cell = this.values.get(label);
|
|
198
|
+
if (!cell)
|
|
199
|
+
return;
|
|
200
|
+
cell.textContent = text;
|
|
201
|
+
cell.style.color = colour;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* A duration in milliseconds at a fixed width, so the column does not jitter.
|
|
206
|
+
*
|
|
207
|
+
* The desktop formats these as `{:>6.2} ms` for exactly the same reason.
|
|
208
|
+
*/
|
|
209
|
+
export function formatMs(ms) {
|
|
210
|
+
return `${ms.toFixed(2).padStart(6)} ms`;
|
|
211
|
+
}
|
|
212
|
+
export function formatPercent(percent) {
|
|
213
|
+
return `${percent.toFixed(0).padStart(4)}%`;
|
|
214
|
+
}
|
|
215
|
+
/** A plain count, right-aligned to the same width as the percentages above it. */
|
|
216
|
+
export function count(value) {
|
|
217
|
+
return value.toString().padStart(5);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Green through to red as a figure approaches and passes its budget.
|
|
221
|
+
*
|
|
222
|
+
* Only worth colouring near the limit: a number that matters at 100% is easy to miss if
|
|
223
|
+
* it spends most of its time at 12%.
|
|
224
|
+
*/
|
|
225
|
+
export function loadColour(fraction) {
|
|
226
|
+
if (fraction > 1)
|
|
227
|
+
return BAD;
|
|
228
|
+
if (fraction > 0.8)
|
|
229
|
+
return WARN;
|
|
230
|
+
return TEXT;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* How alarming the audio buffer's depth is.
|
|
234
|
+
*
|
|
235
|
+
* Zero is not a warning but a failure that was audible: the worklet emitted silence.
|
|
236
|
+
* Below half the target is the warning, because that is the buffer draining rather than
|
|
237
|
+
* sitting where the pacing loop means to hold it.
|
|
238
|
+
*/
|
|
239
|
+
export function bufferColour(bufferedMs, targetMs) {
|
|
240
|
+
if (bufferedMs <= 0)
|
|
241
|
+
return BAD;
|
|
242
|
+
if (bufferedMs < targetMs / 2)
|
|
243
|
+
return WARN;
|
|
244
|
+
return TEXT;
|
|
245
|
+
}
|