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 +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +57 -1
- data/exe/rubyboy +15 -2
- data/exe/rubyboy-bench +32 -13
- data/lib/bench.rb +67 -12
- data/lib/rubyboy/apu.rb +15 -4
- data/lib/rubyboy/audio.rb +11 -3
- data/lib/rubyboy/cartridge/mbc1.rb +30 -18
- data/lib/rubyboy/emulator.rb +146 -32
- data/lib/rubyboy/emulator_headless.rb +5 -1
- data/lib/rubyboy/emulator_wasm.rb +1 -1
- data/lib/rubyboy/hardware_state.rb +449 -0
- data/lib/rubyboy/lcd.rb +12 -3
- data/lib/rubyboy/ram.rb +2 -2
- data/lib/rubyboy/rom.rb +23 -0
- data/lib/rubyboy/save_file.rb +36 -0
- data/lib/rubyboy/sdl.rb +12 -0
- data/lib/rubyboy/state_file.rb +50 -0
- data/lib/rubyboy/version.rb +1 -1
- data/lib/rubyboy.rb +3 -0
- metadata +20 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '09e12943a26b3089da7e75f3a26a0994dd71c1a748b63c840c49c3772502b625'
|
|
4
|
+
data.tar.gz: c37fdfa6182bb28cbca6ffe5438670860e0e6c10e32b581509dbfca28e12f70d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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 @
|
|
47
|
-
@
|
|
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
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
23
|
+
@rom.data[rom_offset(addr)] || 0xff
|
|
21
24
|
when 0xa, 0xb
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
data/lib/rubyboy/emulator.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|