game-koi 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.2",
3
+ "version": "0.3.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