rubyboy 1.5.1 → 1.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 22c8e1f0094ec086f0e7130e6c79623d0e9385c718f0724ca2773c101ff3b976
4
- data.tar.gz: 49adde27b593ef7bf8bf3f874160922ed7994388960db71f5757b2cbf033735f
3
+ metadata.gz: '09e12943a26b3089da7e75f3a26a0994dd71c1a748b63c840c49c3772502b625'
4
+ data.tar.gz: c37fdfa6182bb28cbca6ffe5438670860e0e6c10e32b581509dbfca28e12f70d
5
5
  SHA512:
6
- metadata.gz: 48abbc79e8dde1e691f3f76a2736ad909b29af549c3c3b6a99eedd16a5b832f8b84bfb810ed4115483e116f5acbd50e1b07ec333e61778dc82433293f36a8401
7
- data.tar.gz: dcf874037cd1d133687e3a99c5aeb36d97cd8a8f356514bb97b40eec57184b3bed7bd003538925fce18875d13d8bd37e1f50ffdd646568b5fbc351925f710a5a
6
+ metadata.gz: 35c057be65b5b1143d870d6db5d8a0b769074e42c10281680b411e1d348f2b8b5109ff12dcddae3ca3b803b6a379fff4f7343fd0e16b111e25686d25d5468861
7
+ data.tar.gz: 900d258e730fdd03f23b508d1ff4cfd3e80b738ebf0c9ce49e6a064faeb0456a01be84bab269d62f59bcb7895d25b1dfeb08d41a2d39c0fabce4b52427d63a91
data/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ ## [1.6.0] - 2026-10-04
2
+
3
+ - Add battery-backed saves and hardware-oriented save states
4
+ - Fix MBC1 ROM bank mapping, external RAM size and RAM banking
5
+ - Fix audio drop-outs and APU sample-rate drift
6
+ - Support Spinel compilation and fix SDL event allocation
7
+ - Add --frames, --unlimited and --no-audio player options
8
+ - Add benchmark warmup, SDL rendering and JSON output
9
+ - Disable VSync for rendered benchmarks and require SDL 2.0.18 or higher
10
+ - Update JSON and development dependencies for recent Ruby versions
11
+
1
12
  ## [1.5.1] - 2025-02-16
2
13
 
3
14
  - Optimize Cartridge::Mbc1 and Bus
data/README.md CHANGED
@@ -15,7 +15,7 @@ A Game Boy emulator written in Ruby
15
15
  </div>
16
16
 
17
17
  ## Requirements
