rubyboy 1.5.1 → 1.7.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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +39 -27
  4. data/exe/rubyboy +16 -5
  5. data/exe/rubyboy-bench +15 -7
  6. data/lib/bench.rb +33 -7
  7. data/lib/executor.rb +19 -10
  8. data/lib/rubyboy/apu.rb +15 -4
  9. data/lib/rubyboy/apu_channels/channel1.rb +4 -4
  10. data/lib/rubyboy/apu_channels/channel2.rb +4 -4
  11. data/lib/rubyboy/apu_channels/channel3.rb +2 -9
  12. data/lib/rubyboy/apu_channels/channel4.rb +0 -7
  13. data/lib/rubyboy/audio.rb +17 -4
  14. data/lib/rubyboy/cartridge/mbc1.rb +30 -18
  15. data/lib/rubyboy/emulator.rb +145 -47
  16. data/lib/rubyboy/emulator_headless.rb +6 -2
  17. data/lib/rubyboy/emulator_wasm.rb +31 -8
  18. data/lib/rubyboy/hardware_state.rb +450 -0
  19. data/lib/rubyboy/lcd.rb +2 -1
  20. data/lib/rubyboy/ram.rb +2 -2
  21. data/lib/rubyboy/rom.rb +26 -0
  22. data/lib/rubyboy/save_file.rb +36 -0
  23. data/lib/rubyboy/sdl.rb +12 -0
  24. data/lib/rubyboy/state_file.rb +49 -0
  25. data/lib/rubyboy/version.rb +1 -1
  26. data/lib/rubyboy.rb +3 -0
  27. metadata +9 -48
  28. data/.rspec +0 -3
  29. data/.rubocop.yml +0 -54
  30. data/CODE_OF_CONDUCT.md +0 -84
  31. data/Rakefile +0 -12
  32. data/docs/favicon.png +0 -0
  33. data/docs/index.html +0 -75
  34. data/docs/index.js +0 -113
  35. data/docs/logo-light-23.svg +0 -1
  36. data/docs/ogp.png +0 -0
  37. data/docs/styles.css +0 -245
  38. data/docs/worker.js +0 -115
  39. data/exe/rubyboy-wasm +0 -26
  40. data/lib/opcodes.json +0 -11657
  41. data/lib/roms/bgbtest.gb +0 -0
  42. data/lib/roms/cpu_instrs/cpu_instrs.gb +0 -0
  43. data/lib/roms/cpu_instrs/individual/01-special.gb +0 -0
  44. data/lib/roms/cpu_instrs/individual/02-interrupts.gb +0 -0
  45. data/lib/roms/cpu_instrs/individual/03-op sp,hl.gb +0 -0
  46. data/lib/roms/cpu_instrs/individual/04-op r,imm.gb +0 -0
  47. data/lib/roms/cpu_instrs/individual/05-op rp.gb +0 -0
  48. data/lib/roms/cpu_instrs/individual/06-ld r,r.gb +0 -0
  49. data/lib/roms/cpu_instrs/individual/07-jr,jp,call,ret,rst.gb +0 -0
  50. data/lib/roms/cpu_instrs/individual/08-misc instrs.gb +0 -0
  51. data/lib/roms/cpu_instrs/individual/09-op r,r.gb +0 -0
  52. data/lib/roms/cpu_instrs/individual/10-bit ops.gb +0 -0
  53. data/lib/roms/cpu_instrs/individual/11-op a,(hl).gb +0 -0
  54. data/lib/roms/cpu_instrs/readme.txt +0 -119
  55. data/lib/roms/dmg-acid2.gb +0 -0
  56. data/lib/roms/hello-world.gb +0 -0
  57. data/lib/roms/instr_timing/instr_timing.gb +0 -0
  58. data/lib/roms/instr_timing/readme.txt +0 -139
  59. data/lib/rubyboy/raylib/audio.rb +0 -32
  60. data/lib/rubyboy/raylib/lcd.rb +0 -40
  61. data/lib/rubyboy/raylib/raylib_loader.rb +0 -36
  62. data/lib/stackprof.rb +0 -14
  63. data/resource/logo/logo.png +0 -0
  64. data/resource/logo/logo.svg +0 -1
  65. data/resource/logo/rubyboy.png +0 -0
  66. data/resource/screenshots/pokemon.png +0 -0
  67. data/resource/screenshots/puyopuyo.png +0 -0
  68. data/sig/rubyboy.rbs +0 -4
