Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
/build/
/rubies/

.DS_Store
*.wasm
*.sav
*.sav.tmp
Expand All @@ -17,6 +18,3 @@

# rspec failure tracking
.rspec_status

# Local Spinel build metadata (Wasm binaries are ignored above)
/docs/spinel/build-info.json
62 changes: 62 additions & 0 deletions BENCHMARK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Benchmarks

## Choosing a method

| Runtime | Method |
| --- | --- |
| CRuby (with or without YJIT) | [`rubyboy-bench`](#cruby) |
| Spinel, native executable | [`rubyboy-bench` compiled with Spinel](#spinel) |
| ruby.wasm, Spinel WebAssembly | Not available. The FPS counter on the web page shows drawn frames per second and is not a benchmark. |

## CRuby

From the repository root:

```sh
bundle exec ruby --yjit -Ilib exe/rubyboy-bench \
--rom-path lib/roms/tobu.gb --frames 1500 --warmup-frames 1500 --count 5
```

| Option | Default | Meaning |
| --- | --- | --- |
| `--rom-path PATH` | `lib/roms/tobu.gb` | ROM to run |
| `--frames N` | 1500 | Measured frames per trial |
| `--warmup-frames N` | 0 | Unmeasured frames before each trial |
| `--count N` | 3 | Number of trials |

Each trial starts with a fresh emulator and runs the CPU, timer, PPU and APU
without input, saves, drawing, audio output or real-time synchronization.
The command prints each trial's duration and the aggregate FPS (total measured
frames divided by total measured time).

## Spinel

Build `rubyboy-bench` with Spinel as described in
[spinel/README.md](spinel/README.md#benchmark), then run it with the same options:

```sh
build/spinel/rubyboy-bench \
--rom-path lib/roms/tobu.gb --frames 1500 --warmup-frames 1500 --count 5
```

The executable prints `Ruby: 4.0.7`, which is the Ruby version Spinel reports,
not a CRuby version. Record `spinel --version` instead.

## Comparing results

Use the same machine, ROM, `--frames`, `--warmup-frames` and `--count` for every
runtime. Use a warmup so that YJIT has compiled the hot code before measurement.
Record the Ruby version (`ruby -v`) or Spinel revision (`spinel --version`) and the
Rubyboy revision with each result.

## Results

Measured on 2026-10-10 with Rubyboy `3484036` on a MacBook Air (Apple M4, macOS 15.5)
on AC power with other applications closed, using the commands above
(`tobu.gb`, `--frames 1500 --warmup-frames 1500 --count 5`).

| Runtime | Version | FPS |
| --- | --- | ---: |
| CRuby | 4.0.7 | 114 |
| CRuby + YJIT | 4.0.7 | 297 |
| Spinel (native) | `2810a23` | 603 |
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## [Unreleased]

- Keep audio output with --unlimited by dropping audio that cannot be played in time
- Remove the --no-audio player option
- Remove rubyboy-bench --render and --json, and the SDL 2.0.18 requirement
- Stop shipping the rubyboy-wasm build command and the browser files in the gem

## [1.6.0] - 2026-10-04

- Add battery-backed saves and hardware-oriented save states
Expand Down
2 changes: 0 additions & 2 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,9 @@ gem 'rake', '~> 13.0'
group :development, :test do
# These bundled gems are required by older RuboCop on recent Ruby versions.
gem 'benchmark'
gem 'heap-profiler', '~> 0.7.0'
gem 'ostruct'
gem 'rspec', '~> 3.12'
gem 'rubocop', '~> 1.57'
gem 'stackprof', '~> 0.2.25'
end

group :wasm do
Expand Down
5 changes: 0 additions & 5 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ PATH
specs:
rubyboy (1.6.0)
ffi (~> 1.16, >= 1.16.3)
json (~> 2.8)

GEM
remote: https://rubygems.org/
Expand All @@ -13,7 +12,6 @@ GEM
benchmark (0.5.0)
diff-lcs (1.5.0)
ffi (1.16.3)
heap-profiler (0.7.0)
js (2.10.1)
json (2.18.0)
language_server-protocol (3.17.0.3)
Expand Down Expand Up @@ -64,7 +62,6 @@ GEM
logger
ruby_wasm (2.10.1-x86_64-linux)
logger
stackprof (0.2.25)
unicode-display_width (2.5.0)

PLATFORMS
Expand All @@ -75,15 +72,13 @@ PLATFORMS

DEPENDENCIES
benchmark
heap-profiler (~> 0.7.0)
js (= 2.10.1)
ostruct
rake (~> 13.0)
rspec (~> 3.12)
rubocop (~> 1.57)
ruby_wasm (= 2.10.1)
rubyboy!
stackprof (~> 0.2.25)

BUNDLED WITH
4.0.20
146 changes: 37 additions & 109 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,137 +6,65 @@

A Game Boy emulator written in Ruby

**[Try the demo in your browser!](https://sacckey.github.io/rubyboy/)** - Powered by WebAssembly

The [Spinel version](https://sacckey.github.io/rubyboy/spinel/) uses the same UI
with Ruby compiled to C and then to WebAssembly, without `ruby.wasm`.
See [build instructions](wasm/README.md).
**Try it in your browser:**
[ruby.wasm version](https://sacckey.github.io/rubyboy/) (CRuby compiled to WebAssembly) /
[Spinel version](https://sacckey.github.io/rubyboy/spinel/) (the same Ruby code compiled to C by Spinel, then to WebAssembly)

## Screenshots
<div align="center">
<img src="/resource/screenshots/pokemon.png" width="400px"/>
<img src="/resource/screenshots/puyopuyo.png" width="400px"/>
</div>

## Requirements
[SDL2](https://wiki.libsdl.org/SDL2/Installation) 2.0.18 or higher.

## Installation

This project requires Ruby 3.2.0 or higher.
Rubyboy requires Ruby 3.2 or higher and [SDL2](https://wiki.libsdl.org/SDL2/Installation).

$ gem install rubyboy

> [!TIP]
> To enhance performance, it is highly recommended to use Ruby 3.3.x with YJIT.
> Run with YJIT (`RUBYOPT=--yjit`) for better performance.

Install the gem and add to the application's Gemfile by executing:
## Usage

$ bundle add rubyboy
$ RUBYOPT=--yjit rubyboy <rom_path>

If bundler is not being used to manage dependencies, install the gem by executing:
### Controls

$ gem install ffi rubyboy
| Key | Button |
| :---: | :---: |
| `W` `A` `S` `D` | ↑ ← ↓ → |
| `K` | A |
| `J` | B |
| `U` | Select |
| `I` | Start |
| `1`–`0` | Save state to slot 1–9, 0 |
| Left `Shift` + `1`–`0` | Load state from that slot |

## Usage
Battery-backed games save to `<rom>.sav`, and save states go to `<rom>.state<N>`,
next to the ROM file.

$ RUBYOPT=--yjit rubyboy <rom_path>
### Options

| Key | Button |
| :---: | :----: |
| `W` | ↑ |
| `A` | ← |
| `S` | ↓ |
| `D` | → |
| `J` | A |
| `K` | B |
| `U` | Select |
| `I` | Start |

## Bounded execution

The player accepts `--frames N` to stop after N completed frames, `--no-audio`
to disable audio output while continuing APU emulation, and `--unlimited` to
disable real-time synchronization:

```sh
bundle exec ruby --yjit -Ilib exe/rubyboy --frames 600 --unlimited --no-audio lib/roms/tobu.gb
```

Normal playback remains synchronized to real time with audio enabled.
`--unlimited` alone still permits audio queue backpressure; use `--no-audio`
for maximum speed. These player options retain keyboard input and battery save
loading/writing. Use the benchmark below for comparisons with a fixed start state.

## Benchmarking

Run a headless benchmark with an explicit warmup before each measured trial:

```sh
bundle exec ruby --yjit -Ilib exe/rubyboy-bench \
--rom-path lib/roms/tobu.gb --frames 1500 --warmup-frames 1500 --count 5 --json
```

Add `--render` to include SDL drawing, with audio output and VSync disabled.
No environment variable is required; the benchmark overrides the VSync setting
for its renderer, including `SDL_RENDER_VSYNC=1`:

```sh
bundle exec ruby --yjit -Ilib exe/rubyboy-bench \
--rom-path lib/roms/tobu.gb --frames 1500 --warmup-frames 1500 --count 5 --render --json
```

Both modes run the same CPU, timer, PPU and APU frame processing. Each trial
starts with a fresh emulator, ignores battery saves, accepts no game input and
runs without real-time synchronization or audio output. Initialization, warmup
and display cleanup are excluded from the measured duration. Warmup advances
the machine state by the requested number of frames in both modes. The defaults
are 3 trials, 1500 measured frames and no warmup; counts and measured frames must
be positive, and warmup frames may be zero.

Without `--json`, the command prints each trial's duration and aggregate FPS.
With `--json`, stdout contains one JSON document with individual durations/FPS,
aggregate FPS, runtime identity and execution conditions. Aggregate FPS is the
total measured frame count divided by the sum of measured durations. Closing
the window before a trial finishes aborts the benchmark with a nonzero exit
status instead of reporting a result for that incomplete trial.

For comparisons, use the same source revision, ROM, frame counts, warmup,
window/display settings and SDL render driver. Normal playback retains its
existing VSync setting. Rendered FPS measures frame
processing and SDL drawing calls; it does not count distinct frames visible on
the monitor. Measure performance without recording, then use a separate
`--render --count 1` run for a reference recording. Preserve elapsed real time
when converting the recording to GIF so that speed differences remain visible.

## Browser playback (ruby.wasm)

The existing browser UI uses a worker for emulation and an AudioWorklet for
sound. Use the **Limit to 60 FPS** and **Mute** checkboxes in **Performance** to
change playback while running. Both are checked by default. URL parameters set
their initial state: `?throttle=0&mute=1` for unlimited, muted playback, or
`?throttle=1&mute=0` for normal playback with sound after the first interaction.
The APU continues to run in all modes.

The large Wasm download remains local-first with the existing proxy fallback;
Wasm files remain excluded from Git. The Ruby VM, Rubyboy sources and ROMs are
packed into one Wasm file. Repack and upload `rubyboy.wasm` after changing the
emulator code.

```sh
mise exec ruby@4.0.7 -- ruby exe/rubyboy-wasm pack
python3 -m http.server 8765 --bind 127.0.0.1 --directory docs
```

See [browser playback and verification](docs/wasm-playback.md) for the build,
verification commands and comparison conditions.
| Option | Effect |
| --- | --- |
| `--frames N` | Exit after N frames |
| `--unlimited` | Run as fast as possible; audio that cannot be played in time is dropped |

## Benchmark

On an Apple M4, Rubyboy runs at about 110 FPS on CRuby, 300 FPS with YJIT and
600 FPS when compiled with Spinel. See [BENCHMARK.md](BENCHMARK.md) for the method and details.

## Other ways to run

- [WebAssembly builds](wasm/README.md): the browser versions (ruby.wasm and Spinel)
- [Spinel](spinel/README.md): compile Rubyboy to a native executable

## Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/sacckey/rubyboy. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/sacckey/rubyboy/blob/main/CODE_OF_CONDUCT.md).
Bug reports and pull requests are welcome on GitHub at https://github.com/sacckey/rubyboy. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](CODE_OF_CONDUCT.md).

## License

The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).

## Code of Conduct

Everyone interacting in the Rubyboy project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/sacckey/rubyboy/blob/main/CODE_OF_CONDUCT.md).
22 changes: 4 additions & 18 deletions docs/emulation-loop.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
const CLOCK_HZ = 4_194_304;
const FRAME_CYCLES = 70_224;
const FRAME_MILLISECONDS = FRAME_CYCLES / CLOCK_HZ * 1000;
const DIRECTION_KEYS = { KeyD: 1, KeyA: 2, KeyW: 4, KeyS: 8 };
const ACTION_KEYS = { KeyK: 1, KeyJ: 2, KeyU: 4, KeyI: 8 };

export class EmulationLoop {
constructor(adapter, emit, options = {}) {
Expand All @@ -19,7 +17,6 @@ export class EmulationLoop {
this.action = 0;
this.directionPending = 0;
this.actionPending = 0;
this.completedFrames = 0;
this.loopHandle = null;
this.loopTick = this.tick.bind(this);
this.resetClock();
Expand All @@ -43,15 +40,6 @@ export class EmulationLoop {
this.actionPending |= this.action;
}

updateInput(code, pressed) {
const directionMask = DIRECTION_KEYS[code] || 0;
const actionMask = ACTION_KEYS[code] || 0;
this.setInput(
pressed ? this.direction | directionMask : this.direction & ~directionMask,
pressed ? this.action | actionMask : this.action & ~actionMask,
);
}

releaseInputs() {
this.direction = this.action = this.directionPending = this.actionPending = 0;
}
Expand All @@ -64,9 +52,9 @@ export class EmulationLoop {
this.directionPending = this.actionPending = 0;
}

sendFrame(frameCount) {
sendFrame() {
const data = this.adapter.framebuffer();
this.emit({ type: 'pixelData', data, frameCount, completedFrames: this.completedFrames }, [data]);
this.emit({ type: 'pixelData', data }, [data]);
}

drainAudio() {
Expand All @@ -77,12 +65,10 @@ export class EmulationLoop {
}

runFrame() {
const frames = this.adapter.runFrame(...this.inputMasks());
this.completedFrames += frames;
// Even an LCD-off fallback advances a frame's CPU time.
this.adapter.runFrame(...this.inputMasks());
this.clearPending();
this.drainAudio();
this.sendFrame(frames);
this.sendFrame();
}

runThrottled() {
Expand Down
Loading
Loading