18
- [SDL2](https://wiki.libsdl.org/SDL2/Installation)
18
+ [SDL2](https://wiki.libsdl.org/SDL2/Installation) 2.0.18 or higher.
19
19
 
20
20
  ## Installation
21
21
 
@@ -47,6 +47,62 @@ If bundler is not being used to manage dependencies, install the gem by executin
47
47
  | `U` | Select |
48
48
  | `I` | Start |
49
49
 
50
+ ## Bounded execution
51
+
52
+ The player accepts `--frames N` to stop after N completed frames, `--no-audio`
53
+ to disable audio output while continuing APU emulation, and `--unlimited` to
54
+ disable real-time synchronization:
55
+
56
+ ```sh
57
+ bundle exec ruby --yjit -Ilib exe/rubyboy --frames 600 --unlimited --no-audio lib/roms/tobu.gb
58
+ ```
59
+
60
+ Normal playback remains synchronized to real time with audio enabled.
61
+ `--unlimited` alone still permits audio queue backpressure; use `--no-audio`
62
+ for maximum speed. These player options retain keyboard input and battery save
63
+ loading/writing. Use the benchmark below for comparisons with a fixed start state.
64
+
65
+ ## Benchmarking
66
+
67
+ Run a headless benchmark with an explicit warmup before each measured trial:
68
+
69
+ ```sh
70
+ bundle exec ruby --yjit -Ilib exe/rubyboy-bench \
71
+ --rom-path lib/roms/tobu.gb --frames 1500 --warmup-frames 1500 --count 5 --json
72
+ ```
73
+
74
+ Add `--render` to include SDL drawing, with audio output and VSync disabled.
75
+ No environment variable is required; the benchmark overrides the VSync setting
76
+ for its renderer, including `SDL_RENDER_VSYNC=1`:
77
+
78
+ ```sh
79
+ bundle exec ruby --yjit -Ilib exe/rubyboy-bench \
80
+ --rom-path lib/roms/tobu.gb --frames 1500 --warmup-frames 1500 --count 5 --render --json
81
+ ```
82
+
83
+ Both modes run the same CPU, timer, PPU and APU frame processing. Each trial
84
+ starts with a fresh emulator, ignores battery saves, accepts no game input and
85
+ runs without real-time synchronization or audio output. Initialization, warmup
86
+ and display cleanup are excluded from the measured duration. Warmup advances
87
+ the machine state by the requested number of frames in both modes. The defaults
88
+ are 3 trials, 1500 measured frames and no warmup; counts and measured frames must
89
+ be positive, and warmup frames may be zero.
90
+
91
+ Without `--json`, the command prints each trial's duration and aggregate FPS.
92
+ With `--json`, stdout contains one JSON document with individual durations/FPS,
93
+ aggregate FPS, runtime identity and execution conditions. Aggregate FPS is the
94
+ total measured frame count divided by the sum of measured durations. Closing
95
+ the window before a trial finishes aborts the benchmark with a nonzero exit
96
+ status instead of reporting a result for that incomplete trial.
97
+
98
+ For comparisons, use the same source revision, ROM, frame counts, warmup,
99
+ window/display settings and SDL render driver. Normal playback retains its
100
+ existing VSync setting. Rendered FPS measures frame
101
+ processing and SDL drawing calls; it does not count distinct frames visible on
102
+ the monitor. Measure performance without recording, then use a separate
103
+ `--render --count 1` run for a reference recording. Preserve elapsed real time
104
+ when converting the recording to GIF so that speed differences remain visible.
105
+
50
106
  ## Contributing
51
107
 
52
108
  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).
data/exe/rubyboy CHANGED
@@ -4,16 +4,29 @@
4
4
  require 'rubyboy'
5
5
  require 'optparse'
6
6
 
7
+ frames = nil
8
+ realtime = true
9
+ audio = true
10
+
7
11
  begin
8
12
  OptionParser.new do |opts|
9
13
  opts.banner = "Usage: #{File.basename($PROGRAM_NAME)} ROM_PATH"
10
14
 
15
+ opts.on('--frames N', 'Exit after N frames (positive integer)') do |value|
16
+ value = Integer(value)
17
+ raise OptionParser::InvalidArgument, 'frames must be a positive integer' unless value > 0
18
+
19
+ frames = value
20
+ end
21
+ opts.on('--unlimited', 'Disable real-time synchronization') { realtime = false }
22
+ opts.on('--no-audio', 'Disable audio output while keeping APU emulation') { audio = false }
23
+
11
24
  opts.on('-h', '--help', 'Displays this help') do
12
25
  puts opts
13
26
  exit
14
27
  end
15
28
  end.parse!
16
- rescue OptionParser::ParseError => e
29
+ rescue OptionParser::ParseError, ArgumentError => e
17
30
  puts e.message
18
31
  puts "Usage: #{File.basename($PROGRAM_NAME)} ROM_PATH"
19
32
  exit 1
@@ -28,4 +41,4 @@ else
28
41
  puts 'YJIT is not available in this environment.'
29
42
  end
30
43
 
31
- Rubyboy::Emulator.new(rom_path).start
44
+ Rubyboy::Emulator.new(rom_path, audio:).start(frames:, realtime:)
data/exe/rubyboy-bench CHANGED
@@ -10,12 +10,24 @@ begin
10
10
  OptionParser.new do |opts|
11
11
  opts.banner = "Usage: #{File.basename($PROGRAM_NAME)} [options]"
12
12
 
13
- opts.on('--count COUNT', Integer, 'Number of counts for the benchmark') do |count|
14
- options[:count] = count
13
+ opts.on('--count COUNT', Integer, 'Number of trials for the benchmark') do |count|
14
+ options[:count] = Integer(count)
15
15
  end
16
16
 
17
- opts.on('--frames FRAMES', Integer, 'Number of frames to process') do |frames|
18
- options[:frames] = frames
17
+ opts.on('--frames FRAMES', Integer, 'Number of measured frames per trial') do |frames|
18
+ options[:frames] = Integer(frames)
19
+ end
20
+
21
+ opts.on('--warmup-frames FRAMES', Integer, 'Number of unmeasured warmup frames per trial (default: 0)') do |frames|
22
+ options[:warmup_frames] = Integer(frames)
23
+ end
24
+
25
+ opts.on('--render', 'Draw each frame without audio output or realtime synchronization') do
26
+ options[:render] = true
27
+ end
28
+
29
+ opts.on('--json', 'Output benchmark results and runtime information as JSON') do
30
+ options[:json] = true
19
31
  end
20
32
 
21
33
  opts.on('--rom-path ROM_PATH', String, 'Path to the ROM file') do |rom_path|
@@ -27,17 +39,24 @@ begin
27
39
  exit
28
40
  end
29
41
  end.parse!
30
- rescue OptionParser::ParseError => e
31
- puts e.message
32
- puts "Usage: #{File.basename($PROGRAM_NAME)} --count NUM --frames NUM --rom-path FILEPATH"
42
+ rescue OptionParser::ParseError, ArgumentError => e
43
+ warn e.message
44
+ warn "Usage: #{File.basename($PROGRAM_NAME)} --count NUM --frames NUM --rom-path FILEPATH"
33
45
  exit 1
34
46
  end
35
47
 
36
- puts "Ruby: #{RUBY_VERSION}"
37
- if defined?(RubyVM::YJIT)
38
- puts "YJIT: #{RubyVM::YJIT.enabled?}"
39
- else
40
- puts 'YJIT is not available in this environment.'
48
+ unless options[:json]
49
+ puts "Ruby: #{RUBY_VERSION}"
50
+ if defined?(RubyVM::YJIT)
51
+ puts "YJIT: #{RubyVM::YJIT.enabled?}"
52
+ else
53
+ puts 'YJIT is not available in this environment.'
54
+ end
41
55
  end
42
56
 
43
- Rubyboy::Bench.new.run(**options)
57
+ begin
58
+ Rubyboy::Bench.new.run(**options)
59
+ rescue ArgumentError, Rubyboy::Bench::WindowClosed => e
60
+ warn e.message
61
+ exit 1
62
+ end
data/lib/bench.rb CHANGED
@@ -1,27 +1,82 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
3
4
  require_relative 'rubyboy/emulator_headless'
4
5
 
5
6
  module Rubyboy
6
7
  class Bench
7
- def run(count: 3, frames: 1500, rom_path: 'lib/roms/tobu.gb')
8
- time_sum = 0
8
+ class WindowClosed < StandardError; end
9
+
10
+ def run(count: 3, frames: 1500, rom_path: 'lib/roms/tobu.gb', warmup_frames: 0, render: false, json: false)
11
+ validate_integer(:count, count, 1)
12
+ validate_integer(:frames, frames, 1)
13
+ validate_integer(:warmup_frames, warmup_frames, 0)
14
+ require_relative 'rubyboy/lcd' if render
9
15
 
16
+ trials = []
17
+ time_sum = 0
10
18
  count.times do |i|
11
- emulator = Rubyboy::EmulatorHeadless.new(rom_path)
12
- frame_count = 0
19
+ time = run_trial(rom_path, frames, warmup_frames, render)
20
+ elapsed_seconds = time / 1_000_000_000.0
21
+ trials << { trial: i + 1, frames:, elapsed_seconds:, fps: frames / elapsed_seconds }
22
+ puts "#{i + 1}: #{elapsed_seconds} sec" unless json
23
+ time_sum += time
24
+ end
25
+
26
+ result = {
27
+ schema_version: 1,
28
+ rom_path: File.expand_path(rom_path),
29
+ count:,
30
+ frames:,
31
+ warmup_frames:,
32
+ render:,
33
+ runtime:,
34
+ conditions: { apu: true, audio_output: false, realtime_sync: false, vsync: render ? false : nil, input: false, save: false },
35
+ trials:,
36
+ total_frames: frames * count,
37
+ elapsed_seconds: time_sum / 1_000_000_000.0,
38
+ fps: frames * count * 1_000_000_000.0 / time_sum
39
+ }
40
+ puts(json ? JSON.generate(result) : "FPS: #{result[:fps]}")
41
+ result
42
+ end
43
+
44
+ private
45
+
46
+ def validate_integer(name, value, minimum)
47
+ return if value.is_a?(Integer) && value >= minimum
48
+
49
+ requirement = minimum == 0 ? 'a nonnegative integer' : 'a positive integer'
50
+ raise ArgumentError, "#{name} must be #{requirement}"
51
+ end
52
+
53
+ def run_trial(rom_path, frames, warmup_frames, render)
54
+ emulator = Rubyboy::EmulatorHeadless.new(rom_path)
55
+ lcd = Rubyboy::Lcd.new(vsync: false) if render
56
+ begin
57
+ run_frames(emulator, lcd, warmup_frames)
13
58
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
14
- while frame_count < frames
15
- emulator.step
16
- frame_count += 1
17
- end
18
- time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) - start_time
19
- puts "#{i + 1}: #{time / 1_000_000_000.0} sec"
59
+ run_frames(emulator, lcd, frames)
60
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) - start_time
61
+ ensure
62
+ lcd&.close_window
63
+ end
64
+ end
20
65
 