@@ -4,78 +4,176 @@ 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
20
  def initialize(rom_path)
9
- 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)
21
+ @rom_path = rom_path
22
+ rom_data = File.binread(rom_path).bytes
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
34
  @audio = Audio.new
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)
26
-
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
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)
42
+
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, wait: realtime) if @apu.step(cycles)
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
- def bench(frames)
53
- @lcd.close_window
54
- frame_count = 0
55
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
56
- while frame_count < frames
57
- cycles = @cpu.exec
58
- @timer.step(cycles)
59
- if @ppu.step(cycles)
60
- key_input_check
61
- frame_count += 1
62
- end
63
- end
77
+ def save_state(slot: nil, path: nil)
78
+ state_path = path || slot_path(slot)
79
+ return false unless StateFile.write(state_path, rom: @rom) { hardware_state }
64
80
 
65
- Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) - start_time
81
+ puts "Saved state to #{state_path}"
82
+ true
83
+ end
84
+
85
+ def load_state(slot: nil, path: nil)
86
+ state_path = path || slot_path(slot)
87
+ return false unless StateFile.read(state_path, rom: @rom) { |state| restore_hardware_state(state) }
88
+
89
+ @audio.clear_queue
90
+ puts "Loaded state from #{state_path}"
91
+ true
66
92
  end
67
93
 
68
94
  private
69
95
 
96
+ def default_save_path(rom_path)
97
+ dir = File.dirname(rom_path)
98
+ base = File.basename(rom_path, '.*')
99
+ File.join(dir, "#{base}.sav")
100
+ end
101
+
102
+ def slot_path(slot)
103
+ raise ArgumentError, 'slot must be between 0 and 9' unless (0..9).cover?(slot)
104
+
105
+ dir = File.dirname(@rom_path)
106
+ base = File.basename(@rom_path, '.*')
107
+ File.join(dir, "#{base}.state#{slot}")
108
+ end
109
+
110
+ def hardware_state
111
+ {
112
+ schema: 'game_boy_console_and_cartridge',
113
+ console: {
114
+ cpu: @cpu.hardware_state,
115
+ ram: @ram.hardware_state,
116
+ ppu: @ppu.hardware_state,
117
+ apu: @apu.hardware_state,
118
+ timer: @timer.hardware_state,
119
+ interrupt: @interrupt.hardware_state,
120
+ joypad: @joypad.hardware_state
121
+ },
122
+ cartridge: @mbc.hardware_state
123
+ }
124
+ end
125
+
126
+ def restore_hardware_state(state)
127
+ console = state.fetch(:console)
128
+ @cpu.restore_hardware_state(console.fetch(:cpu))
129
+ @ram.restore_hardware_state(console.fetch(:ram))
130
+ @ppu.restore_hardware_state(console.fetch(:ppu))
131
+ @apu.restore_hardware_state(console.fetch(:apu))
132
+ @timer.restore_hardware_state(console.fetch(:timer))
133
+ @interrupt.restore_hardware_state(console.fetch(:interrupt))
134
+ @joypad.restore_hardware_state(console.fetch(:joypad))
135
+ @mbc.restore_hardware_state(state.fetch(:cartridge))
136
+ end
137
+
138
+ def load_save_file
139
+ return unless @save_file
140
+
141
+ bytes = @save_file.read(@ram.eram.size)
142
+ return unless bytes
143
+
144
+ @ram.eram.replace(bytes)
145
+ puts "Loaded save from #{@save_file.path}"
146
+ end
147
+
148
+ def save_save_file
149
+ return unless @save_file
150
+
151
+ puts "Saved to #{@save_file.path}" if @save_file.write(@ram.eram)
152
+ end
153
+
70
154
  def key_input_check
71
155
  SDL.PumpEvents
72
156
  keyboard = SDL.GetKeyboardState(nil)
