diff --git a/BENCHMARK.md b/BENCHMARK.md index 96c1514..95f6e44 100644 --- a/BENCHMARK.md +++ b/BENCHMARK.md @@ -26,8 +26,8 @@ bundle exec ruby --yjit -Ilib exe/rubyboy-bench \ 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). +The command prints each trial's duration, the aggregate FPS (total measured +frames divided by total measured time) and a checksum of the final frame. ## Spinel @@ -49,14 +49,20 @@ 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. +The checksum depends only on the ROM and the total of `--warmup-frames` and `--frames`, +so every runtime must print the same value. A different value means that runtime +emulated incorrectly. For example, with the bundled ROM, `--frames 600 --count 1` +prints `Checksum: 661048662`. + ## Results -Measured on 2026-10-10 with Rubyboy `3484036` on a MacBook Air (Apple M4, macOS 15.5) +Measured on 2026-10-10 with Rubyboy 1.7.0 (`44f834f`) 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`). +All three printed `Checksum: 3889316277`. | Runtime | Version | FPS | | --- | --- | ---: | -| CRuby | 4.0.7 | 114 | -| CRuby + YJIT | 4.0.7 | 297 | -| Spinel (native) | `2810a23` | 603 | +| CRuby | 4.0.7 | 113 | +| CRuby + YJIT | 4.0.7 | 294 | +| Spinel (native) | `7d999f50c` | 799 | diff --git a/CHANGELOG.md b/CHANGELOG.md index 09b2a33..bbcc456 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,11 @@ -## [Unreleased] +## [1.7.0] - 2026-10-10 - Require Ruby 4.0 or higher - 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 +- Print a checksum of the final frame from rubyboy-bench to check that a runtime emulated correctly +- Run rubyboy-bench without loading SDL - Run the bundled Tobu Tobu Girl ROM when rubyboy or rubyboy-bench gets no ROM path, from any directory - Read ROM files in binary mode, which fixes loading them on Windows - Make the APU duty tables shareable between Ractors diff --git a/Gemfile.lock b/Gemfile.lock index 60bd766..9daf4e4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - rubyboy (1.6.0) + rubyboy (1.7.0) ffi (~> 1.16, >= 1.16.3) GEM diff --git a/README.md b/README.md index aad7da2..34d3378 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,8 @@ next to the ROM file. ## 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. +On an Apple M4, Rubyboy runs at about 110 FPS on CRuby, 290 FPS with YJIT and +800 FPS when compiled with Spinel. See [BENCHMARK.md](BENCHMARK.md) for the method and details. ## Other ways to run diff --git a/exe/rubyboy-bench b/exe/rubyboy-bench index 692381f..a394a9f 100755 --- a/exe/rubyboy-bench +++ b/exe/rubyboy-bench @@ -1,7 +1,6 @@ #!/usr/bin/env ruby # frozen_string_literal: true -require 'rubyboy' require 'bench' require 'optparse' diff --git a/lib/bench.rb b/lib/bench.rb index d181049..fd15358 100644 --- a/lib/bench.rb +++ b/lib/bench.rb @@ -10,6 +10,7 @@ def run(count: 3, frames: 1500, rom_path: Rom::DEFAULT_PATH, warmup_frames: 0) validate_integer(:warmup_frames, warmup_frames, 0) time_sum = 0 + frame_checksum = 0 count.times do |i| emulator = Rubyboy::EmulatorHeadless.new(rom_path) run_frames(emulator, warmup_frames) @@ -19,9 +20,12 @@ def run(count: 3, frames: 1500, rom_path: Rom::DEFAULT_PATH, warmup_frames: 0) puts "#{i + 1}: #{time / 1_000_000_000.0} sec" time_sum += time + frame_checksum = checksum(emulator.framebuffer) end puts "FPS: #{frames * count * 1_000_000_000.0 / time_sum}" + # Every trial ends on the same frame. A different value on another runtime means it emulated incorrectly. + puts "Checksum: #{frame_checksum}" end private @@ -33,6 +37,11 @@ def validate_integer(name, value, minimum) raise ArgumentError, "#{name} must be #{requirement}" end + # Integer arithmetic only, so CRuby and ahead-of-time compilers such as Spinel produce the same value. + def checksum(framebuffer) + framebuffer.reduce(0) { |hash, pixel| ((hash * 31) + pixel) & 0xffffffff } + end + def run_frames(emulator, frames) frame_count = 0 while frame_count < frames diff --git a/lib/rubyboy/emulator_headless.rb b/lib/rubyboy/emulator_headless.rb index 5a37581..737a7f2 100644 --- a/lib/rubyboy/emulator_headless.rb +++ b/lib/rubyboy/emulator_headless.rb @@ -27,6 +27,10 @@ def initialize(rom_path) @cpu = Cpu.new(bus, interrupt) end + def framebuffer + @ppu.buffer + end + def step loop do cycles = @cpu.exec diff --git a/lib/rubyboy/version.rb b/lib/rubyboy/version.rb index de4e5d9..4491007 100644 --- a/lib/rubyboy/version.rb +++ b/lib/rubyboy/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Rubyboy - VERSION = '1.6.0' + VERSION = '1.7.0' end diff --git a/spec/bench_spec.rb b/spec/bench_spec.rb index 626ef57..81dbeba 100644 --- a/spec/bench_spec.rb +++ b/spec/bench_spec.rb @@ -5,20 +5,20 @@ require 'rbconfig' RSpec.describe Rubyboy::Bench do - let(:emulator) { instance_double(Rubyboy::EmulatorHeadless, step: nil) } + let(:emulator) { instance_double(Rubyboy::EmulatorHeadless, step: nil, framebuffer: [1, 2]) } before do allow(Rubyboy::EmulatorHeadless).to receive(:new).and_return(emulator) end it 'starts each trial from a fresh emulator, warms up identically, and aggregates measured durations' do - second_emulator = instance_double(Rubyboy::EmulatorHeadless, step: nil) + second_emulator = instance_double(Rubyboy::EmulatorHeadless, step: nil, framebuffer: [1, 2]) allow(Rubyboy::EmulatorHeadless).to receive(:new).with('test.gb').and_return(emulator, second_emulator) allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC, :nanosecond).and_return(1_000_000_000, 2_000_000_000, 3_000_000_000, 5_000_000_000) expect do described_class.new.run(count: 2, frames: 3, warmup_frames: 2, rom_path: 'test.gb') - end.to output("1: 1.0 sec\n2: 2.0 sec\nFPS: 2.0\n").to_stdout + end.to output("1: 1.0 sec\n2: 2.0 sec\nFPS: 2.0\nChecksum: 33\n").to_stdout expect(Rubyboy::EmulatorHeadless).to have_received(:new).with('test.gb').twice expect(emulator).to have_received(:step).exactly(5).times @@ -59,6 +59,13 @@ expect(stdout).to match(/^FPS: /) end + it 'prints the same checksum of the final frame on every run' do + stdout, stderr, status = Open3.capture3(RbConfig.ruby, '-Ilib', 'exe/rubyboy-bench', '--count', '2', '--frames', '60', chdir: repository_root) + + expect(status.success?).to be(true), stderr + expect(stdout).to end_with("Checksum: 4059938609\n") + end + it 'rejects an invalid count with a failing exit status' do _stdout, stderr, status = Open3.capture3(RbConfig.ruby, '-Ilib', 'exe/rubyboy-bench', '--count', '0', chdir: repository_root)