21
- time_sum += time
66
+ def run_frames(emulator, lcd, frames)
67
+ frame_count = 0
68
+ while frame_count < frames
69
+ raise WindowClosed, 'Benchmark stopped because the window was closed.' if lcd&.window_should_close?
70
+
71
+ emulator.step
72
+ lcd&.draw(emulator.framebuffer)
73
+ frame_count += 1
22
74
  end
75
+ end
23
76
 
24
- puts "FPS: #{frames * count * 1_000_000_000.0 / time_sum}"
77
+ def runtime
78
+ yjit_enabled = defined?(RubyVM::YJIT) ? RubyVM::YJIT.enabled? : false
79
+ { engine: RUBY_ENGINE, version: RUBY_VERSION, description: RUBY_DESCRIPTION, platform: RUBY_PLATFORM, yjit_enabled: }
25
80
  end
26
81
  end
27
82
  end
data/lib/rubyboy/apu.rb CHANGED
@@ -7,13 +7,24 @@ require_relative 'apu_channels/channel4'
7
7
 
8
8
  module Rubyboy
9
9
  class Apu
10
+ # APU samples are pushed to SDL at 48 kHz (must match Rubyboy::Audio::SAMPLE_RATE).
11
+ # The CPU runs at 4_194_304 Hz, so the real divisor is 87.3813...
12
+ # The previous implementation used `>= 87` which effectively ran the sampler
13
+ # at ~48_210 Hz — a 0.4% drift that slowly over-filled the SDL audio queue
14
+ # and triggered the clear-on-overflow in Audio#queue, producing audible gaps
15
+ # in BGM. We fix this with a fixed-point accumulator that targets the exact
16
+ # ratio: every step adds `cycles * SAMPLE_RATE` and we emit one sample
17
+ # whenever the accumulator reaches CPU_CLOCK_HZ.
18
+ CPU_CLOCK_HZ = 4_194_304
19
+ SAMPLE_RATE = 48_000
20
+
10
21
  attr_reader :samples
