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 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 the built-in keyboard layout (arrow
30
- keys, Z/X, Enter, right Shift) is wired up automatically. Pass `keymap: null` to
31
- `create()` to opt out of that and drive `press`/`release` yourself — from a gamepad,
32
- touch controls, or your own key bindings.
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
+ }
@@ -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
- constructor(emulator, wasmMemory, ctx, audioContext, worklet, keymap, targetBuffer, maxFramesPerWake) {
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 koi = new GameKoi(emulator, wasm.memory, ctx, audioContext, worklet, keymap, options.targetBuffer ?? 1600, options.maxFramesPerWake ?? 4);
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;
@@ -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
+ }
@@ -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 this number, so it goes back every block.\n this.port.postMessage({ buffered: this.available() });\n return true;\n }\n }\n\n registerProcessor(\"koi-processor\", KoiProcessor);\n";
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 this number, so it goes back every block.
63
- this.port.postMessage({ buffered: this.available() });
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.1.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