73
157
  keyboard_state = keyboard.read_array_of_uint8(229)
74
158
 
75
- 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
- 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)
159
+ check_state_save_keys(keyboard_state)
160
+ 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)
161
+ 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
162
  @joypad.direction_button(15 - direction)
78
163
  @joypad.action_button(15 - action)
79
164
  end
165
+
166
+ def check_state_save_keys(keyboard_state)
167
+ shift = keyboard_state[SDL::SDL_SCANCODE_LSHIFT] > 0
168
+ SAVE_STATE_KEYS.each_with_index do |key, index|
169
+ pressed = keyboard_state[key]
170
+ next unless pressed > 0 && @prev_save_state_keys[index] == 0
171
+
172
+ slot = index == 9 ? 0 : index + 1
173
+ shift ? load_state(slot:) : save_state(slot:)
174
+ ensure
175
+ @prev_save_state_keys[index] = pressed
176
+ end
177
+ end
80
178
  end
81
179
  end
@@ -14,9 +14,9 @@ require_relative 'cartridge/factory'
14
14
  module Rubyboy
15
15
  class EmulatorHeadless
16
16
  def initialize(rom_path)
17
- rom_data = File.open(rom_path, 'r') { _1.read.bytes }
17
+ rom_data = File.binread(rom_path).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
@@ -3,7 +3,6 @@
3
3
  require_relative 'apu'
4
4
  require_relative 'bus'
5
5
  require_relative 'cpu'
6
- require_relative 'emulator'
7
6
  require_relative 'ppu'
8
7
  require_relative 'rom'
9
8
  require_relative 'ram'
@@ -14,12 +13,13 @@ require_relative 'cartridge/factory'
14
13
 
15
14
  module Rubyboy
16
15
  class EmulatorWasm
17
- CPU_CLOCK_HZ = 4_194_304
18
- CYCLE_NANOSEC = 1_000_000_000 / CPU_CLOCK_HZ
16
+ FRAME_CYCLES = 70_224
17
+
18
+ attr_reader :audio_samples
19
19
 
20
20
  def initialize(rom_data)
21
21
  rom = Rom.new(rom_data)
22
- ram = Ram.new
22
+ ram = Ram.new(rom)
23
23
  mbc = Cartridge::Factory.create(rom, ram)
24
24
  interrupt = Interrupt.new
25
25
  @ppu = Ppu.new(interrupt)
@@ -28,16 +28,39 @@ module Rubyboy
28
28
  @apu = Apu.new
29
29
  @bus = Bus.new(@ppu, rom, ram, mbc, @timer, interrupt, @joypad, @apu)
30
30
  @cpu = Cpu.new(@bus, interrupt)
31
+ @audio_samples = []
31
32
  end
32
33
 
33
34
  def step(direction_key, action_key)
34
- @joypad.direction_button(direction_key)
35
- @joypad.action_button(action_key)
36
- loop do
35
+ set_input(direction_key, action_key)
36
+ @audio_samples.clear
37
+ elapsed_cycles = 0
38
+ # LCD-off games must return to the browser event loop as well.
39
+ while elapsed_cycles < FRAME_CYCLES
37
40
  cycles = @cpu.exec
41
+ elapsed_cycles += cycles
38
42
  @timer.step(cycles)
39
- return @ppu.buffer if @ppu.step(cycles)
43
+ collect_audio(cycles)
44
+ break if @ppu.step(cycles)
40
45
  end
46
+ framebuffer
47
+ end
48
+
49
+ def framebuffer
50
+ @ppu.buffer
51
+ end
52
+
53
+ private
54
+
55
+ def set_input(direction_key, action_key)
56
+ @joypad.direction_button(direction_key & 15)
57
+ @joypad.action_button(action_key & 15)
58
+ end
59
+
60
+ def collect_audio(cycles)
61
+ # Complete 512-frame stereo blocks are copied before the APU reuses its
62
+ # sample buffer. Its incomplete block remains intact between calls.
63
+ @audio_samples.concat(@apu.samples) if @apu.step(cycles)
41
64
  end
42
65
  end
43
66
  end