11
22
 
12
23
  def initialize
13
24
  @nr50 = 0
14
25
  @nr51 = 0
15
26
  @cycles = 0
16
- @sampling_cycles = 0
27
+ @sampling_accumulator = 0
17
28
  @fs = 0
18
29
  @samples = Array.new(1024, 0.0)
19
30
  @sample_idx = 0
@@ -25,7 +36,7 @@ module Rubyboy
25
36
 
26
37
  def step(cycles)
27
38
  @cycles += cycles
28
- @sampling_cycles += cycles
39
+ @sampling_accumulator += cycles * SAMPLE_RATE
29
40
 
30
41
  @channel1.step(cycles)
31
42
  @channel2.step(cycles)
@@ -43,8 +54,8 @@ module Rubyboy
43
54
  @fs = (@fs + 1) % 8
44
55
  end
45
56
 
46
- if @sampling_cycles >= 87
47
- @sampling_cycles -= 87
57
+ if @sampling_accumulator >= CPU_CLOCK_HZ
58
+ @sampling_accumulator -= CPU_CLOCK_HZ
48
59
 
49
60
  left_sample = (
50
61
  @nr51[7] * @channel4.dac_output +
data/lib/rubyboy/audio.rb CHANGED
@@ -22,14 +22,22 @@ module Rubyboy
22
22
  end
23
23
 
24
24
  def queue(buffer)
25
- # sleep(0.001) while SDL.GetQueuedAudioSize(@device) > 8192
26
-
27
- SDL.ClearQueuedAudio(@device) if SDL.GetQueuedAudioSize(@device) > 8192
25
+ # Block until the SDL audio queue has drained enough to accept another
26
+ # buffer. The previous implementation called SDL.ClearQueuedAudio here
27
+ # instead, which dropped the entire queue whenever the emulator ran ahead
28
+ # of real time — that produced audible pops/gaps in BGM (most noticeable
29
+ # in Pokemon-style tracks). Blocking here costs a tiny bit of frame
30
+ # budget but keeps audio continuous.
31
+ sleep(0.001) while SDL.GetQueuedAudioSize(@device) > 8192
28
32
 
29
33
  buf_ptr = FFI::MemoryPointer.new(:float, buffer.size)
30
34
  buf_ptr.put_array_of_float(0, buffer)
31
35
 
32
36
  SDL.QueueAudio(@device, buf_ptr, buffer.size * buf_ptr.type_size)
33
37
  end
38
+
39
+ def clear_queue
40
+ SDL.ClearQueuedAudio(@device)
41
+ end
34
42
  end
35
43
  end
@@ -3,6 +3,9 @@
3
3
  module Rubyboy
4
4
  module Cartridge
5
5
  class Mbc1
6
+ ROM_BANK_SIZE = 0x4000
7
+ RAM_BANK_SIZE = 0x2000
8
+
6
9
  def initialize(rom, ram)
7
10
  @rom = rom
8
11
  @ram = ram
@@ -15,19 +18,13 @@ module Rubyboy
15
18
  def read_byte(addr)
16
19
  case (addr >> 12)
17
20
  when 0x0, 0x1, 0x2, 0x3
18
- @rom.data[addr]
21
+ @rom.data[rom_offset(addr)] || 0xff
19
22
  when 0x4, 0x5, 0x6, 0x7
20
- @rom.data[addr + ((@rom_bank - 1) << 14)]
23
+ @rom.data[rom_offset(addr)] || 0xff
21
24
  when 0xa, 0xb
22
- if @ram_enable
23
- if @ram_banking_mode
24
- @ram.eram[addr - 0xa000 + (@ram_bank << 11)]
25
- else
26
- @ram.eram[addr - 0xa000]
27
- end
28
- else
29
- 0xff
30
- end
25
+ return 0xff unless @ram_enable
26
+
27
+ @ram.eram[ram_offset(addr)] || 0xff
31
28
  end
32
29
  end
33
30
 
@@ -43,15 +40,30 @@ module Rubyboy
43
40
  when 0x6, 0x7
44
41
  @ram_banking_mode = value & 0x01 == 0x01
45
42
  when 0xa, 0xb
46
- if @ram_enable
47
- if @ram_banking_mode
48
- @ram.eram[addr - 0xa000 + (@ram_bank << 11)] = value
49
- else
50
- @ram.eram[addr - 0xa000] = value
51
- end
52
- end
43
+ return unless @ram_enable
44
+
45
+ offset = ram_offset(addr)
46
+ @ram.eram[offset] = value if offset < @ram.eram.size
53
47
  end
54
48
  end
49
+
50
+ private
51
+
52
+ def rom_offset(addr)
53
+ bank =
54
+ if addr < ROM_BANK_SIZE
55
+ @ram_banking_mode ? (@ram_bank << 5) : 0
56
+ else
57
+ (@ram_bank << 5) | @rom_bank
58
+ end
59
+
60
+ ((bank * ROM_BANK_SIZE) + (addr & (ROM_BANK_SIZE - 1))) % @rom.data.size
61
+ end
62
+
63
+ def ram_offset(addr)
64
+ bank = @ram_banking_mode ? @ram_bank : 0
65
+ (addr - 0xa000) + (bank * RAM_BANK_SIZE)
66
+ end
55
67
  end
56
68
  end
57
69
  end
@@ -4,49 +4,74 @@ module Rubyboy
4
4
  class Emulator
5
5
  CPU_CLOCK_HZ = 4_194_304
6
6
  CYCLE_NANOSEC = 1_000_000_000 / CPU_CLOCK_HZ
7
+ SAVE_STATE_KEYS = [
8
+ SDL::SDL_SCANCODE_1,
9
+ SDL::SDL_SCANCODE_2,
10
+ SDL::SDL_SCANCODE_3,
11
+ SDL::SDL_SCANCODE_4,
12
+ SDL::SDL_SCANCODE_5,
13
+ SDL::SDL_SCANCODE_6,
14
+ SDL::SDL_SCANCODE_7,
15
+ SDL::SDL_SCANCODE_8,
16
+ SDL::SDL_SCANCODE_9,
17
+ SDL::SDL_SCANCODE_0
18
+ ].freeze
7
19
 
8
- def initialize(rom_path)
20
+ def initialize(rom_path, audio: true)
21
+ @rom_path = rom_path
9
22
  rom_data = File.open(rom_path, 'r') { _1.read.bytes }
10
- rom = Rom.new(rom_data)
11
- ram = Ram.new
12
- mbc = Cartridge::Factory.create(rom, ram)
13
- interrupt = Interrupt.new
14
- @ppu = Ppu.new(interrupt)
15
- @timer = Timer.new(interrupt)
16
- @joypad = Joypad.new(interrupt)
23
+ @rom = Rom.new(rom_data)
24
+ @ram = Ram.new(@rom)
25
+ @mbc = Cartridge::Factory.create(@rom, @ram)
26
+ @interrupt = Interrupt.new
27
+ @ppu = Ppu.new(@interrupt)
28
+ @timer = Timer.new(@interrupt)
29
+ @joypad = Joypad.new(@interrupt)
17
30
  @apu = Apu.new
18
- @bus = Bus.new(@ppu, rom, ram, mbc, @timer, interrupt, @joypad, @apu)
19
- @cpu = Cpu.new(@bus, interrupt)
31
+ @bus = Bus.new(@ppu, @rom, @ram, @mbc, @timer, @interrupt, @joypad, @apu)
32
+ @cpu = Cpu.new(@bus, @interrupt)
20
33
  @lcd = Lcd.new
21
- @audio = Audio.new
34
+ @audio = audio ? Audio.new : nil
35
+ @prev_save_state_keys = Array.new(SAVE_STATE_KEYS.size, 0)
36
+ @save_file = @rom.battery? ? SaveFile.new(default_save_path(rom_path)) : nil
37
+ load_save_file
22
38
  end
23
39
 
24
- def start
25
- SDL.InitSubSystem(SDL::INIT_KEYBOARD)
40
+ def start(frames: nil, realtime: true)
41
+ raise ArgumentError, 'frames must be a positive integer' unless frames.nil? || (frames.is_a?(Integer) && frames > 0)
26
42
 
27
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
28
- elapsed_machine_time = 0
29
- catch(:exit_loop) do
30
- loop do
31
- elapsed_real_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) - start_time
32
- while elapsed_real_time > elapsed_machine_time
33
- cycles = @cpu.exec
34
- @timer.step(cycles)
35
- @audio.queue(@apu.samples) if @apu.step(cycles)
36
- if @ppu.step(cycles)
37
- @lcd.draw(@ppu.buffer)
38
- key_input_check
39
- throw :exit_loop if @lcd.window_should_close?
40
- end
43
+ begin
44
+ SDL.InitSubSystem(SDL::INIT_KEYBOARD)
41
45
 
