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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +39 -27
- data/exe/rubyboy +16 -5
- data/exe/rubyboy-bench +15 -7
- data/lib/bench.rb +33 -7
- data/lib/executor.rb +19 -10
- data/lib/rubyboy/apu.rb +15 -4
- data/lib/rubyboy/apu_channels/channel1.rb +4 -4
- data/lib/rubyboy/apu_channels/channel2.rb +4 -4
- data/lib/rubyboy/apu_channels/channel3.rb +2 -9
- data/lib/rubyboy/apu_channels/channel4.rb +0 -7
- data/lib/rubyboy/audio.rb +17 -4
- data/lib/rubyboy/cartridge/mbc1.rb +30 -18
- data/lib/rubyboy/emulator.rb +145 -47
- data/lib/rubyboy/emulator_headless.rb +6 -2
- data/lib/rubyboy/emulator_wasm.rb +31 -8
- data/lib/rubyboy/hardware_state.rb +450 -0
- data/lib/rubyboy/lcd.rb +2 -1
- data/lib/rubyboy/ram.rb +2 -2
- data/lib/rubyboy/rom.rb +26 -0
- data/lib/rubyboy/save_file.rb +36 -0
- data/lib/rubyboy/sdl.rb +12 -0
- data/lib/rubyboy/state_file.rb +49 -0
- data/lib/rubyboy/version.rb +1 -1
- data/lib/rubyboy.rb +3 -0
- metadata +9 -48
- data/.rspec +0 -3
- data/.rubocop.yml +0 -54
- data/CODE_OF_CONDUCT.md +0 -84
- data/Rakefile +0 -12
- data/docs/favicon.png +0 -0
- data/docs/index.html +0 -75
- data/docs/index.js +0 -113
- data/docs/logo-light-23.svg +0 -1
- data/docs/ogp.png +0 -0
- data/docs/styles.css +0 -245
- data/docs/worker.js +0 -115
- data/exe/rubyboy-wasm +0 -26
- data/lib/opcodes.json +0 -11657
- data/lib/roms/bgbtest.gb +0 -0
- data/lib/roms/cpu_instrs/cpu_instrs.gb +0 -0
- data/lib/roms/cpu_instrs/individual/01-special.gb +0 -0
- data/lib/roms/cpu_instrs/individual/02-interrupts.gb +0 -0
- data/lib/roms/cpu_instrs/individual/03-op sp,hl.gb +0 -0
- data/lib/roms/cpu_instrs/individual/04-op r,imm.gb +0 -0
- data/lib/roms/cpu_instrs/individual/05-op rp.gb +0 -0
- data/lib/roms/cpu_instrs/individual/06-ld r,r.gb +0 -0
- data/lib/roms/cpu_instrs/individual/07-jr,jp,call,ret,rst.gb +0 -0
- data/lib/roms/cpu_instrs/individual/08-misc instrs.gb +0 -0
- data/lib/roms/cpu_instrs/individual/09-op r,r.gb +0 -0
- data/lib/roms/cpu_instrs/individual/10-bit ops.gb +0 -0
- data/lib/roms/cpu_instrs/individual/11-op a,(hl).gb +0 -0
- data/lib/roms/cpu_instrs/readme.txt +0 -119
- data/lib/roms/dmg-acid2.gb +0 -0
- data/lib/roms/hello-world.gb +0 -0
- data/lib/roms/instr_timing/instr_timing.gb +0 -0
- data/lib/roms/instr_timing/readme.txt +0 -139
- data/lib/rubyboy/raylib/audio.rb +0 -32
- data/lib/rubyboy/raylib/lcd.rb +0 -40
- data/lib/rubyboy/raylib/raylib_loader.rb +0 -36
- data/lib/stackprof.rb +0 -14
- data/resource/logo/logo.png +0 -0
- data/resource/logo/logo.svg +0 -1
- data/resource/logo/rubyboy.png +0 -0
- data/resource/screenshots/pokemon.png +0 -0
- data/resource/screenshots/puyopuyo.png +0 -0
- data/sig/rubyboy.rbs +0 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 715ec944430fe6b0f108f1a51dc90722059e754939ca8e23726fb8c9ca226407
|
|
4
|
+
data.tar.gz: 12b42b656684bc9b4255559aaec3600c6fa4d6f32132fc144666de097f7512a0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0d5c32193c766397003bb8d2bfc1072dc8dd0dbca967dbd87c2e041b4559f6f43aa761452fa68acc3036b08e2a5606f96df15733284fa7f1c880e07c9061de57
|
|
7
|
+
data.tar.gz: 1f514d729da361a7ede186479b8ad844c7d57d04fec572d1d2c02fdcd7f86e82d7d8ebb603596369eb3800e5ed389aae79fa3b3a9bb094cfe137c3c3e553a44d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
+
## [1.7.0] - 2026-10-10
|
|
2
|
+
|
|
3
|
+
- Require Ruby 4.0 or higher
|
|
4
|
+
- Keep audio output with --unlimited by dropping audio that cannot be played in time
|
|
5
|
+
- Remove the --no-audio player option
|
|
6
|
+
- Remove rubyboy-bench --render and --json, and the SDL 2.0.18 requirement
|
|
7
|
+
- Print a checksum of the final frame from rubyboy-bench to check that a runtime emulated correctly
|
|
8
|
+
- Run rubyboy-bench without loading SDL
|
|
9
|
+
- Run the bundled Tobu Tobu Girl ROM when rubyboy or rubyboy-bench gets no ROM path, from any directory
|
|
10
|
+
- Read ROM files in binary mode, which fixes loading them on Windows
|
|
11
|
+
- Make the APU duty tables shareable between Ractors
|
|
12
|
+
- Ship only the runtime code, the default ROM and documents in the gem, without the rubyboy-wasm command or test ROMs
|
|
13
|
+
|
|
14
|
+
## [1.6.0] - 2026-10-04
|
|
15
|
+
|
|
16
|
+
- Add battery-backed saves and hardware-oriented save states
|
|
17
|
+
- Fix MBC1 ROM bank mapping, external RAM size and RAM banking
|
|
18
|
+
- Fix audio drop-outs and APU sample-rate drift
|
|
19
|
+
- Support Spinel compilation and fix SDL event allocation
|
|
20
|
+
- Add --frames, --unlimited and --no-audio player options
|
|
21
|
+
- Add benchmark warmup, SDL rendering and JSON output
|
|
22
|
+
- Disable VSync for rendered benchmarks and require SDL 2.0.18 or higher
|
|
23
|
+
- Update JSON and development dependencies for recent Ruby versions
|
|
24
|
+
|
|
1
25
|
## [1.5.1] - 2025-02-16
|
|
2
26
|
|
|
3
27
|
- Optimize Cartridge::Mbc1 and Bus
|
data/README.md
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
A Game Boy emulator written in Ruby
|
|
8
8
|
|
|
9
|
-
**
|
|
9
|
+
**Try it in your browser:**
|
|
10
|
+
[ruby.wasm version](https://sacckey.github.io/rubyboy/) (CRuby compiled to WebAssembly) /
|
|
11
|
+
[Spinel version](https://sacckey.github.io/rubyboy/spinel/) (the same Ruby code compiled to C by Spinel, then to WebAssembly)
|
|
10
12
|
|
|
11
13
|
## Screenshots
|
|
12
14
|
<div align="center">
|
|
@@ -14,47 +16,57 @@ A Game Boy emulator written in Ruby
|
|
|
14
16
|
<img src="/resource/screenshots/puyopuyo.png" width="400px"/>
|
|
15
17
|
</div>
|
|
16
18
|
|
|
17
|
-
## Requirements
|
|
18
|
-
[SDL2](https://wiki.libsdl.org/SDL2/Installation)
|
|
19
|
-
|
|
20
19
|
## Installation
|
|
21
20
|
|
|
22
|
-
|
|
21
|
+
Rubyboy requires Ruby 4.0 or higher and [SDL2](https://wiki.libsdl.org/SDL2/Installation).
|
|
22
|
+
|
|
23
|
+
$ gem install rubyboy
|
|
23
24
|
|
|
24
25
|
> [!TIP]
|
|
25
|
-
>
|
|
26
|
+
> Run with YJIT (`RUBYOPT=--yjit`) for better performance.
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
26
29
|
|
|
27
|
-
|
|
30
|
+
$ RUBYOPT=--yjit rubyboy [rom_path]
|
|
28
31
|
|
|
29
|
-
|
|
32
|
+
Without a ROM path, it runs the bundled [Tobu Tobu Girl](https://tangramgames.dk/tobutobugirl/).
|
|
30
33
|
|
|
31
|
-
|
|
34
|
+
### Controls
|
|
32
35
|
|
|
33
|
-
|
|
36
|
+
| Key | Button |
|
|
37
|
+
| :---: | :---: |
|
|
38
|
+
| `W` `A` `S` `D` | ↑ ← ↓ → |
|
|
39
|
+
| `K` | A |
|
|
40
|
+
| `J` | B |
|
|
41
|
+
| `U` | Select |
|
|
42
|
+
| `I` | Start |
|
|
43
|
+
| `1`–`0` | Save state to slot 1–9, 0 |
|
|
44
|
+
| Left `Shift` + `1`–`0` | Load state from that slot |
|
|
34
45
|
|
|
35
|
-
|
|
46
|
+
Battery-backed games save to `<rom>.sav`, and save states go to `<rom>.state<N>`,
|
|
47
|
+
next to the ROM file.
|
|
48
|
+
|
|
49
|
+
### Options
|
|
36
50
|
|
|
37
|
-
|
|
51
|
+
| Option | Effect |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `--frames N` | Exit after N frames |
|
|
54
|
+
| `--unlimited` | Run as fast as possible; audio that cannot be played in time is dropped |
|
|
38
55
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
| `I` | Start |
|
|
56
|
+
## Benchmark
|
|
57
|
+
|
|
58
|
+
On an Apple M4, Rubyboy runs at about 110 FPS on CRuby, 290 FPS with YJIT and
|
|
59
|
+
800 FPS when compiled with Spinel. See [BENCHMARK.md](BENCHMARK.md) for the method and details.
|
|
60
|
+
|
|
61
|
+
## Other ways to run
|
|
62
|
+
|
|
63
|
+
- [WebAssembly builds](wasm/README.md): the browser versions (ruby.wasm and Spinel)
|
|
64
|
+
- [Spinel](spinel/README.md): compile Rubyboy to a native executable
|
|
49
65
|
|
|
50
66
|
## Contributing
|
|
51
67
|
|
|
52
|
-
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](
|
|
68
|
+
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).
|
|
53
69
|
|
|
54
70
|
## License
|
|
55
71
|
|
|
56
72
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
57
|
-
|
|
58
|
-
## Code of Conduct
|
|
59
|
-
|
|
60
|
-
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).
|
data/exe/rubyboy
CHANGED
|
@@ -4,22 +4,33 @@
|
|
|
4
4
|
require 'rubyboy'
|
|
5
5
|
require 'optparse'
|
|
6
6
|
|
|
7
|
+
frames = nil
|
|
8
|
+
realtime = true
|
|
9
|
+
|
|
7
10
|
begin
|
|
8
11
|
OptionParser.new do |opts|
|
|
9
|
-
opts.banner = "Usage: #{File.basename($PROGRAM_NAME)} ROM_PATH"
|
|
12
|
+
opts.banner = "Usage: #{File.basename($PROGRAM_NAME)} [ROM_PATH]"
|
|
13
|
+
|
|
14
|
+
opts.on('--frames N', 'Exit after N frames (positive integer)') do |value|
|
|
15
|
+
value = Integer(value)
|
|
16
|
+
raise OptionParser::InvalidArgument, 'frames must be a positive integer' unless value > 0
|
|
17
|
+
|
|
18
|
+
frames = value
|
|
19
|
+
end
|
|
20
|
+
opts.on('--unlimited', 'Disable real-time synchronization; excess audio is dropped') { realtime = false }
|
|
10
21
|
|
|
11
22
|
opts.on('-h', '--help', 'Displays this help') do
|
|
12
23
|
puts opts
|
|
13
24
|
exit
|
|
14
25
|
end
|
|
15
26
|
end.parse!
|
|
16
|
-
rescue OptionParser::ParseError => e
|
|
27
|
+
rescue OptionParser::ParseError, ArgumentError => e
|
|
17
28
|
puts e.message
|
|
18
|
-
puts "Usage: #{File.basename($PROGRAM_NAME)} ROM_PATH"
|
|
29
|
+
puts "Usage: #{File.basename($PROGRAM_NAME)} [ROM_PATH]"
|
|
19
30
|
exit 1
|
|
20
31
|
end
|
|
21
32
|
|
|
22
|
-
rom_path = ARGV.shift ||
|
|
33
|
+
rom_path = ARGV.shift || Rubyboy::Rom::DEFAULT_PATH
|
|
23
34
|
|
|
24
35
|
puts "Ruby: #{RUBY_VERSION}"
|
|
25
36
|
if defined?(RubyVM::YJIT)
|
|
@@ -28,4 +39,4 @@ else
|
|
|
28
39
|
puts 'YJIT is not available in this environment.'
|
|
29
40
|
end
|
|
30
41
|
|
|
31
|
-
Rubyboy::Emulator.new(rom_path).start
|
|
42
|
+
Rubyboy::Emulator.new(rom_path).start(frames:, realtime:)
|
data/exe/rubyboy-bench
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
require 'rubyboy'
|
|
5
4
|
require 'bench'
|
|
6
5
|
require 'optparse'
|
|
7
6
|
|
|
@@ -10,14 +9,18 @@ begin
|
|
|
10
9
|
OptionParser.new do |opts|
|
|
11
10
|
opts.banner = "Usage: #{File.basename($PROGRAM_NAME)} [options]"
|
|
12
11
|
|
|
13
|
-
opts.on('--count COUNT', Integer, 'Number of
|
|
12
|
+
opts.on('--count COUNT', Integer, 'Number of trials for the benchmark') do |count|
|
|
14
13
|
options[:count] = count
|
|
15
14
|
end
|
|
16
15
|
|
|
17
|
-
opts.on('--frames FRAMES', Integer, 'Number of frames
|
|
16
|
+
opts.on('--frames FRAMES', Integer, 'Number of measured frames per trial') do |frames|
|
|
18
17
|
options[:frames] = frames
|
|
19
18
|
end
|
|
20
19
|
|
|
20
|
+
opts.on('--warmup-frames FRAMES', Integer, 'Number of unmeasured warmup frames per trial (default: 0)') do |frames|
|
|
21
|
+
options[:warmup_frames] = frames
|
|
22
|
+
end
|
|
23
|
+
|
|
21
24
|
opts.on('--rom-path ROM_PATH', String, 'Path to the ROM file') do |rom_path|
|
|
22
25
|
options[:rom_path] = rom_path
|
|
23
26
|
end
|
|
@@ -27,9 +30,9 @@ begin
|
|
|
27
30
|
exit
|
|
28
31
|
end
|
|
29
32
|
end.parse!
|
|
30
|
-
rescue OptionParser::ParseError => e
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
rescue OptionParser::ParseError, ArgumentError => e
|
|
34
|
+
warn e.message
|
|
35
|
+
warn "Usage: #{File.basename($PROGRAM_NAME)} --count NUM --frames NUM --rom-path FILEPATH"
|
|
33
36
|
exit 1
|
|
34
37
|
end
|
|
35
38
|
|
|
@@ -40,4 +43,9 @@ else
|
|
|
40
43
|
puts 'YJIT is not available in this environment.'
|
|
41
44
|
end
|
|
42
45
|
|
|
43
|
-
|
|
46
|
+
begin
|
|
47
|
+
Rubyboy::Bench.new.run(**options)
|
|
48
|
+
rescue ArgumentError => e
|
|
49
|
+
warn e.message
|
|
50
|
+
exit 1
|
|
51
|
+
end
|
data/lib/bench.rb
CHANGED
|
@@ -4,24 +4,50 @@ require_relative 'rubyboy/emulator_headless'
|
|
|
4
4
|
|
|
5
5
|
module Rubyboy
|
|
6
6
|
class Bench
|
|
7
|
-
def run(count: 3, frames: 1500, rom_path:
|
|
8
|
-
|
|
7
|
+
def run(count: 3, frames: 1500, rom_path: Rom::DEFAULT_PATH, warmup_frames: 0)
|
|
8
|
+
validate_integer(:count, count, 1)
|
|
9
|
+
validate_integer(:frames, frames, 1)
|
|
10
|
+
validate_integer(:warmup_frames, warmup_frames, 0)
|
|
9
11
|
|
|
12
|
+
time_sum = 0
|
|
13
|
+
frame_checksum = 0
|
|
10
14
|
count.times do |i|
|
|
11
15
|
emulator = Rubyboy::EmulatorHeadless.new(rom_path)
|
|
12
|
-
|
|
16
|
+
run_frames(emulator, warmup_frames)
|
|
13
17
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
|
|
14
|
-
|
|
15
|
-
emulator.step
|
|
16
|
-
frame_count += 1
|
|
17
|
-
end
|
|
18
|
+
run_frames(emulator, frames)
|
|
18
19
|
time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) - start_time
|
|
19
20
|
puts "#{i + 1}: #{time / 1_000_000_000.0} sec"
|
|
20
21
|
|
|
21
22
|
time_sum += time
|
|
23
|
+
frame_checksum = checksum(emulator.framebuffer)
|
|
22
24
|
end
|
|
23
25
|
|
|
24
26
|
puts "FPS: #{frames * count * 1_000_000_000.0 / time_sum}"
|
|
27
|
+
# Every trial ends on the same frame. A different value on another runtime means it emulated incorrectly.
|
|
28
|
+
puts "Checksum: #{frame_checksum}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def validate_integer(name, value, minimum)
|
|
34
|
+
return if value.is_a?(Integer) && value >= minimum
|
|
35
|
+
|
|
36
|
+
requirement = minimum == 0 ? 'a nonnegative integer' : 'a positive integer'
|
|
37
|
+
raise ArgumentError, "#{name} must be #{requirement}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Integer arithmetic only, so CRuby and ahead-of-time compilers such as Spinel produce the same value.
|
|
41
|
+
def checksum(framebuffer)
|
|
42
|
+
framebuffer.reduce(0) { |hash, pixel| ((hash * 31) + pixel) & 0xffffffff }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def run_frames(emulator, frames)
|
|
46
|
+
frame_count = 0
|
|
47
|
+
while frame_count < frames
|
|
48
|
+
emulator.step
|
|
49
|
+
frame_count += 1
|
|
50
|
+
end
|
|
25
51
|
end
|
|
26
52
|
end
|
|
27
53
|
end
|
data/lib/executor.rb
CHANGED
|
@@ -1,36 +1,45 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'js'
|
|
4
|
-
require 'json'
|
|
5
|
-
|
|
6
3
|
require_relative 'rubyboy/emulator_wasm'
|
|
7
4
|
|
|
8
5
|
class Executor
|
|
9
6
|
ALLOWED_ROMS = ['tobu.gb', 'bgbtest.gb'].freeze
|
|
10
7
|
|
|
11
|
-
def initialize
|
|
12
|
-
rom_data = File.
|
|
8
|
+
def initialize(rom_path = '/lib/roms/tobu.gb')
|
|
9
|
+
rom_data = File.binread(rom_path).bytes
|
|
13
10
|
@emulator = Rubyboy::EmulatorWasm.new(rom_data)
|
|
14
11
|
end
|
|
15
12
|
|
|
16
13
|
def exec(direction_key = 0b1111, action_key = 0b1111)
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
@emulator.step(direction_key, action_key)
|
|
15
|
+
write_framebuffer
|
|
16
|
+
write_audio
|
|
19
17
|
end
|
|
20
18
|
|
|
21
19
|
def read_rom_from_virtual_fs
|
|
22
20
|
rom_path = '/rom.data'
|
|
23
21
|
raise "ROM file not found in virtual filesystem at #{rom_path}" unless File.exist?(rom_path)
|
|
24
22
|
|
|
25
|
-
rom_data = File.
|
|
23
|
+
rom_data = File.binread(rom_path).bytes
|
|
26
24
|
@emulator = Rubyboy::EmulatorWasm.new(rom_data)
|
|
27
25
|
end
|
|
28
26
|
|
|
29
27
|
def read_pre_installed_rom(rom_name)
|
|
30
28
|
raise 'ROM not found in allowed ROMs' unless ALLOWED_ROMS.include?(rom_name)
|
|
31
29
|
|
|
32
|
-
rom_path = File.join('lib/roms', rom_name)
|
|
33
|
-
rom_data = File.
|
|
30
|
+
rom_path = File.join('/lib/roms', rom_name)
|
|
31
|
+
rom_data = File.binread(rom_path).bytes
|
|
34
32
|
@emulator = Rubyboy::EmulatorWasm.new(rom_data)
|
|
35
33
|
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def write_framebuffer
|
|
38
|
+
File.binwrite('/video.data', @emulator.framebuffer.pack('V*'))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def write_audio
|
|
42
|
+
samples = @emulator.audio_samples
|
|
43
|
+
File.binwrite('/audio.data', samples.pack('e*')) unless samples.empty?
|
|
44
|
+
end
|
|
36
45
|
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 +
|
|
@@ -6,10 +6,10 @@ module Rubyboy
|
|
|
6
6
|
attr_accessor :enabled, :wave_duty_position
|
|
7
7
|
|
|
8
8
|
WAVE_DUTY = [
|
|
9
|
-
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0], # 12.5%
|
|
10
|
-
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0], # 25%
|
|
11
|
-
[0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], # 50%
|
|
12
|
-
[0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0] # 75%
|
|
9
|
+
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0].freeze, # 12.5%
|
|
10
|
+
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0].freeze, # 25%
|
|
11
|
+
[0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0].freeze, # 50%
|
|
12
|
+
[0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0].freeze # 75%
|
|
13
13
|
].freeze
|
|
14
14
|
|
|
15
15
|
def initialize
|
|
@@ -6,10 +6,10 @@ module Rubyboy
|
|
|
6
6
|
attr_accessor :enabled, :wave_duty_position
|
|
7
7
|
|
|
8
8
|
WAVE_DUTY = [
|
|
9
|
-
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0], # 12.5%
|
|
10
|
-
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0], # 25%
|
|
11
|
-
[0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], # 50%
|
|
12
|
-
[0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0] # 75%
|
|
9
|
+
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0].freeze, # 12.5%
|
|
10
|
+
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0].freeze, # 25%
|
|
11
|
+
[0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0].freeze, # 50%
|
|
12
|
+
[0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0].freeze # 75%
|
|
13
13
|
].freeze
|
|
14
14
|
|
|
15
15
|
def initialize
|
|
@@ -5,13 +5,6 @@ module Rubyboy
|
|
|
5
5
|
class Channel3
|
|
6
6
|
attr_accessor :enabled, :wave_duty_position, :wave_ram
|
|
7
7
|
|
|
8
|
-
WAVE_DUTY = [
|
|
9
|
-
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0], # 12.5%
|
|
10
|
-
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0], # 25%
|
|
11
|
-
[0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], # 50%
|
|
12
|
-
[0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0] # 75%
|
|
13
|
-
].freeze
|
|
14
|
-
|
|
15
8
|
def initialize
|
|
16
9
|
@cycles = 0
|
|
17
10
|
@frequency = 0
|
|
@@ -76,9 +69,9 @@ module Rubyboy
|
|
|
76
69
|
def dac_output
|
|
77
70
|
return 0.0 unless @dac_enabled && @enabled
|
|
78
71
|
|
|
79
|
-
ret = (
|
|
72
|
+
ret = (0xf & (
|
|
80
73
|
@wave_ram[@wave_duty_position >> 1] >> ((@wave_duty_position & 0x01) << 2)
|
|
81
|
-
))
|
|
74
|
+
)) >> @volume_shift
|
|
82
75
|
|
|
83
76
|
(ret / 7.5) - 1.0
|
|
84
77
|
end
|
|
@@ -5,13 +5,6 @@ module Rubyboy
|
|
|
5
5
|
class Channel4
|
|
6
6
|
attr_accessor :enabled, :wave_duty_position
|
|
7
7
|
|
|
8
|
-
WAVE_DUTY = [
|
|
9
|
-
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0], # 12.5%
|
|
10
|
-
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0], # 25%
|
|
11
|
-
[0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], # 50%
|
|
12
|
-
[0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0] # 75%
|
|
13
|
-
].freeze
|
|
14
|
-
|
|
15
8
|
def initialize
|
|
16
9
|
@cycles = 0
|
|
17
10
|
@frequency = 0
|
data/lib/rubyboy/audio.rb
CHANGED
|
@@ -21,15 +21,28 @@ module Rubyboy
|
|
|
21
21
|
SDL.PauseAudioDevice(@device, 0)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def queue(buffer)
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
def queue(buffer, wait: true)
|
|
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
|
+
# Unlimited playback drops this buffer instead, so audio never slows it down.
|
|
32
|
+
while SDL.GetQueuedAudioSize(@device) > 8192
|
|
33
|
+
return unless wait
|
|
34
|
+
|
|
35
|
+
sleep(0.001)
|
|
36
|
+
end
|
|
28
37
|
|
|
29
38
|
buf_ptr = FFI::MemoryPointer.new(:float, buffer.size)
|
|
30
39
|
buf_ptr.put_array_of_float(0, buffer)
|
|
31
40
|
|
|
32
41
|
SDL.QueueAudio(@device, buf_ptr, buffer.size * buf_ptr.type_size)
|
|
33
42
|
end
|
|
43
|
+
|
|
44
|
+
def clear_queue
|
|
45
|
+
SDL.ClearQueuedAudio(@device)
|
|
46
|
+
end
|
|
34
47
|
end
|
|
35
48
|
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
|