game-koi 0.1.0 → 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/README.md +51 -4
- package/dist/gamepad.d.ts +85 -0
- package/dist/gamepad.js +158 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +118 -2
- 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/README.md
CHANGED
|
@@ -26,10 +26,51 @@ button.addEventListener("click", async () => {
|
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
That's the whole integration: no separate files to serve for the audio worklet, no
|
|
29
|
-
manual wasm memory or canvas bookkeeping, and
|
|
30
|
-
keys, Z/X, Enter, right Shift)
|
|
31
|
-
|
|
32
|
-
touch controls
|
|
29
|
+
manual wasm memory or canvas bookkeeping, and both input devices are wired up
|
|
30
|
+
automatically — the keyboard layout (arrow keys, Z/X, Enter, right Shift) and any
|
|
31
|
+
connected gamepad. Pass `keymap: null` or `gamepadMap: null` to `create()` to opt out
|
|
32
|
+
of either and drive `press`/`release` yourself — from touch controls or your own
|
|
33
|
+
bindings.
|
|
34
|
+
|
|
35
|
+
### Gamepads
|
|
36
|
+
|
|
37
|
+
A standard-layout pad maps d-pad to d-pad, the East and South face buttons to A and B
|
|
38
|
+
(the DMG's diagonal), Start to Start and Back/Select to Select. The left analog stick
|
|
39
|
+
doubles as the d-pad, thresholded at half deflection. Every connected pad drives the
|
|
40
|
+
one player, since the Game Boy has only one.
|
|
41
|
+
|
|
42
|
+
Two things follow from how the browser exposes pads, and neither needs anything from
|
|
43
|
+
the page:
|
|
44
|
+
|
|
45
|
+
- There is no event for a button being pressed — only connect/disconnect — so pads are
|
|
46
|
+
polled once per animation frame. That is also why a pad shows up on its own in
|
|
47
|
+
Chrome, which hides pads until one has been interacted with.
|
|
48
|
+
- Pads the browser cannot identify (`mapping !== "standard"`) are ignored, because the
|
|
49
|
+
button indices of an unrecognised layout are whatever the driver enumerated and
|
|
50
|
+
binding them would be confidently wrong rather than merely absent. Use
|
|
51
|
+
`gamepadMap: null` and your own polling for such a pad.
|
|
52
|
+
|
|
53
|
+
### Debug overlay
|
|
54
|
+
|
|
55
|
+
Press <kbd>`</kbd> (backtick) to toggle a stats panel over the canvas, or call
|
|
56
|
+
`koi.toggleOverlay()`. It is the browser counterpart of the desktop build's panel, on
|
|
57
|
+
the same key, and it counts browser-shaped things:
|
|
58
|
+
|
|
59
|
+
- **FPS / Speed** — emulated frames per second, and that as a percentage of a real
|
|
60
|
+
DMG's 59.73 Hz. Frames, not animation-frame wakes: on a 120 Hz display the loop wakes
|
|
61
|
+
twice per frame.
|
|
62
|
+
- **emulate / draw / Work** — milliseconds per emulated frame, and their total as a
|
|
63
|
+
share of the 16.74 ms a frame is worth.
|
|
64
|
+
- **Frames/wake, Refresh, Capped** — how the two clocks relate, and how often
|
|
65
|
+
`maxFramesPerWake` clamped a catch-up.
|
|
66
|
+
- **Buffer / Underruns / Dropped / Output** — how much audio is queued ahead, and the
|
|
67
|
+
two ways that goes wrong. Underruns are silence that was actually heard, and are the
|
|
68
|
+
closest thing here to the desktop's "late frames"; a `suspended` output is the usual
|
|
69
|
+
reason for no sound at all.
|
|
70
|
+
|
|
71
|
+
The panel is read-only and `pointer-events: none`, so it never intercepts a click, and
|
|
72
|
+
it is built the first time it is opened — a page that never opens it gets no extra
|
|
73
|
+
element. Pass `overlayKey: null` to bind no key.
|
|
33
74
|
|
|
34
75
|
## API
|
|
35
76
|
|
|
@@ -38,12 +79,18 @@ touch controls, or your own key bindings.
|
|
|
38
79
|
- `rom: Uint8Array` — the `.gb` file's bytes.
|
|
39
80
|
- `keymap?: Record<string, Button> | null` — maps `KeyboardEvent.code` to a
|
|
40
81
|
button; `null` disables built-in keyboard handling.
|
|
82
|
+
- `gamepadMap?: Record<number, Button> | null` — maps a standard-layout gamepad's
|
|
83
|
+
button indices to a button; `null` disables gamepad polling. The left stick acts
|
|
84
|
+
as a d-pad regardless of this map.
|
|
85
|
+
- `overlayKey?: string | null` — `KeyboardEvent.code` toggling the stats panel.
|
|
86
|
+
Default `"Backquote"`; `null` binds no key.
|
|
41
87
|
- `targetBuffer?: number` — audio samples to keep queued ahead. Default `1600`.
|
|
42
88
|
- `maxFramesPerWake?: number` — cap on frames emulated per wake-up, so a
|
|
43
89
|
backgrounded tab can't return to a freeze. Default `4`.
|
|
44
90
|
- `koi.loadRom(rom)` — swaps in a new ROM, keeping the same canvas and audio setup.
|
|
45
91
|
- `koi.press(button)` / `koi.release(button)` — `Button` is one of `"up"`, `"down"`,
|
|
46
92
|
`"left"`, `"right"`, `"a"`, `"b"`, `"start"`, `"select"`.
|
|
93
|
+
- `koi.toggleOverlay()` / `koi.overlayVisible` — the debug stats panel.
|
|
47
94
|
- `koi.pause()` / `koi.resume()`.
|
|
48
95
|
- `koi.dispose()` — stops the loop and tears down the audio graph. Call this before
|
|
49
96
|
dropping a `GameKoi` instance, or the AudioContext and its worklet leak.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gamepad input, via the browser's Gamepad API.
|
|
3
|
+
*
|
|
4
|
+
* Unlike the keyboard, a gamepad is not a source of events here: the Gamepad API has
|
|
5
|
+
* `gamepadconnected`/`gamepaddisconnected` and nothing else — a button press fires no
|
|
6
|
+
* event at all. `navigator.getGamepads()` hands back a *snapshot* of every pad's
|
|
7
|
+
* current state, so this is polled once per wake of the render loop and diffed against
|
|
8
|
+
* the previous snapshot to recover the press/release edges the joypad wants. One frame
|
|
9
|
+
* (~16.7 ms) of latency is the granularity the emulator already runs at, so polling
|
|
10
|
+
* more often would buy nothing.
|
|
11
|
+
*
|
|
12
|
+
* Polling is also what makes a pad appear at all: Chrome exposes no pads until one has
|
|
13
|
+
* been interacted with, and reveals them through a later `getGamepads()` call rather
|
|
14
|
+
* than retroactively. Nothing extra is needed to pick that up.
|
|
15
|
+
*
|
|
16
|
+
* # Analog sticks are not a d-pad
|
|
17
|
+
*
|
|
18
|
+
* The DMG's d-pad is four switches: a direction is held or it isn't. A stick reports a
|
|
19
|
+
* continuous position, so it is thresholded into at most one direction per axis. The
|
|
20
|
+
* diff against the previous snapshot is what keeps a resting-at-0.7 stick from
|
|
21
|
+
* re-pressing Right on every frame.
|
|
22
|
+
*/
|
|
23
|
+
import type { Button } from "./index.js";
|
|
24
|
+
/**
|
|
25
|
+
* The part of the browser's `Gamepad` this module reads.
|
|
26
|
+
*
|
|
27
|
+
* Narrowed to the two fields that matter (a real `Gamepad` is structurally compatible)
|
|
28
|
+
* so the mapping can be unit-tested with plain objects and no browser attached.
|
|
29
|
+
*/
|
|
30
|
+
export interface PadState {
|
|
31
|
+
/** `"standard"` iff the browser recognised the layout; see `heldButtons`. */
|
|
32
|
+
mapping: string;
|
|
33
|
+
buttons: readonly {
|
|
34
|
+
readonly pressed: boolean;
|
|
35
|
+
}[];
|
|
36
|
+
axes: readonly number[];
|
|
37
|
+
}
|
|
38
|
+
/** A press or release edge, to be applied to the emulator's joypad. */
|
|
39
|
+
export interface Action {
|
|
40
|
+
type: "press" | "release";
|
|
41
|
+
button: Button;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Standard-layout button indices mapped to Game Boy buttons.
|
|
45
|
+
*
|
|
46
|
+
* A and B sit on a diagonal on the DMG — B lower-left, A upper-right — so the East and
|
|
47
|
+
* South face buttons reproduce that under the thumb. The Gamepad API reports face
|
|
48
|
+
* buttons by *position*, not by printed label: index 0 is always the bottom button and
|
|
49
|
+
* index 1 the right-hand one, whatever a given pad prints on them.
|
|
50
|
+
*/
|
|
51
|
+
export declare const DEFAULT_GAMEPAD_MAP: Readonly<Record<number, Button>>;
|
|
52
|
+
/**
|
|
53
|
+
* Everything held across every connected pad, as a set of Game Boy buttons.
|
|
54
|
+
*
|
|
55
|
+
* Every pad is accepted rather than picking a "player 1": the Game Boy has one player,
|
|
56
|
+
* so asking which controller owns it would be ceremony for nothing.
|
|
57
|
+
*
|
|
58
|
+
* Pads whose `mapping` is not `"standard"` are skipped entirely. The indices in a
|
|
59
|
+
* non-standard layout are whatever the driver happened to enumerate, so guessing at
|
|
60
|
+
* them produces confidently wrong bindings rather than none; a page with such a pad can
|
|
61
|
+
* pass `gamepadMap: null` and drive `press`/`release` itself.
|
|
62
|
+
*/
|
|
63
|
+
export declare function heldButtons(pads: Iterable<PadState | null>, map: Readonly<Record<number, Button>>): Set<Button>;
|
|
64
|
+
/**
|
|
65
|
+
* The edges between two snapshots.
|
|
66
|
+
*
|
|
67
|
+
* Releases come first so that flicking a stick from Left straight through centre to
|
|
68
|
+
* Right cannot leave Left stuck down for the instant the presses are applied.
|
|
69
|
+
*/
|
|
70
|
+
export declare function diff(previous: ReadonlySet<Button>, next: ReadonlySet<Button>): Action[];
|
|
71
|
+
/** Remembers the last snapshot so each poll can report only what changed. */
|
|
72
|
+
export declare class GamepadInput {
|
|
73
|
+
private readonly map;
|
|
74
|
+
private held;
|
|
75
|
+
constructor(map?: Readonly<Record<number, Button>>);
|
|
76
|
+
/** Diffs the pads against the previous poll and returns the edges to apply. */
|
|
77
|
+
poll(pads: Iterable<PadState | null>): Action[];
|
|
78
|
+
/**
|
|
79
|
+
* Releases everything currently held, forgetting the snapshot.
|
|
80
|
+
*
|
|
81
|
+
* Used when the loop stops: a button held at the moment of a pause would otherwise
|
|
82
|
+
* still be held from the emulator's point of view, with no poll coming to release it.
|
|
83
|
+
*/
|
|
84
|
+
releaseAll(): Action[];
|
|
85
|
+
}
|
package/dist/gamepad.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gamepad input, via the browser's Gamepad API.
|
|
3
|
+
*
|
|
4
|
+
* Unlike the keyboard, a gamepad is not a source of events here: the Gamepad API has
|
|
5
|
+
* `gamepadconnected`/`gamepaddisconnected` and nothing else — a button press fires no
|
|
6
|
+
* event at all. `navigator.getGamepads()` hands back a *snapshot* of every pad's
|
|
7
|
+
* current state, so this is polled once per wake of the render loop and diffed against
|
|
8
|
+
* the previous snapshot to recover the press/release edges the joypad wants. One frame
|
|
9
|
+
* (~16.7 ms) of latency is the granularity the emulator already runs at, so polling
|
|
10
|
+
* more often would buy nothing.
|
|
11
|
+
*
|
|
12
|
+
* Polling is also what makes a pad appear at all: Chrome exposes no pads until one has
|
|
13
|
+
* been interacted with, and reveals them through a later `getGamepads()` call rather
|
|
14
|
+
* than retroactively. Nothing extra is needed to pick that up.
|
|
15
|
+
*
|
|
16
|
+
* # Analog sticks are not a d-pad
|
|
17
|
+
*
|
|
18
|
+
* The DMG's d-pad is four switches: a direction is held or it isn't. A stick reports a
|
|
19
|
+
* continuous position, so it is thresholded into at most one direction per axis. The
|
|
20
|
+
* diff against the previous snapshot is what keeps a resting-at-0.7 stick from
|
|
21
|
+
* re-pressing Right on every frame.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Standard-layout button indices mapped to Game Boy buttons.
|
|
25
|
+
*
|
|
26
|
+
* A and B sit on a diagonal on the DMG — B lower-left, A upper-right — so the East and
|
|
27
|
+
* South face buttons reproduce that under the thumb. The Gamepad API reports face
|
|
28
|
+
* buttons by *position*, not by printed label: index 0 is always the bottom button and
|
|
29
|
+
* index 1 the right-hand one, whatever a given pad prints on them.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_GAMEPAD_MAP = {
|
|
32
|
+
0: "b", // South
|
|
33
|
+
1: "a", // East
|
|
34
|
+
8: "select", // Back/Select
|
|
35
|
+
9: "start",
|
|
36
|
+
12: "up",
|
|
37
|
+
13: "down",
|
|
38
|
+
14: "left",
|
|
39
|
+
15: "right",
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* How far a stick must be pushed before it counts as a direction press.
|
|
43
|
+
*
|
|
44
|
+
* Half deflection, the same figure the desktop build uses: high enough that a drifting
|
|
45
|
+
* or off-centre stick does not hold a direction on its own, low enough that a diagonal
|
|
46
|
+
* registers on both axes.
|
|
47
|
+
*/
|
|
48
|
+
const STICK_THRESHOLD = 0.5;
|
|
49
|
+
/** Standard-layout axis indices. The right stick (2, 3) is deliberately ignored. */
|
|
50
|
+
const LEFT_STICK_X = 0;
|
|
51
|
+
const LEFT_STICK_Y = 1;
|
|
52
|
+
/**
|
|
53
|
+
* Everything held across every connected pad, as a set of Game Boy buttons.
|
|
54
|
+
*
|
|
55
|
+
* Every pad is accepted rather than picking a "player 1": the Game Boy has one player,
|
|
56
|
+
* so asking which controller owns it would be ceremony for nothing.
|
|
57
|
+
*
|
|
58
|
+
* Pads whose `mapping` is not `"standard"` are skipped entirely. The indices in a
|
|
59
|
+
* non-standard layout are whatever the driver happened to enumerate, so guessing at
|
|
60
|
+
* them produces confidently wrong bindings rather than none; a page with such a pad can
|
|
61
|
+
* pass `gamepadMap: null` and drive `press`/`release` itself.
|
|
62
|
+
*/
|
|
63
|
+
export function heldButtons(pads, map) {
|
|
64
|
+
const held = new Set();
|
|
65
|
+
for (const pad of pads) {
|
|
66
|
+
if (!pad || pad.mapping !== "standard")
|
|
67
|
+
continue;
|
|
68
|
+
pad.buttons.forEach((button, index) => {
|
|
69
|
+
// `pressed` rather than a threshold on `value`: the browser already applies the
|
|
70
|
+
// right one per button, and the Game Boy has no analog inputs to preserve.
|
|
71
|
+
if (!button.pressed)
|
|
72
|
+
return;
|
|
73
|
+
const mapped = map[index];
|
|
74
|
+
if (mapped)
|
|
75
|
+
held.add(mapped);
|
|
76
|
+
});
|
|
77
|
+
// The stick is a direction, not an index, so it is not part of `map`. Note the Y
|
|
78
|
+
// sign: the Gamepad API reports +1 as *down*, the opposite of the convention the
|
|
79
|
+
// desktop build's gilrs uses.
|
|
80
|
+
addAxis(held, pad.axes[LEFT_STICK_X], "left", "right");
|
|
81
|
+
addAxis(held, pad.axes[LEFT_STICK_Y], "up", "down");
|
|
82
|
+
}
|
|
83
|
+
cancelOpposites(held);
|
|
84
|
+
return held;
|
|
85
|
+
}
|
|
86
|
+
/** Adds whichever direction an axis position is holding, if any. */
|
|
87
|
+
function addAxis(held, value, negative, positive) {
|
|
88
|
+
if (value === undefined)
|
|
89
|
+
return;
|
|
90
|
+
if (value <= -STICK_THRESHOLD)
|
|
91
|
+
held.add(negative);
|
|
92
|
+
else if (value >= STICK_THRESHOLD)
|
|
93
|
+
held.add(positive);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Drops both halves of an opposing pair held at once.
|
|
97
|
+
*
|
|
98
|
+
* A physical d-pad cannot produce Left+Right — it is one rocker — and some games handle
|
|
99
|
+
* the impossible state badly. Two sources can still ask for it here: a stick pushed one
|
|
100
|
+
* way while the d-pad is held the other, or two pads disagreeing. Neutral is the
|
|
101
|
+
* closest reachable state.
|
|
102
|
+
*/
|
|
103
|
+
function cancelOpposites(held) {
|
|
104
|
+
const pairs = [
|
|
105
|
+
["left", "right"],
|
|
106
|
+
["up", "down"],
|
|
107
|
+
];
|
|
108
|
+
for (const [a, b] of pairs) {
|
|
109
|
+
if (held.has(a) && held.has(b)) {
|
|
110
|
+
held.delete(a);
|
|
111
|
+
held.delete(b);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The edges between two snapshots.
|
|
117
|
+
*
|
|
118
|
+
* Releases come first so that flicking a stick from Left straight through centre to
|
|
119
|
+
* Right cannot leave Left stuck down for the instant the presses are applied.
|
|
120
|
+
*/
|
|
121
|
+
export function diff(previous, next) {
|
|
122
|
+
const actions = [];
|
|
123
|
+
for (const button of previous) {
|
|
124
|
+
if (!next.has(button))
|
|
125
|
+
actions.push({ type: "release", button });
|
|
126
|
+
}
|
|
127
|
+
for (const button of next) {
|
|
128
|
+
if (!previous.has(button))
|
|
129
|
+
actions.push({ type: "press", button });
|
|
130
|
+
}
|
|
131
|
+
return actions;
|
|
132
|
+
}
|
|
133
|
+
/** Remembers the last snapshot so each poll can report only what changed. */
|
|
134
|
+
export class GamepadInput {
|
|
135
|
+
map;
|
|
136
|
+
held = new Set();
|
|
137
|
+
constructor(map = DEFAULT_GAMEPAD_MAP) {
|
|
138
|
+
this.map = map;
|
|
139
|
+
}
|
|
140
|
+
/** Diffs the pads against the previous poll and returns the edges to apply. */
|
|
141
|
+
poll(pads) {
|
|
142
|
+
const next = heldButtons(pads, this.map);
|
|
143
|
+
const actions = diff(this.held, next);
|
|
144
|
+
this.held = next;
|
|
145
|
+
return actions;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Releases everything currently held, forgetting the snapshot.
|
|
149
|
+
*
|
|
150
|
+
* Used when the loop stops: a button held at the moment of a pause would otherwise
|
|
151
|
+
* still be held from the emulator's point of view, with no poll coming to release it.
|
|
152
|
+
*/
|
|
153
|
+
releaseAll() {
|
|
154
|
+
const actions = diff(this.held, new Set());
|
|
155
|
+
this.held = new Set();
|
|
156
|
+
return actions;
|
|
157
|
+
}
|
|
158
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -19,6 +19,19 @@ export interface GameKoiOptions {
|
|
|
19
19
|
* build's layout: arrow keys, Z/X, Enter, right Shift.
|
|
20
20
|
*/
|
|
21
21
|
keymap?: Record<string, Button> | null;
|
|
22
|
+
/**
|
|
23
|
+
* Maps a standard-layout gamepad's button indices to a button. Pass `null` to
|
|
24
|
+
* disable gamepad polling entirely. Defaults to the desktop build's layout: d-pad,
|
|
25
|
+
* East/South face buttons for A/B, Start and Back/Select. The left analog stick acts
|
|
26
|
+
* as a d-pad regardless of this map, since it is a direction rather than an index.
|
|
27
|
+
*/
|
|
28
|
+
gamepadMap?: Record<number, Button> | null;
|
|
29
|
+
/**
|
|
30
|
+
* `KeyboardEvent.code` that toggles the debug stats panel. Default `"Backquote"`
|
|
31
|
+
* (the ` / ~ key). Pass `null` to bind no key and drive {@link GameKoi.toggleOverlay}
|
|
32
|
+
* yourself. Like `keymap`, this is a physical key position, not a character.
|
|
33
|
+
*/
|
|
34
|
+
overlayKey?: string | null;
|
|
22
35
|
/** Samples to keep queued ahead of playback. Default 1600 (~2 frames at 48kHz). */
|
|
23
36
|
targetBuffer?: number;
|
|
24
37
|
/**
|
|
@@ -36,14 +49,22 @@ export declare class GameKoi {
|
|
|
36
49
|
private readonly wasmMemory;
|
|
37
50
|
private readonly imageData;
|
|
38
51
|
private readonly keymap;
|
|
52
|
+
private readonly gamepad;
|
|
39
53
|
private readonly targetBuffer;
|
|
40
54
|
private readonly maxFramesPerWake;
|
|
55
|
+
private readonly stats;
|
|
41
56
|
private emulator;
|
|
42
57
|
private buffered;
|
|
58
|
+
/** Cumulative worklet counters, as last reported. See `worklet.ts`. */
|
|
59
|
+
private underruns;
|
|
60
|
+
private dropped;
|
|
61
|
+
/** Built on first toggle, so a page that never opens it gets no element. */
|
|
62
|
+
private overlay;
|
|
43
63
|
private running;
|
|
44
64
|
private rafHandle;
|
|
45
65
|
private keydownListener?;
|
|
46
66
|
private keyupListener?;
|
|
67
|
+
private overlayListener?;
|
|
47
68
|
private constructor();
|
|
48
69
|
/**
|
|
49
70
|
* Builds and starts a running emulator.
|
|
@@ -56,12 +77,45 @@ export declare class GameKoi {
|
|
|
56
77
|
loadRom(rom: Uint8Array): void;
|
|
57
78
|
press(button: Button): void;
|
|
58
79
|
release(button: Button): void;
|
|
80
|
+
/**
|
|
81
|
+
* Shows or hides the debug stats panel — the same thing the ` key does.
|
|
82
|
+
*
|
|
83
|
+
* The panel is built the first time this is called, so a page that never opens it
|
|
84
|
+
* never gets an extra element in its DOM.
|
|
85
|
+
*/
|
|
86
|
+
toggleOverlay(): void;
|
|
87
|
+
/** Whether the stats panel is currently showing. */
|
|
88
|
+
get overlayVisible(): boolean;
|
|
59
89
|
pause(): void;
|
|
60
90
|
resume(): void;
|
|
61
91
|
/** Stops the loop, releases wasm memory, and tears down the audio graph. */
|
|
62
92
|
dispose(): void;
|
|
63
93
|
private loop;
|
|
94
|
+
/**
|
|
95
|
+
* Feeds the counters and refreshes the panel.
|
|
96
|
+
*
|
|
97
|
+
* Always run, not just while the panel is open: the averages are computed over half a
|
|
98
|
+
* second, so a panel that only started counting when it opened would show nothing for
|
|
99
|
+
* its first window. The cost is four `performance.now()` reads per wake.
|
|
100
|
+
*/
|
|
101
|
+
private recordWake;
|
|
64
102
|
private drawFrame;
|
|
103
|
+
/**
|
|
104
|
+
* Reads every connected pad and applies whatever changed since the last wake.
|
|
105
|
+
*
|
|
106
|
+
* Done here rather than from a listener because the Gamepad API fires no event for a
|
|
107
|
+
* button: `getGamepads()` is a snapshot and polling it is the only way to see one.
|
|
108
|
+
*/
|
|
109
|
+
private pollGamepads;
|
|
110
|
+
private applyGamepadActions;
|
|
65
111
|
private attachKeyboard;
|
|
66
112
|
private detachKeyboard;
|
|
113
|
+
/**
|
|
114
|
+
* Binds the panel's toggle key.
|
|
115
|
+
*
|
|
116
|
+
* Its own listener rather than a case inside the keymap one, because the two are
|
|
117
|
+
* independent: a page that drives the joypad itself (`keymap: null`) can still want
|
|
118
|
+
* the panel, and the panel key is not a Game Boy button.
|
|
119
|
+
*/
|
|
120
|
+
private attachOverlayKey;
|
|
67
121
|
}
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
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 { FrameStats } from "./stats.js";
|
|
14
|
+
import { StatsOverlay } from "./overlay.js";
|
|
12
15
|
const DEFAULT_KEYMAP = {
|
|
13
16
|
ArrowRight: "right",
|
|
14
17
|
ArrowLeft: "left",
|
|
@@ -35,29 +38,43 @@ export class GameKoi {
|
|
|
35
38
|
wasmMemory;
|
|
36
39
|
imageData;
|
|
37
40
|
keymap;
|
|
41
|
+
gamepad;
|
|
38
42
|
targetBuffer;
|
|
39
43
|
maxFramesPerWake;
|
|
44
|
+
stats;
|
|
40
45
|
emulator;
|
|
41
46
|
buffered = 0;
|
|
47
|
+
/** Cumulative worklet counters, as last reported. See `worklet.ts`. */
|
|
48
|
+
underruns = 0;
|
|
49
|
+
dropped = 0;
|
|
50
|
+
/** Built on first toggle, so a page that never opens it gets no element. */
|
|
51
|
+
overlay = null;
|
|
42
52
|
running = false;
|
|
43
53
|
rafHandle = 0;
|
|
44
54
|
keydownListener;
|
|
45
55
|
keyupListener;
|
|
46
|
-
|
|
56
|
+
overlayListener;
|
|
57
|
+
constructor(emulator, wasmMemory, ctx, audioContext, worklet, keymap, gamepadMap, overlayKey, targetBuffer, maxFramesPerWake) {
|
|
47
58
|
this.emulator = emulator;
|
|
48
59
|
this.wasmMemory = wasmMemory;
|
|
49
60
|
this.ctx = ctx;
|
|
50
61
|
this.audioContext = audioContext;
|
|
51
62
|
this.worklet = worklet;
|
|
52
63
|
this.keymap = keymap;
|
|
64
|
+
this.gamepad = gamepadMap ? new GamepadInput(gamepadMap) : null;
|
|
53
65
|
this.targetBuffer = targetBuffer;
|
|
54
66
|
this.maxFramesPerWake = maxFramesPerWake;
|
|
55
67
|
this.imageData = ctx.createImageData(Emulator.width(), Emulator.height());
|
|
68
|
+
this.stats = new FrameStats(performance.now());
|
|
56
69
|
this.worklet.port.onmessage = (event) => {
|
|
57
70
|
this.buffered = event.data.buffered;
|
|
71
|
+
this.underruns = event.data.underruns;
|
|
72
|
+
this.dropped = event.data.dropped;
|
|
58
73
|
};
|
|
59
74
|
if (keymap)
|
|
60
75
|
this.attachKeyboard();
|
|
76
|
+
if (overlayKey !== null)
|
|
77
|
+
this.attachOverlayKey(overlayKey);
|
|
61
78
|
}
|
|
62
79
|
/**
|
|
63
80
|
* Builds and starts a running emulator.
|
|
@@ -87,13 +104,18 @@ export class GameKoi {
|
|
|
87
104
|
worklet.connect(audioContext.destination);
|
|
88
105
|
const emulator = new Emulator(options.rom, audioContext.sampleRate);
|
|
89
106
|
const keymap = options.keymap === null ? null : options.keymap ?? DEFAULT_KEYMAP;
|
|
90
|
-
const
|
|
107
|
+
const gamepadMap = options.gamepadMap === null ? null : options.gamepadMap ?? DEFAULT_GAMEPAD_MAP;
|
|
108
|
+
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
109
|
koi.running = true;
|
|
92
110
|
koi.loop();
|
|
93
111
|
return koi;
|
|
94
112
|
}
|
|
95
113
|
/** Swaps in a new ROM, keeping the same canvas and audio graph. */
|
|
96
114
|
loadRom(rom) {
|
|
115
|
+
// The new machine's joypad starts with nothing held, so the snapshot of what is
|
|
116
|
+
// held has to start over too or the first poll will emit no press for a direction
|
|
117
|
+
// that was already down.
|
|
118
|
+
this.gamepad?.releaseAll();
|
|
97
119
|
this.emulator.free();
|
|
98
120
|
this.emulator = new Emulator(rom, this.audioContext.sampleRate);
|
|
99
121
|
}
|
|
@@ -103,9 +125,26 @@ export class GameKoi {
|
|
|
103
125
|
release(button) {
|
|
104
126
|
this.emulator.release(button);
|
|
105
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Shows or hides the debug stats panel — the same thing the ` key does.
|
|
130
|
+
*
|
|
131
|
+
* The panel is built the first time this is called, so a page that never opens it
|
|
132
|
+
* never gets an extra element in its DOM.
|
|
133
|
+
*/
|
|
134
|
+
toggleOverlay() {
|
|
135
|
+
this.overlay ??= new StatsOverlay(this.ctx.canvas);
|
|
136
|
+
this.overlay.toggle();
|
|
137
|
+
}
|
|
138
|
+
/** Whether the stats panel is currently showing. */
|
|
139
|
+
get overlayVisible() {
|
|
140
|
+
return this.overlay?.visible ?? false;
|
|
141
|
+
}
|
|
106
142
|
pause() {
|
|
107
143
|
this.running = false;
|
|
108
144
|
cancelAnimationFrame(this.rafHandle);
|
|
145
|
+
// A direction held at the moment of the pause has no poll coming to release it,
|
|
146
|
+
// so it would still be down when the machine resumes.
|
|
147
|
+
this.applyGamepadActions(this.gamepad?.releaseAll());
|
|
109
148
|
void this.audioContext.suspend();
|
|
110
149
|
}
|
|
111
150
|
resume() {
|
|
@@ -120,6 +159,9 @@ export class GameKoi {
|
|
|
120
159
|
this.running = false;
|
|
121
160
|
cancelAnimationFrame(this.rafHandle);
|
|
122
161
|
this.detachKeyboard();
|
|
162
|
+
if (this.overlayListener)
|
|
163
|
+
removeEventListener("keydown", this.overlayListener);
|
|
164
|
+
this.overlay?.dispose();
|
|
123
165
|
this.emulator.free();
|
|
124
166
|
this.worklet.disconnect();
|
|
125
167
|
void this.audioContext.close();
|
|
@@ -127,6 +169,11 @@ export class GameKoi {
|
|
|
127
169
|
loop = () => {
|
|
128
170
|
if (!this.running)
|
|
129
171
|
return;
|
|
172
|
+
const wokeAt = performance.now();
|
|
173
|
+
this.pollGamepads();
|
|
174
|
+
// Measured from after the input poll: reading a gamepad snapshot is neither
|
|
175
|
+
// emulation nor drawing, and it costs a fraction of a microsecond.
|
|
176
|
+
const emulateStart = performance.now();
|
|
130
177
|
// Emulate however many frames the audio buffer is short by, capped. Audio wins
|
|
131
178
|
// when the display's refresh rate and the Game Boy's 59.73Hz disagree, since a
|
|
132
179
|
// dry buffer is audible and a repeated frame is not.
|
|
@@ -148,10 +195,39 @@ export class GameKoi {
|
|
|
148
195
|
}
|
|
149
196
|
frames++;
|
|
150
197
|
}
|
|
198
|
+
const drawStart = performance.now();
|
|
151
199
|
if (frames > 0)
|
|
152
200
|
this.drawFrame();
|
|
201
|
+
const drawEnd = performance.now();
|
|
202
|
+
this.recordWake(wokeAt, emulateStart, drawStart, drawEnd, frames);
|
|
153
203
|
this.rafHandle = requestAnimationFrame(this.loop);
|
|
154
204
|
};
|
|
205
|
+
/**
|
|
206
|
+
* Feeds the counters and refreshes the panel.
|
|
207
|
+
*
|
|
208
|
+
* Always run, not just while the panel is open: the averages are computed over half a
|
|
209
|
+
* second, so a panel that only started counting when it opened would show nothing for
|
|
210
|
+
* its first window. The cost is four `performance.now()` reads per wake.
|
|
211
|
+
*/
|
|
212
|
+
recordWake(wokeAt, emulateStart, drawStart, drawEnd, frames) {
|
|
213
|
+
this.stats.wake({
|
|
214
|
+
emulate: drawStart - emulateStart,
|
|
215
|
+
draw: frames > 0 ? drawEnd - drawStart : 0,
|
|
216
|
+
frames,
|
|
217
|
+
// The catch-up was clamped and the buffer is still short, so a deficit is
|
|
218
|
+
// being carried into the next wake.
|
|
219
|
+
capped: frames >= this.maxFramesPerWake && this.buffered < this.targetBuffer,
|
|
220
|
+
}, wokeAt);
|
|
221
|
+
this.stats.setAudio({
|
|
222
|
+
buffered: this.buffered,
|
|
223
|
+
target: this.targetBuffer,
|
|
224
|
+
underruns: this.underruns,
|
|
225
|
+
dropped: this.dropped,
|
|
226
|
+
sampleRate: this.audioContext.sampleRate,
|
|
227
|
+
state: this.audioContext.state,
|
|
228
|
+
});
|
|
229
|
+
this.overlay?.update(this.stats.snapshot(), wokeAt);
|
|
230
|
+
}
|
|
155
231
|
drawFrame() {
|
|
156
232
|
// Read `wasmMemory.buffer` fresh rather than caching it: any wasm allocation can
|
|
157
233
|
// grow linear memory, which allocates a new ArrayBuffer and silently detaches
|
|
@@ -161,6 +237,30 @@ export class GameKoi {
|
|
|
161
237
|
this.imageData.data.set(bytes);
|
|
162
238
|
this.ctx.putImageData(this.imageData, 0, 0);
|
|
163
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* Reads every connected pad and applies whatever changed since the last wake.
|
|
242
|
+
*
|
|
243
|
+
* Done here rather than from a listener because the Gamepad API fires no event for a
|
|
244
|
+
* button: `getGamepads()` is a snapshot and polling it is the only way to see one.
|
|
245
|
+
*/
|
|
246
|
+
pollGamepads() {
|
|
247
|
+
if (!this.gamepad)
|
|
248
|
+
return;
|
|
249
|
+
// Guarded because the API is absent outside a secure context, and in a few
|
|
250
|
+
// embedded webviews that still have no gamepad support at all.
|
|
251
|
+
const pads = navigator.getGamepads?.();
|
|
252
|
+
if (!pads)
|
|
253
|
+
return;
|
|
254
|
+
this.applyGamepadActions(this.gamepad.poll(pads));
|
|
255
|
+
}
|
|
256
|
+
applyGamepadActions(actions) {
|
|
257
|
+
for (const action of actions ?? []) {
|
|
258
|
+
if (action.type === "press")
|
|
259
|
+
this.press(action.button);
|
|
260
|
+
else
|
|
261
|
+
this.release(action.button);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
164
264
|
attachKeyboard() {
|
|
165
265
|
this.keydownListener = (event) => {
|
|
166
266
|
const button = this.keymap?.[event.code];
|
|
@@ -185,4 +285,20 @@ export class GameKoi {
|
|
|
185
285
|
if (this.keyupListener)
|
|
186
286
|
removeEventListener("keyup", this.keyupListener);
|
|
187
287
|
}
|
|
288
|
+
/**
|
|
289
|
+
* Binds the panel's toggle key.
|
|
290
|
+
*
|
|
291
|
+
* Its own listener rather than a case inside the keymap one, because the two are
|
|
292
|
+
* independent: a page that drives the joypad itself (`keymap: null`) can still want
|
|
293
|
+
* the panel, and the panel key is not a Game Boy button.
|
|
294
|
+
*/
|
|
295
|
+
attachOverlayKey(code) {
|
|
296
|
+
this.overlayListener = (event) => {
|
|
297
|
+
if (event.code !== code || event.repeat)
|
|
298
|
+
return;
|
|
299
|
+
this.toggleOverlay();
|
|
300
|
+
event.preventDefault();
|
|
301
|
+
};
|
|
302
|
+
addEventListener("keydown", this.overlayListener);
|
|
303
|
+
}
|
|
188
304
|
}
|
|
@@ -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
|
+
}
|
package/dist/stats.d.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side performance counters for the browser build.
|
|
3
|
+
*
|
|
4
|
+
* Everything here describes the *emulator*, not the emulated machine: how fast the page
|
|
5
|
+
* is managing to run it, and whether it is keeping up. A Game Boy has no idea what a
|
|
6
|
+
* wall clock is — its own notion of a frame is 70224 T-cycles, and that number never
|
|
7
|
+
* varies however slowly they are executed.
|
|
8
|
+
*
|
|
9
|
+
* This is the browser counterpart of `game-koi-desktop`'s `stats.rs`, and it counts
|
|
10
|
+
* different things on purpose, because the two frontends are paced differently.
|
|
11
|
+
*
|
|
12
|
+
* # Frames are not wakes
|
|
13
|
+
*
|
|
14
|
+
* The desktop sleeps until each 16.74 ms deadline, so one loop iteration is one frame
|
|
15
|
+
* and a frame rate is just how often the loop ran. Here `requestAnimationFrame` wakes at
|
|
16
|
+
* the *display's* rate — 120 Hz on a fast panel, 0 Hz in a backgrounded tab — and each
|
|
17
|
+
* wake emulates however many frames the audio buffer is short by, which may be two, one
|
|
18
|
+
* or none. Counting wakes would report the refresh rate and say nothing about the
|
|
19
|
+
* emulator, so `fps` counts frames the emulator actually completed. `framesPerWake` is
|
|
20
|
+
* the ratio between the two clocks: 1.0 on a 60 Hz display, ~0.5 on a 120 Hz one.
|
|
21
|
+
*
|
|
22
|
+
* # There is no "blocked" figure here
|
|
23
|
+
*
|
|
24
|
+
* The desktop's most useful number is time neither spent working nor slept away, which
|
|
25
|
+
* is the compositor holding the swapchain. A page never sleeps and never presents, so
|
|
26
|
+
* that figure has no analogue. Its replacement is the audio buffer: since pacing runs
|
|
27
|
+
* off the sound card, "am I keeping up" *is* "is the buffer staying full", and a buffer
|
|
28
|
+
* that reaches zero is the audible failure the whole design exists to avoid. That is why
|
|
29
|
+
* `underruns` is the closest thing here to the desktop's late-frame count.
|
|
30
|
+
*
|
|
31
|
+
* # The clock is passed in, never read
|
|
32
|
+
*
|
|
33
|
+
* Nothing in this module calls `performance.now()`; the caller passes the timestamp it
|
|
34
|
+
* already has. That keeps the counters pure — testable with plain numbers, no timers and
|
|
35
|
+
* no fake clock.
|
|
36
|
+
*/
|
|
37
|
+
/** The DMG's frame rate. Not 60: 4194304 / 70224 cycles per frame. */
|
|
38
|
+
export declare const TARGET_HZ = 59.7275;
|
|
39
|
+
/** The wall-clock budget for one emulated frame, in milliseconds. */
|
|
40
|
+
export declare const FRAME_MS: number;
|
|
41
|
+
/** What one `requestAnimationFrame` wake did. */
|
|
42
|
+
export interface WakeReport {
|
|
43
|
+
/** Milliseconds spent running the emulator and handing its samples to the worklet. */
|
|
44
|
+
emulate: number;
|
|
45
|
+
/** Milliseconds spent blitting to the canvas; zero when nothing was drawn. */
|
|
46
|
+
draw: number;
|
|
47
|
+
/** Frames emulated this wake — often 0 or 1, more while catching up. */
|
|
48
|
+
frames: number;
|
|
49
|
+
/** Whether `maxFramesPerWake` clamped the catch-up, leaving a deficit behind. */
|
|
50
|
+
capped: boolean;
|
|
51
|
+
}
|
|
52
|
+
/** The audio side's view of itself, as last reported by the worklet. */
|
|
53
|
+
export interface AudioReport {
|
|
54
|
+
/** Sample frames sitting in the worklet's ring, waiting to be played. */
|
|
55
|
+
buffered: number;
|
|
56
|
+
/** How many the page tries to keep there. */
|
|
57
|
+
target: number;
|
|
58
|
+
/** Cumulative silent samples emitted because the ring was empty. */
|
|
59
|
+
underruns: number;
|
|
60
|
+
/** Cumulative samples overwritten because the ring was full. */
|
|
61
|
+
dropped: number;
|
|
62
|
+
/** The `AudioContext`'s rate — what the core resamples its output to. */
|
|
63
|
+
sampleRate: number;
|
|
64
|
+
/** `"running"`, `"suspended"` or `"closed"`. */
|
|
65
|
+
state: string;
|
|
66
|
+
}
|
|
67
|
+
/** Everything the panel draws, computed once per refresh. */
|
|
68
|
+
export interface StatsSnapshot {
|
|
69
|
+
/** Emulated frames per second, averaged over the sampling window. */
|
|
70
|
+
fps: number;
|
|
71
|
+
/** `fps` as a percentage of real hardware, where 100% is a DMG. */
|
|
72
|
+
speedPercent: number;
|
|
73
|
+
/** Milliseconds of emulation per emulated frame. */
|
|
74
|
+
emulate: number;
|
|
75
|
+
/** Milliseconds of canvas blitting per emulated frame. */
|
|
76
|
+
draw: number;
|
|
77
|
+
/** `emulate + draw`, the work this page does per frame. */
|
|
78
|
+
work: number;
|
|
79
|
+
/** That work as a fraction of the 16.74 ms a frame is worth. */
|
|
80
|
+
workFraction: number;
|
|
81
|
+
/** Emulated frames per `rAF` wake: the ratio of the two clocks. */
|
|
82
|
+
framesPerWake: number;
|
|
83
|
+
/** How often `rAF` is firing — in practice, the display's refresh rate. */
|
|
84
|
+
wakeRate: number;
|
|
85
|
+
frames: number;
|
|
86
|
+
wakes: number;
|
|
87
|
+
/** Wakes whose catch-up hit `maxFramesPerWake`. */
|
|
88
|
+
cappedWakes: number;
|
|
89
|
+
/** `null` until the worklet has reported once. */
|
|
90
|
+
audio: (AudioReport & {
|
|
91
|
+
bufferedMs: number;
|
|
92
|
+
}) | null;
|
|
93
|
+
}
|
|
94
|
+
/** Rolling counters, recomputed roughly twice a second. */
|
|
95
|
+
export declare class FrameStats {
|
|
96
|
+
private windowStart;
|
|
97
|
+
private framesInWindow;
|
|
98
|
+
private wakesInWindow;
|
|
99
|
+
private emulateInWindow;
|
|
100
|
+
private drawInWindow;
|
|
101
|
+
private fps;
|
|
102
|
+
private wakeRate;
|
|
103
|
+
private emulatePerFrame;
|
|
104
|
+
private drawPerFrame;
|
|
105
|
+
private framesPerWake;
|
|
106
|
+
private frames;
|
|
107
|
+
private wakes;
|
|
108
|
+
private cappedWakes;
|
|
109
|
+
private audio;
|
|
110
|
+
constructor(now: number);
|
|
111
|
+
/** Records a wake and, once the window is old enough, recomputes the averages. */
|
|
112
|
+
wake(report: WakeReport, now: number): void;
|
|
113
|
+
/** Takes the audio thread's latest report of itself. */
|
|
114
|
+
setAudio(report: AudioReport): void;
|
|
115
|
+
snapshot(): StatsSnapshot;
|
|
116
|
+
}
|
package/dist/stats.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side performance counters for the browser build.
|
|
3
|
+
*
|
|
4
|
+
* Everything here describes the *emulator*, not the emulated machine: how fast the page
|
|
5
|
+
* is managing to run it, and whether it is keeping up. A Game Boy has no idea what a
|
|
6
|
+
* wall clock is — its own notion of a frame is 70224 T-cycles, and that number never
|
|
7
|
+
* varies however slowly they are executed.
|
|
8
|
+
*
|
|
9
|
+
* This is the browser counterpart of `game-koi-desktop`'s `stats.rs`, and it counts
|
|
10
|
+
* different things on purpose, because the two frontends are paced differently.
|
|
11
|
+
*
|
|
12
|
+
* # Frames are not wakes
|
|
13
|
+
*
|
|
14
|
+
* The desktop sleeps until each 16.74 ms deadline, so one loop iteration is one frame
|
|
15
|
+
* and a frame rate is just how often the loop ran. Here `requestAnimationFrame` wakes at
|
|
16
|
+
* the *display's* rate — 120 Hz on a fast panel, 0 Hz in a backgrounded tab — and each
|
|
17
|
+
* wake emulates however many frames the audio buffer is short by, which may be two, one
|
|
18
|
+
* or none. Counting wakes would report the refresh rate and say nothing about the
|
|
19
|
+
* emulator, so `fps` counts frames the emulator actually completed. `framesPerWake` is
|
|
20
|
+
* the ratio between the two clocks: 1.0 on a 60 Hz display, ~0.5 on a 120 Hz one.
|
|
21
|
+
*
|
|
22
|
+
* # There is no "blocked" figure here
|
|
23
|
+
*
|
|
24
|
+
* The desktop's most useful number is time neither spent working nor slept away, which
|
|
25
|
+
* is the compositor holding the swapchain. A page never sleeps and never presents, so
|
|
26
|
+
* that figure has no analogue. Its replacement is the audio buffer: since pacing runs
|
|
27
|
+
* off the sound card, "am I keeping up" *is* "is the buffer staying full", and a buffer
|
|
28
|
+
* that reaches zero is the audible failure the whole design exists to avoid. That is why
|
|
29
|
+
* `underruns` is the closest thing here to the desktop's late-frame count.
|
|
30
|
+
*
|
|
31
|
+
* # The clock is passed in, never read
|
|
32
|
+
*
|
|
33
|
+
* Nothing in this module calls `performance.now()`; the caller passes the timestamp it
|
|
34
|
+
* already has. That keeps the counters pure — testable with plain numbers, no timers and
|
|
35
|
+
* no fake clock.
|
|
36
|
+
*/
|
|
37
|
+
/** The DMG's frame rate. Not 60: 4194304 / 70224 cycles per frame. */
|
|
38
|
+
export const TARGET_HZ = 59.7275;
|
|
39
|
+
/** The wall-clock budget for one emulated frame, in milliseconds. */
|
|
40
|
+
export const FRAME_MS = 1000 / TARGET_HZ;
|
|
41
|
+
/**
|
|
42
|
+
* How long to gather before recomputing the averages.
|
|
43
|
+
*
|
|
44
|
+
* A rate computed over a single wake is pure noise — one scheduler hiccup and it reads
|
|
45
|
+
* 12 fps. Averaging over about half a second gives numbers steady enough to read, which
|
|
46
|
+
* matters more here than on the desktop because these are rendered as text that a person
|
|
47
|
+
* is trying to follow rather than a graph.
|
|
48
|
+
*/
|
|
49
|
+
const SAMPLE_WINDOW_MS = 500;
|
|
50
|
+
/** Rolling counters, recomputed roughly twice a second. */
|
|
51
|
+
export class FrameStats {
|
|
52
|
+
windowStart;
|
|
53
|
+
framesInWindow = 0;
|
|
54
|
+
wakesInWindow = 0;
|
|
55
|
+
emulateInWindow = 0;
|
|
56
|
+
drawInWindow = 0;
|
|
57
|
+
fps = 0;
|
|
58
|
+
wakeRate = 0;
|
|
59
|
+
emulatePerFrame = 0;
|
|
60
|
+
drawPerFrame = 0;
|
|
61
|
+
framesPerWake = 0;
|
|
62
|
+
frames = 0;
|
|
63
|
+
wakes = 0;
|
|
64
|
+
cappedWakes = 0;
|
|
65
|
+
audio = null;
|
|
66
|
+
constructor(now) {
|
|
67
|
+
this.windowStart = now;
|
|
68
|
+
}
|
|
69
|
+
/** Records a wake and, once the window is old enough, recomputes the averages. */
|
|
70
|
+
wake(report, now) {
|
|
71
|
+
this.wakes++;
|
|
72
|
+
this.wakesInWindow++;
|
|
73
|
+
this.frames += report.frames;
|
|
74
|
+
this.framesInWindow += report.frames;
|
|
75
|
+
this.emulateInWindow += report.emulate;
|
|
76
|
+
this.drawInWindow += report.draw;
|
|
77
|
+
if (report.capped)
|
|
78
|
+
this.cappedWakes++;
|
|
79
|
+
const elapsed = now - this.windowStart;
|
|
80
|
+
if (elapsed < SAMPLE_WINDOW_MS)
|
|
81
|
+
return;
|
|
82
|
+
const seconds = elapsed / 1000;
|
|
83
|
+
this.fps = this.framesInWindow / seconds;
|
|
84
|
+
this.wakeRate = this.wakesInWindow / seconds;
|
|
85
|
+
// Per *frame*, not per wake: a wake that emulated nothing did no work, and
|
|
86
|
+
// averaging those in would report a machine faster than it is.
|
|
87
|
+
const frames = this.framesInWindow;
|
|
88
|
+
this.emulatePerFrame = frames > 0 ? this.emulateInWindow / frames : 0;
|
|
89
|
+
this.drawPerFrame = frames > 0 ? this.drawInWindow / frames : 0;
|
|
90
|
+
this.framesPerWake = this.wakesInWindow > 0 ? frames / this.wakesInWindow : 0;
|
|
91
|
+
this.windowStart = now;
|
|
92
|
+
this.framesInWindow = 0;
|
|
93
|
+
this.wakesInWindow = 0;
|
|
94
|
+
this.emulateInWindow = 0;
|
|
95
|
+
this.drawInWindow = 0;
|
|
96
|
+
}
|
|
97
|
+
/** Takes the audio thread's latest report of itself. */
|
|
98
|
+
setAudio(report) {
|
|
99
|
+
this.audio = report;
|
|
100
|
+
}
|
|
101
|
+
snapshot() {
|
|
102
|
+
const work = this.emulatePerFrame + this.drawPerFrame;
|
|
103
|
+
return {
|
|
104
|
+
fps: this.fps,
|
|
105
|
+
speedPercent: (this.fps / TARGET_HZ) * 100,
|
|
106
|
+
emulate: this.emulatePerFrame,
|
|
107
|
+
draw: this.drawPerFrame,
|
|
108
|
+
work,
|
|
109
|
+
workFraction: work / FRAME_MS,
|
|
110
|
+
framesPerWake: this.framesPerWake,
|
|
111
|
+
wakeRate: this.wakeRate,
|
|
112
|
+
frames: this.frames,
|
|
113
|
+
wakes: this.wakes,
|
|
114
|
+
cappedWakes: this.cappedWakes,
|
|
115
|
+
audio: this.audio
|
|
116
|
+
? { ...this.audio, bufferedMs: (this.audio.buffered / this.audio.sampleRate) * 1000 }
|
|
117
|
+
: null,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
package/dist/worklet.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const WORKLET_SOURCE = "\n const CAPACITY = 48000; // ~0.5s at 48kHz stereo: rides out a slow main thread.\n\n class KoiProcessor extends AudioWorkletProcessor {\n constructor() {\n super();\n this.left = new Float32Array(CAPACITY);\n this.right = new Float32Array(CAPACITY);\n this.readIndex = 0;\n this.writeIndex = 0;\n\n this.port.onmessage = (event) => {\n const { left, right } = event.data;\n for (let i = 0; i < left.length; i++) {\n const next = (this.writeIndex + 1) % CAPACITY;\n // Full: drop the oldest rather than the newest, so audio does not fall\n // permanently further behind the picture.\n if (next === this.readIndex) {\n this.readIndex = (this.readIndex + 1) % CAPACITY;\n }\n this.left[this.writeIndex] = left[i];\n this.right[this.writeIndex] = right[i];\n this.writeIndex = next;\n }\n };\n }\n\n available() {\n return (this.writeIndex - this.readIndex + CAPACITY) % CAPACITY;\n }\n\n process(_inputs, outputs) {\n const output = outputs[0];\n const outL = output[0];\n const outR = output.length > 1 ? output[1] : output[0];\n\n for (let i = 0; i < outL.length; i++) {\n if (this.readIndex === this.writeIndex) {\n // Silence on underrun. Holding the last sample turns a click into a buzz,\n // which is worse.\n outL[i] = 0;\n outR[i] = 0;\n } else {\n outL[i] = this.left[this.readIndex];\n outR[i] = this.right[this.readIndex];\n this.readIndex = (this.readIndex + 1) % CAPACITY;\n }\n }\n\n // The main thread paces itself off
|
|
1
|
+
export declare const WORKLET_SOURCE = "\n const CAPACITY = 48000; // ~0.5s at 48kHz stereo: rides out a slow main thread.\n\n class KoiProcessor extends AudioWorkletProcessor {\n constructor() {\n super();\n this.left = new Float32Array(CAPACITY);\n this.right = new Float32Array(CAPACITY);\n this.readIndex = 0;\n this.writeIndex = 0;\n // The two ways this can go wrong, counted for the stats panel. Both are\n // cumulative: what matters is whether they are climbing, not their value.\n this.underruns = 0;\n this.dropped = 0;\n\n this.port.onmessage = (event) => {\n const { left, right } = event.data;\n for (let i = 0; i < left.length; i++) {\n const next = (this.writeIndex + 1) % CAPACITY;\n // Full: drop the oldest rather than the newest, so audio does not fall\n // permanently further behind the picture.\n if (next === this.readIndex) {\n this.readIndex = (this.readIndex + 1) % CAPACITY;\n this.dropped++;\n }\n this.left[this.writeIndex] = left[i];\n this.right[this.writeIndex] = right[i];\n this.writeIndex = next;\n }\n };\n }\n\n available() {\n return (this.writeIndex - this.readIndex + CAPACITY) % CAPACITY;\n }\n\n process(_inputs, outputs) {\n const output = outputs[0];\n const outL = output[0];\n const outR = output.length > 1 ? output[1] : output[0];\n\n for (let i = 0; i < outL.length; i++) {\n if (this.readIndex === this.writeIndex) {\n // Silence on underrun. Holding the last sample turns a click into a buzz,\n // which is worse.\n outL[i] = 0;\n outR[i] = 0;\n this.underruns++;\n } else {\n outL[i] = this.left[this.readIndex];\n outR[i] = this.right[this.readIndex];\n this.readIndex = (this.readIndex + 1) % CAPACITY;\n }\n }\n\n // The main thread paces itself off \"buffered\", so it goes back every block. The\n // counters ride along in the same message rather than in one of their own \u2014\n // there is no cheaper time to send them, and a second postMessage per block from\n // the audio thread would cost more than the numbers are worth.\n this.port.postMessage({\n buffered: this.available(),\n underruns: this.underruns,\n dropped: this.dropped,\n });\n return true;\n }\n }\n\n registerProcessor(\"koi-processor\", KoiProcessor);\n";
|
package/dist/worklet.js
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Kept as a source string, not a separate .js file, so `GameKoi` can register it via a
|
|
12
12
|
// Blob URL — a consumer never needs to serve this file or point a bundler at it.
|
|
13
|
+
//
|
|
14
|
+
// Being a template literal, the body below cannot contain a backtick or a `${`, even in
|
|
15
|
+
// a comment: either one ends the string. The failure is a parse error in *this* file
|
|
16
|
+
// pointing at a line that looks fine.
|
|
13
17
|
export const WORKLET_SOURCE = `
|
|
14
18
|
const CAPACITY = 48000; // ~0.5s at 48kHz stereo: rides out a slow main thread.
|
|
15
19
|
|
|
@@ -20,6 +24,10 @@ export const WORKLET_SOURCE = `
|
|
|
20
24
|
this.right = new Float32Array(CAPACITY);
|
|
21
25
|
this.readIndex = 0;
|
|
22
26
|
this.writeIndex = 0;
|
|
27
|
+
// The two ways this can go wrong, counted for the stats panel. Both are
|
|
28
|
+
// cumulative: what matters is whether they are climbing, not their value.
|
|
29
|
+
this.underruns = 0;
|
|
30
|
+
this.dropped = 0;
|
|
23
31
|
|
|
24
32
|
this.port.onmessage = (event) => {
|
|
25
33
|
const { left, right } = event.data;
|
|
@@ -29,6 +37,7 @@ export const WORKLET_SOURCE = `
|
|
|
29
37
|
// permanently further behind the picture.
|
|
30
38
|
if (next === this.readIndex) {
|
|
31
39
|
this.readIndex = (this.readIndex + 1) % CAPACITY;
|
|
40
|
+
this.dropped++;
|
|
32
41
|
}
|
|
33
42
|
this.left[this.writeIndex] = left[i];
|
|
34
43
|
this.right[this.writeIndex] = right[i];
|
|
@@ -52,6 +61,7 @@ export const WORKLET_SOURCE = `
|
|
|
52
61
|
// which is worse.
|
|
53
62
|
outL[i] = 0;
|
|
54
63
|
outR[i] = 0;
|
|
64
|
+
this.underruns++;
|
|
55
65
|
} else {
|
|
56
66
|
outL[i] = this.left[this.readIndex];
|
|
57
67
|
outR[i] = this.right[this.readIndex];
|
|
@@ -59,8 +69,15 @@ export const WORKLET_SOURCE = `
|
|
|
59
69
|
}
|
|
60
70
|
}
|
|
61
71
|
|
|
62
|
-
// The main thread paces itself off
|
|
63
|
-
|
|
72
|
+
// The main thread paces itself off "buffered", so it goes back every block. The
|
|
73
|
+
// counters ride along in the same message rather than in one of their own —
|
|
74
|
+
// there is no cheaper time to send them, and a second postMessage per block from
|
|
75
|
+
// the audio thread would cost more than the numbers are worth.
|
|
76
|
+
this.port.postMessage({
|
|
77
|
+
buffered: this.available(),
|
|
78
|
+
underruns: this.underruns,
|
|
79
|
+
dropped: this.dropped,
|
|
80
|
+
});
|
|
64
81
|
return true;
|
|
65
82
|
}
|
|
66
83
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "game-koi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A Game Boy (DMG) emulator, compiled to WebAssembly, with a drop-in canvas + audio wrapper",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"sideEffects": false,
|
|
21
21
|
"scripts": {
|
|
22
22
|
"build": "tsc -p tsconfig.json",
|
|
23
|
+
"test": "npm run build && node --test \"test/*.test.js\"",
|
|
23
24
|
"prepublishOnly": "npm run build"
|
|
24
25
|
},
|
|
25
26
|
"keywords": [
|
|
Binary file
|