42
- elapsed_machine_time += cycles * CYCLE_NANOSEC
46
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) if realtime
47
+ elapsed_machine_time = 0
48
+ frame_count = 0
49
+ catch(:exit_loop) do
50
+ loop do
51
+ elapsed_real_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) - start_time if realtime
52
+ while !realtime || elapsed_real_time > elapsed_machine_time
53
+ cycles = @cpu.exec
54
+ @timer.step(cycles)
55
+ @audio.queue(@apu.samples) if @apu.step(cycles) && @audio
56
+ if @ppu.step(cycles)
57
+ @lcd.draw(@ppu.buffer)
58
+ key_input_check
59
+ frame_count += 1
60
+ throw :exit_loop if frames && frame_count >= frames
61
+ throw :exit_loop if @lcd.window_should_close?
62
+ end
63
+
64
+ elapsed_machine_time += cycles * CYCLE_NANOSEC if realtime
65
+ end
43
66
  end
44
67
  end
68
+ rescue StandardError => e
69
+ puts e.to_s[0, 100]
70
+ raise e
71
+ ensure
72
+ @lcd.close_window
73
+ save_save_file
45
74
  end
46
- @lcd.close_window
47
- rescue StandardError => e
48
- puts e.to_s[0, 100]
49
- raise e
50
75
  end
51
76
 
52
77
  def bench(frames)
@@ -65,17 +90,106 @@ module Rubyboy
65
90
  Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) - start_time
66
91
  end
67
92
 
93
+ def save_state(slot: nil, path: nil)
94
+ state_path = path || slot_path(slot)
95
+ return false unless StateFile.write(state_path, rom: @rom) { hardware_state }
96
+
97
+ puts "Saved state to #{state_path}"
98
+ true
99
+ end
100
+
101
+ def load_state(slot: nil, path: nil)
102
+ state_path = path || slot_path(slot)
103
+ return false unless StateFile.read(state_path, rom: @rom) { |state| restore_hardware_state(state) }
104
+
105
+ @audio&.clear_queue
106
+ puts "Loaded state from #{state_path}"
107
+ true
108
+ end
109
+
68
110
  private
69
111
 
112
+ def default_save_path(rom_path)
113
+ dir = File.dirname(rom_path)
114
+ base = File.basename(rom_path, '.*')
115
+ File.join(dir, "#{base}.sav")
116
+ end
117
+
118
+ def slot_path(slot)
119
+ raise ArgumentError, 'slot must be between 0 and 9' unless (0..9).cover?(slot)
120
+
121
+ dir = File.dirname(@rom_path)
122
+ base = File.basename(@rom_path, '.*')
123
+ File.join(dir, "#{base}.state#{slot}")
124
+ end
125
+
126
+ def hardware_state
127
+ {
128
+ schema: 'game_boy_console_and_cartridge',
129
+ console: {
130
+ cpu: @cpu.hardware_state,
131
+ ram: @ram.hardware_state,
132
+ ppu: @ppu.hardware_state,
133
+ apu: @apu.hardware_state,
134
+ timer: @timer.hardware_state,
135
+ interrupt: @interrupt.hardware_state,
136
+ joypad: @joypad.hardware_state
137
+ },
138
+ cartridge: @mbc.hardware_state
139
+ }
140
+ end
141
+
142
+ def restore_hardware_state(state)
143
+ console = state.fetch(:console)
144
+ @cpu.restore_hardware_state(console.fetch(:cpu))
145
+ @ram.restore_hardware_state(console.fetch(:ram))
146
+ @ppu.restore_hardware_state(console.fetch(:ppu))
147
+ @apu.restore_hardware_state(console.fetch(:apu))
148
+ @timer.restore_hardware_state(console.fetch(:timer))
149
+ @interrupt.restore_hardware_state(console.fetch(:interrupt))
150
+ @joypad.restore_hardware_state(console.fetch(:joypad))
151
+ @mbc.restore_hardware_state(state.fetch(:cartridge))
152
+ end
153
+
154
+ def load_save_file
155
+ return unless @save_file
156
+
157
+ bytes = @save_file.read(@ram.eram.size)
158
+ return unless bytes
159
+
160
+ @ram.eram.replace(bytes)
161
+ puts "Loaded save from #{@save_file.path}"
162
+ end
163
+
164
+ def save_save_file
165
+ return unless @save_file
166
+
167
+ puts "Saved to #{@save_file.path}" if @save_file.write(@ram.eram)
168
+ end
169
+
70
170
  def key_input_check
71
171
  SDL.PumpEvents
72
172
  keyboard = SDL.GetKeyboardState(nil)
73
173
  keyboard_state = keyboard.read_array_of_uint8(229)
74
174
 
175
+ check_state_save_keys(keyboard_state)
75
176
  direction = (keyboard_state[SDL::SDL_SCANCODE_D]) | (keyboard_state[SDL::SDL_SCANCODE_A] << 1) | (keyboard_state[SDL::SDL_SCANCODE_W] << 2) | (keyboard_state[SDL::SDL_SCANCODE_S] << 3)
76
177
  action = (keyboard_state[SDL::SDL_SCANCODE_K]) | (keyboard_state[SDL::SDL_SCANCODE_J] << 1) | (keyboard_state[SDL::SDL_SCANCODE_U] << 2) | (keyboard_state[SDL::SDL_SCANCODE_I] << 3)
77
178
  @joypad.direction_button(15 - direction)
78
179
  @joypad.action_button(15 - action)
79
180
  end
181
+
182
+ def check_state_save_keys(keyboard_state)
183
+ shift = keyboard_state[SDL::SDL_SCANCODE_LSHIFT] > 0
184
+ SAVE_STATE_KEYS.each_with_index do |key, index|
185
+ pressed = keyboard_state[key]
186
+ next unless pressed > 0 && @prev_save_state_keys[index] == 0
187
+
188
+ slot = index == 9 ? 0 : index + 1
189
+ shift ? load_state(slot:) : save_state(slot:)
190
+ ensure
191
+ @prev_save_state_keys[index] = pressed
192
+ end
193
+ end
80
194
  end
81
195
  end
@@ -16,7 +16,7 @@ module Rubyboy
16
16
  def initialize(rom_path)
17
17
  rom_data = File.open(rom_path, 'r') { _1.read.bytes }
18
18
  rom = Rom.new(rom_data)
19
- ram = Ram.new
19
+ ram = Ram.new(rom)
20
20
  mbc = Cartridge::Factory.create(rom, ram)
21
21
  interrupt = Interrupt.new
22
22
  @ppu = Ppu.new(interrupt)
@@ -27,6 +27,10 @@ module Rubyboy
27
27
  @cpu = Cpu.new(bus, interrupt)
28
28
  end
29
29
 
30
+ def framebuffer
31
+ @ppu.buffer
32
+ end
33
+
30
34
  def step
31
35
  loop do
32
36
  cycles = @cpu.exec