badline 0.1.0 → 0.2.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 +167 -0
- data/CLAUDE.md +217 -0
- data/CODE_OF_CONDUCT.md +92 -0
- data/CONTRIBUTING.md +51 -0
- data/README.md +224 -31
- data/doc/pinned-behaviour.md +1117 -0
- data/exe/badline +22 -2
- data/exe/badline-sid +34 -0
- data/lib/badline/address_bus.rb +88 -23
- data/lib/badline/audio/aiff.rb +36 -0
- data/lib/badline/audio/bare_player.rb +129 -0
- data/lib/badline/audio/cli.rb +133 -0
- data/lib/badline/audio/console.rb +75 -0
- data/lib/badline/audio/jukebox.rb +82 -0
- data/lib/badline/audio/machine_player.rb +57 -0
- data/lib/badline/audio/options.rb +137 -0
- data/lib/badline/audio/pcm_writer.rb +57 -0
- data/lib/badline/audio/playback.rb +108 -0
- data/lib/badline/audio/renderer.rb +85 -0
- data/lib/badline/audio/sdl_sink.rb +102 -0
- data/lib/badline/audio/wav.rb +23 -0
- data/lib/badline/audio.rb +14 -0
- data/lib/badline/cartridge/action_replay.rb +111 -0
- data/lib/badline/cartridge/atomic_power.rb +24 -0
- data/lib/badline/cartridge/bank.rb +81 -0
- data/lib/badline/cartridge/comal80.rb +48 -0
- data/lib/badline/cartridge/dinamic.rb +34 -0
- data/lib/badline/cartridge/easy_flash.rb +122 -0
- data/lib/badline/cartridge/epyx_fastload.rb +71 -0
- data/lib/badline/cartridge/final_cartridge3.rb +77 -0
- data/lib/badline/cartridge/flash.rb +265 -0
- data/lib/badline/cartridge/freezer.rb +31 -0
- data/lib/badline/cartridge/fun_play.rb +38 -0
- data/lib/badline/cartridge/g_mod2.rb +62 -0
- data/lib/badline/cartridge/game_system.rb +40 -0
- data/lib/badline/cartridge/kcs_power.rb +72 -0
- data/lib/badline/cartridge/mach5.rb +34 -0
- data/lib/badline/cartridge/magic_desk.rb +6 -3
- data/lib/badline/cartridge/ocean.rb +11 -12
- data/lib/badline/cartridge/pagefox.rb +56 -0
- data/lib/badline/cartridge/retro_replay.rb +276 -0
- data/lib/badline/cartridge/rex_utility.rb +33 -0
- data/lib/badline/cartridge/rgcd.rb +48 -0
- data/lib/badline/cartridge/simons_basic.rb +40 -0
- data/lib/badline/cartridge/standard.rb +3 -19
- data/lib/badline/cartridge/super_games.rb +36 -0
- data/lib/badline/cartridge/westermann.rb +34 -0
- data/lib/badline/cartridge/zaxxon.rb +50 -0
- data/lib/badline/cartridge.rb +131 -15
- data/lib/badline/cia/interrupt_register.rb +88 -0
- data/lib/badline/cia/serial.rb +219 -0
- data/lib/badline/cia/timer.rb +43 -31
- data/lib/badline/cia.rb +109 -74
- data/lib/badline/color_memory.rb +14 -5
- data/lib/badline/computer.rb +68 -8
- data/lib/badline/control_ports.rb +54 -9
- data/lib/badline/cpu/addressing.rb +131 -0
- data/lib/badline/cpu/microcode.rb +171 -0
- data/lib/badline/cpu/operations.rb +88 -0
- data/lib/badline/cpu/stack_operations.rb +82 -0
- data/lib/badline/cpu.rb +108 -167
- data/lib/badline/cycleable.rb +3 -0
- data/lib/badline/datasette.rb +81 -0
- data/lib/badline/debug_register.rb +28 -0
- data/lib/badline/gui/application.rb +79 -18
- data/lib/badline/gui/gamepads.rb +97 -0
- data/lib/badline/gui/joy_map.rb +15 -7
- data/lib/badline/gui.rb +1 -0
- data/lib/badline/input/mouse1351.rb +41 -0
- data/lib/badline/input/paddles.rb +36 -0
- data/lib/badline/input.rb +4 -0
- data/lib/badline/instruction_set/arithmetic.rb +16 -21
- data/lib/badline/instruction_set/bitwise.rb +17 -22
- data/lib/badline/instruction_set/branch.rb +20 -19
- data/lib/badline/instruction_set/flag.rb +7 -7
- data/lib/badline/instruction_set/illegal.rb +14 -22
- data/lib/badline/instruction_set/inc_dec.rb +8 -10
- data/lib/badline/instruction_set/stack.rb +5 -57
- data/lib/badline/instruction_set/transfer.rb +9 -9
- data/lib/badline/instruction_set.rb +9 -31
- data/lib/badline/interrupts.rb +104 -0
- data/lib/badline/joystick.rb +9 -9
- data/lib/badline/kernal_trap/channel.rb +33 -0
- data/lib/badline/kernal_trap/drive.rb +239 -0
- data/lib/badline/kernal_trap/file.rb +4 -24
- data/lib/badline/kernal_trap/load.rb +2 -2
- data/lib/badline/kernal_trap/routine.rb +34 -0
- data/lib/badline/kernal_trap/save.rb +1 -1
- data/lib/badline/kernal_trap/serial.rb +139 -0
- data/lib/badline/kernal_trap.rb +4 -0
- data/lib/badline/keyboard.rb +40 -16
- data/lib/badline/media.rb +48 -10
- data/lib/badline/roms/eapi/LICENSE.md +15 -0
- data/lib/badline/roms/eapi/README +41 -0
- data/lib/badline/roms/eapi/eapi-am29f040-14 +0 -0
- data/lib/badline/sid/decimator.rb +46 -0
- data/lib/badline/sid/envelope.rb +232 -0
- data/lib/badline/sid/filter.rb +213 -0
- data/lib/badline/sid/voice.rb +61 -0
- data/lib/badline/sid/waveform/combined.rb +126 -0
- data/lib/badline/sid/waveform/fast_forward.rb +105 -0
- data/lib/badline/sid/waveform/noise_writeback.rb +82 -0
- data/lib/badline/sid/waveform.rb +289 -0
- data/lib/badline/sid.rb +274 -7
- data/lib/badline/status.rb +32 -27
- data/lib/badline/storage/crt_file.rb +18 -2
- data/lib/badline/storage/disk_image.rb +52 -5
- data/lib/badline/storage/host_directory.rb +18 -4
- data/lib/badline/storage/sid_file.rb +274 -0
- data/lib/badline/storage/song_lengths.rb +65 -0
- data/lib/badline/storage/t64.rb +67 -0
- data/lib/badline/storage/tap.rb +68 -0
- data/lib/badline/storage.rb +19 -0
- data/lib/badline/time_of_day.rb +105 -52
- data/lib/badline/version.rb +1 -1
- data/lib/badline/vic/bank.rb +14 -6
- data/lib/badline/vic/border_mask.rb +76 -0
- data/lib/badline/vic/collisions.rb +104 -0
- data/lib/badline/vic/color_patches.rb +33 -0
- data/lib/badline/vic/display_state.rb +92 -29
- data/lib/badline/vic/graphics_mode.rb +78 -53
- data/lib/badline/vic/graphics_shifter.rb +140 -0
- data/lib/badline/vic/register_log.rb +63 -0
- data/lib/badline/vic/registers.rb +3 -0
- data/lib/badline/vic/sequencer.rb +163 -137
- data/lib/badline/vic/sequencer_output.rb +116 -0
- data/lib/badline/vic/sprite/internal_bus.rb +49 -0
- data/lib/badline/vic/sprite/shifter.rb +129 -0
- data/lib/badline/vic/sprite.rb +275 -65
- data/lib/badline/vic/sprites.rb +220 -58
- data/lib/badline/vic.rb +373 -49
- data/lib/badline.rb +5 -0
- data/vendor/.gitkeep +0 -0
- metadata +103 -4
data/exe/badline
CHANGED
|
@@ -13,7 +13,8 @@ parser = OptionParser.new do |opts|
|
|
|
13
13
|
Usage: badline [options] [media]
|
|
14
14
|
|
|
15
15
|
Media can be a .prg/.p00 program, a .d64/.d71/.d81 disk image,
|
|
16
|
-
a .
|
|
16
|
+
a .t64 tape archive, a .tap tape, a .crt cartridge, a .sid tune, or
|
|
17
|
+
a directory to mount as device 8.
|
|
17
18
|
|
|
18
19
|
Options:
|
|
19
20
|
BANNER
|
|
@@ -22,6 +23,15 @@ parser = OptionParser.new do |opts|
|
|
|
22
23
|
options[:autostart] = false
|
|
23
24
|
end
|
|
24
25
|
|
|
26
|
+
opts.on("--song N", Integer, "Subtune of a .sid to play (default: the tune's own)") do |n|
|
|
27
|
+
options[:song] = n
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
opts.on("--sid MODEL", { "6581" => :mos6581, "8580" => :mos8580 },
|
|
31
|
+
"SID to fit: 6581 or 8580 (default: a .sid tune's own, else 6581)") do |model|
|
|
32
|
+
options[:sid_model] = model
|
|
33
|
+
end
|
|
34
|
+
|
|
25
35
|
opts.on("--disable-jit", "Run without enabling YJIT") do
|
|
26
36
|
options[:jit] = false
|
|
27
37
|
end
|
|
@@ -52,4 +62,14 @@ RubyVM::YJIT.enable if options[:jit] && jit_available
|
|
|
52
62
|
require "badline"
|
|
53
63
|
require "badline/gui"
|
|
54
64
|
|
|
55
|
-
|
|
65
|
+
begin
|
|
66
|
+
application = Badline::GUI::Application.new(media_path:, autostart: options[:autostart],
|
|
67
|
+
song: options[:song], sid_model: options[:sid_model])
|
|
68
|
+
rescue Badline::Storage::SIDFile::FormatError, Badline::Storage::T64::FormatError,
|
|
69
|
+
Badline::Storage::TAP::FormatError, Badline::Storage::CRTFile::FormatError,
|
|
70
|
+
Badline::Cartridge::UnsupportedTypeError => e
|
|
71
|
+
warn "badline: #{media_path}: #{e.message}"
|
|
72
|
+
exit 1
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
application.run
|
data/exe/badline-sid
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
lib = File.expand_path("../lib", __dir__)
|
|
5
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
6
|
+
|
|
7
|
+
require "badline/audio/options"
|
|
8
|
+
|
|
9
|
+
begin
|
|
10
|
+
options = Badline::Audio::Options.parse(ARGV)
|
|
11
|
+
rescue Badline::Audio::Options::Error => e
|
|
12
|
+
warn "badline-sid: #{e.message}"
|
|
13
|
+
warn "Try 'badline-sid --help' for more information."
|
|
14
|
+
exit 1
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
if options.help?
|
|
18
|
+
puts options.help
|
|
19
|
+
exit
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
jit_available = defined?(RubyVM::YJIT) && !RubyVM::YJIT.enabled?
|
|
23
|
+
RubyVM::YJIT.enable if options.jit? && jit_available
|
|
24
|
+
|
|
25
|
+
require "badline"
|
|
26
|
+
|
|
27
|
+
begin
|
|
28
|
+
Badline::Audio::CLI.new(options).run
|
|
29
|
+
rescue Badline::Audio::CLI::Error => e
|
|
30
|
+
warn "badline-sid: #{e.message}"
|
|
31
|
+
exit 1
|
|
32
|
+
rescue Interrupt
|
|
33
|
+
exit 130
|
|
34
|
+
end
|
data/lib/badline/address_bus.rb
CHANGED
|
@@ -29,43 +29,55 @@ module Badline
|
|
|
29
29
|
class AddressBus
|
|
30
30
|
include Addressable
|
|
31
31
|
|
|
32
|
-
#
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
# I/O 1 and 2, and the Ultimax holes, with nothing on the bus. A read
|
|
33
|
+
# picks up the byte the VIC fetched in the preceding phi1 half-cycle,
|
|
34
|
+
# and a write goes nowhere.
|
|
35
|
+
class OpenBus
|
|
36
|
+
def initialize(vic)
|
|
37
|
+
@vic = vic
|
|
38
|
+
end
|
|
35
39
|
|
|
36
|
-
def peek(_addr) =
|
|
40
|
+
def peek(_addr) = @vic.phi1_data
|
|
37
41
|
def poke(_addr, _value); end
|
|
38
42
|
end
|
|
39
43
|
|
|
40
44
|
PORT_PULLUPS = 0b0001_0111
|
|
41
45
|
PORT_FLOATING = 0b1100_1000
|
|
46
|
+
TAPE_SENSE = 0b0001_0000
|
|
42
47
|
|
|
43
48
|
attr_reader :io_port, :ram, :basic_rom, :character_rom, :kernal_rom,
|
|
44
|
-
:vic, :sid, :color_ram, :cia1, :cia2, :keyboard, :joystick2,
|
|
45
|
-
:cartridge, :ultimax
|
|
49
|
+
:vic, :sid, :color_ram, :cia1, :cia2, :keyboard, :joystick1, :joystick2,
|
|
50
|
+
:control_ports, :cartridge, :ultimax, :phi1_ultimax, :datasette
|
|
46
51
|
|
|
47
|
-
def initialize
|
|
52
|
+
def initialize(sid_model: :mos6581)
|
|
48
53
|
@ram = Memory.new([0xff, 0x07], length: 2**16, start: 0)
|
|
49
54
|
@cartridge = nil
|
|
55
|
+
@debug_register = nil
|
|
50
56
|
|
|
51
57
|
@basic_rom = ROM.load("basic.rom", 0xa000)
|
|
52
58
|
@character_rom = ROM.load("character.rom", 0xd000)
|
|
53
59
|
@kernal_rom = ROM.load("kernal.rom", 0xe000)
|
|
54
60
|
|
|
55
61
|
@keyboard = Keyboard.new
|
|
62
|
+
@joystick1 = Joystick.new
|
|
56
63
|
@joystick2 = Joystick.new
|
|
64
|
+
@control_ports = ControlPorts.new(keyboard: @keyboard, joystick1: @joystick1, joystick2: @joystick2)
|
|
57
65
|
@vic = VIC.new(self)
|
|
58
|
-
@cia1 = CIA.new(
|
|
59
|
-
start: 0xdc00,
|
|
60
|
-
peripheral: ControlPorts.new(keyboard: @keyboard, joystick2: @joystick2)
|
|
61
|
-
)
|
|
66
|
+
@cia1 = CIA.new(start: 0xdc00, peripheral: @control_ports)
|
|
62
67
|
@cia2 = CIA.new(start: 0xdd00)
|
|
63
|
-
@
|
|
68
|
+
@control_ports.port_a_source = @cia1
|
|
69
|
+
@cia1.on_port_b4_change { |high| @vic.lightpen_level(high) }
|
|
70
|
+
@sid = SID.new(model: sid_model, pots: @control_ports)
|
|
71
|
+
|
|
72
|
+
@datasette = Datasette.new
|
|
73
|
+
@datasette.on_flag { @cia1.flag! }
|
|
74
|
+
@datasette.on_sense_change { @io_port.value = port_value }
|
|
64
75
|
|
|
65
|
-
@color_ram = ColorMemory.new(
|
|
76
|
+
@color_ram = ColorMemory.new(@vic)
|
|
77
|
+
@open_bus = OpenBus.new(@vic)
|
|
66
78
|
|
|
67
|
-
@port_ddr =
|
|
68
|
-
@port_out =
|
|
79
|
+
@port_ddr = 0x00
|
|
80
|
+
@port_out = 0x00
|
|
69
81
|
@port_floating = 0x00
|
|
70
82
|
@io_port = Status.new(%i[basic kernal io tape_out tape_switch tape_motor], value: port_value)
|
|
71
83
|
|
|
@@ -76,14 +88,29 @@ module Badline
|
|
|
76
88
|
|
|
77
89
|
def attach_cartridge(cartridge)
|
|
78
90
|
@cartridge = cartridge
|
|
91
|
+
cartridge.connect(ram: @ram, open_bus: @open_bus)
|
|
79
92
|
cartridge.on_change { update_overlays! }
|
|
80
93
|
update_overlays!
|
|
81
94
|
end
|
|
82
95
|
|
|
96
|
+
# The 6510's RES line clears the port's direction and output registers.
|
|
97
|
+
# The floating bits keep their charge.
|
|
98
|
+
def reset_port!
|
|
99
|
+
@port_ddr = 0x00
|
|
100
|
+
@port_out = 0x00
|
|
101
|
+
update_port!
|
|
102
|
+
end
|
|
103
|
+
|
|
83
104
|
def disable_overlays!
|
|
105
|
+
poke(0, 0x2f)
|
|
84
106
|
poke(1, 0)
|
|
85
107
|
end
|
|
86
108
|
|
|
109
|
+
def install_debug_register(&)
|
|
110
|
+
@debug_register = DebugRegister.new(@sid, &)
|
|
111
|
+
update_overlays!
|
|
112
|
+
end
|
|
113
|
+
|
|
87
114
|
def peek(addr)
|
|
88
115
|
return @port_ddr if addr.zero?
|
|
89
116
|
return @io_port.value if addr == 0x01
|
|
@@ -112,11 +139,14 @@ module Badline
|
|
|
112
139
|
driven = @port_ddr & PORT_FLOATING
|
|
113
140
|
@port_floating = (@port_floating & ~driven) | (@port_out & driven)
|
|
114
141
|
@io_port.value = port_value
|
|
142
|
+
# $01 bit 5 drives the motor through an inverter: low runs it.
|
|
143
|
+
@datasette.motor = !@io_port.tape_motor?
|
|
115
144
|
update_overlays!
|
|
116
145
|
end
|
|
117
146
|
|
|
118
147
|
def port_value
|
|
119
148
|
input = PORT_PULLUPS | (@port_floating & PORT_FLOATING)
|
|
149
|
+
input &= ~TAPE_SENSE if @datasette.sense_low?
|
|
120
150
|
(@port_out & @port_ddr) | (input & ~@port_ddr & 0xff)
|
|
121
151
|
end
|
|
122
152
|
|
|
@@ -125,6 +155,7 @@ module Badline
|
|
|
125
155
|
# of range checks.
|
|
126
156
|
def update_overlays!
|
|
127
157
|
@ultimax = @cartridge ? @cartridge.ultimax? : false
|
|
158
|
+
@phi1_ultimax = @cartridge ? @cartridge.phi1_ultimax? : false
|
|
128
159
|
@read_pages.fill(@ram)
|
|
129
160
|
@write_pages.fill(@ram)
|
|
130
161
|
|
|
@@ -133,6 +164,8 @@ module Badline
|
|
|
133
164
|
|
|
134
165
|
def map_banked_pages
|
|
135
166
|
map_rom_overlays
|
|
167
|
+
map_cartridge_ram
|
|
168
|
+
@write_pages.fill(@cartridge.romh_writes, 0xe0, 0x20) if @cartridge&.romh_writes
|
|
136
169
|
|
|
137
170
|
if io?
|
|
138
171
|
map_io_pages
|
|
@@ -151,28 +184,59 @@ module Badline
|
|
|
151
184
|
@read_pages.fill(kernal_rom, 0xe0, 0x20) if kernal?
|
|
152
185
|
end
|
|
153
186
|
|
|
187
|
+
# Cartridge RAM in the ROML or ROMH window decodes writes itself,
|
|
188
|
+
# whatever the $01 lines say.
|
|
189
|
+
def map_cartridge_ram
|
|
190
|
+
return unless @cartridge&.exrom&.zero?
|
|
191
|
+
|
|
192
|
+
map_cartridge_ram_bank(@cartridge.roml, 0x80)
|
|
193
|
+
map_cartridge_ram_bank(@cartridge.romh, 0xa0) if @cartridge.game.zero?
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def map_cartridge_ram_bank(bank, first_page)
|
|
197
|
+
@write_pages.fill(bank, first_page, 0x20) if bank.is_a?(Cartridge::RAMBank)
|
|
198
|
+
end
|
|
199
|
+
|
|
154
200
|
# Ultimax cartridges ignore the $01 lines: 4K of RAM, ROML/ROMH windows,
|
|
155
|
-
# I/O always visible and open address space everywhere else.
|
|
201
|
+
# I/O always visible and open address space everywhere else. The ROML
|
|
202
|
+
# and ROMH selects fire on writes as well, so cartridge RAM or flash in
|
|
203
|
+
# either window takes the writes there.
|
|
156
204
|
def map_ultimax_pages
|
|
157
|
-
@read_pages.fill(
|
|
158
|
-
@write_pages.fill(
|
|
205
|
+
@read_pages.fill(@open_bus, 0x10, 0xf0)
|
|
206
|
+
@write_pages.fill(@open_bus, 0x10, 0xf0)
|
|
159
207
|
@read_pages.fill(@cartridge.roml, 0x80, 0x20) if @cartridge.roml
|
|
208
|
+
map_ultimax_writes(@cartridge.roml, 0x80)
|
|
160
209
|
@read_pages.fill(@cartridge.romh, 0xe0, 0x20) if @cartridge.romh
|
|
210
|
+
map_ultimax_writes(@cartridge.romh, 0xe0)
|
|
211
|
+
map_ultimax_a000
|
|
161
212
|
map_io_pages
|
|
162
213
|
end
|
|
163
214
|
|
|
215
|
+
def map_ultimax_writes(bank, first_page)
|
|
216
|
+
@write_pages.fill(bank, first_page, 0x20) if bank.respond_to?(:poke)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def map_ultimax_a000
|
|
220
|
+
return unless (window = @cartridge.ultimax_a000)
|
|
221
|
+
|
|
222
|
+
@read_pages.fill(window, 0xa0, 0x20)
|
|
223
|
+
@write_pages.fill(window, 0xa0, 0x20)
|
|
224
|
+
end
|
|
225
|
+
|
|
164
226
|
def map_io_pages
|
|
165
227
|
{
|
|
166
228
|
vic => 0xd0..0xd3, sid => 0xd4..0xd7, color_ram => 0xd8..0xdb,
|
|
167
|
-
cia1 => 0xdc..0xdc, cia2 => 0xdd..0xdd
|
|
168
|
-
# 0xde/0xdf are open I/O unless a cartridge claims them
|
|
229
|
+
cia1 => 0xdc..0xdc, cia2 => 0xdd..0xdd, @open_bus => 0xde..0xdf
|
|
169
230
|
}.each do |chip, pages|
|
|
170
231
|
pages.each { |p| @read_pages[p] = @write_pages[p] = chip }
|
|
171
232
|
end
|
|
172
|
-
|
|
233
|
+
@read_pages[0xd7] = @write_pages[0xd7] = @debug_register if @debug_register
|
|
234
|
+
map_cartridge_io if @cartridge
|
|
235
|
+
end
|
|
173
236
|
|
|
174
|
-
|
|
237
|
+
def map_cartridge_io
|
|
175
238
|
@write_pages.fill(@cartridge, 0xde, 2)
|
|
239
|
+
@cartridge.readable_io_pages.each { |p| @read_pages[p] = @cartridge }
|
|
176
240
|
end
|
|
177
241
|
|
|
178
242
|
def basic?
|
|
@@ -191,8 +255,9 @@ module Badline
|
|
|
191
255
|
@cartridge&.romh && @cartridge.exrom.zero? && @cartridge.game.zero? && io_port.kernal?
|
|
192
256
|
end
|
|
193
257
|
|
|
258
|
+
# In 16K mode LORAM alone leaves $d000 as RAM, where it still maps I/O.
|
|
194
259
|
def character?
|
|
195
|
-
(io_port.
|
|
260
|
+
(io_port.kernal? || (io_port.basic? && game_high?)) && !io_port.io?
|
|
196
261
|
end
|
|
197
262
|
|
|
198
263
|
def io?
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Audio
|
|
5
|
+
# AIFF, the same shape as WAV but big-endian, and with the sample rate
|
|
6
|
+
# stored as an 80-bit IEEE 754 extended float.
|
|
7
|
+
class AIFF < PCMWriter
|
|
8
|
+
EXPONENT_BIAS = 16_383
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def pack_format = "s>*"
|
|
13
|
+
|
|
14
|
+
def header
|
|
15
|
+
["FORM", 0, "AIFF",
|
|
16
|
+
"COMM", 18, 1, 0, 16].pack("a4Na4a4NnNn") +
|
|
17
|
+
extended(rate) +
|
|
18
|
+
["SSND", 0, 0, 0].pack("a4NNN")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def patch
|
|
22
|
+
write_at(4, [46 + data_size].pack("N"))
|
|
23
|
+
write_at(22, [samples].pack("N"))
|
|
24
|
+
write_at(42, [8 + data_size].pack("N"))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Sign bit, 15-bit biased exponent, then a 64-bit mantissa that keeps
|
|
28
|
+
# its leading one rather than hiding it.
|
|
29
|
+
def extended(value)
|
|
30
|
+
exponent = EXPONENT_BIAS + value.bit_length - 1
|
|
31
|
+
mantissa = value << (64 - value.bit_length)
|
|
32
|
+
[exponent, mantissa >> 32, mantissa & 0xffff_ffff].pack("nNN")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Audio
|
|
5
|
+
# Runs a PSID tune on a bare rig: a CPU and RAM with the SID clocked
|
|
6
|
+
# alongside it. The VIC and the CIAs still decode their registers, so a
|
|
7
|
+
# tune writing to them lands somewhere sane, but neither is ever cycled,
|
|
8
|
+
# which is what makes this path about three times faster than the whole
|
|
9
|
+
# machine.
|
|
10
|
+
#
|
|
11
|
+
# Calls go through a six-byte stub whose JSR operand the dispatch trap
|
|
12
|
+
# rewrites:
|
|
13
|
+
#
|
|
14
|
+
# stub: jsr target
|
|
15
|
+
# idle: jmp idle
|
|
16
|
+
#
|
|
17
|
+
# The CPU spins in the idle loop between calls, so a dispatch always
|
|
18
|
+
# lands on an instruction boundary and a play routine that overruns its
|
|
19
|
+
# frame is never re-entered.
|
|
20
|
+
class BarePlayer
|
|
21
|
+
include IntegerHelper
|
|
22
|
+
|
|
23
|
+
# 312 raster lines of 63 cycles.
|
|
24
|
+
FRAME_CYCLES = 19_656
|
|
25
|
+
|
|
26
|
+
# A tune's init routine is free to unpack itself, but not forever.
|
|
27
|
+
INIT_LIMIT = 10_000_000
|
|
28
|
+
|
|
29
|
+
# The CIA 1 timer A latch the PAL KERNAL leaves, 60 underflows a
|
|
30
|
+
# second.
|
|
31
|
+
KERNAL_TIMER = 0x4025
|
|
32
|
+
|
|
33
|
+
attr_reader :sid
|
|
34
|
+
|
|
35
|
+
def initialize(tune, song: nil, sid_model: tune.sid_model)
|
|
36
|
+
@tune = tune
|
|
37
|
+
@song = (song || tune.start_song).clamp(1, tune.songs) - 1
|
|
38
|
+
@bus = AddressBus.new(sid_model:)
|
|
39
|
+
# The CPU port as the KERNAL leaves it, which a PSID tune expects.
|
|
40
|
+
@bus.poke(0x00, 0x2f)
|
|
41
|
+
@bus.poke(0x01, 0x37)
|
|
42
|
+
@bus.cia1.timer_a_latch = KERNAL_TIMER
|
|
43
|
+
@cpu = CPU.new(@bus)
|
|
44
|
+
@sid = @bus.sid
|
|
45
|
+
@idle = false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def start
|
|
49
|
+
@bus.ram.write(@tune.load_address, @tune.data)
|
|
50
|
+
@bus.ram.write(stub_address, stub)
|
|
51
|
+
@sid.synthesize!
|
|
52
|
+
@cpu.status.interrupt = true
|
|
53
|
+
@cpu.program_counter = idle_address
|
|
54
|
+
install_dispatch
|
|
55
|
+
call(@tune.init_address, @song)
|
|
56
|
+
settle
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Advances one call of play, or `budget` cycles if that is shorter,
|
|
60
|
+
# then yields whatever the SID recorded over it. Returns the cycles
|
|
61
|
+
# advanced.
|
|
62
|
+
def frame(budget, &)
|
|
63
|
+
call(@tune.play_address)
|
|
64
|
+
cycles = [budget, period].min
|
|
65
|
+
cycles.times { step }
|
|
66
|
+
@sid.drain_samples.each(&)
|
|
67
|
+
cycles
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# A PAL frame, or for a CIA-timed song one period of CIA 1 timer A,
|
|
73
|
+
# which init and play are both free to reprogram.
|
|
74
|
+
def period
|
|
75
|
+
return FRAME_CYCLES unless @tune.cia_timed?(@song + 1)
|
|
76
|
+
|
|
77
|
+
@bus.cia1.timer_a_latch + 1
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def stub_address = @stub_address ||= @tune.driver_address
|
|
81
|
+
|
|
82
|
+
def idle_address = stub_address + 3
|
|
83
|
+
|
|
84
|
+
def stub
|
|
85
|
+
[0x20, 0x00, 0x00, 0x4c,
|
|
86
|
+
low_byte(idle_address), high_byte(idle_address)]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def call(address, argument = 0)
|
|
90
|
+
@pending = address
|
|
91
|
+
@argument = argument
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A tune that never returns from init gets cut off and plays with
|
|
95
|
+
# whatever it managed to set up.
|
|
96
|
+
def settle
|
|
97
|
+
limit = @cpu.cycles + INIT_LIMIT
|
|
98
|
+
step until @idle || @cpu.cycles > limit
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def step
|
|
102
|
+
@sid.cycle!
|
|
103
|
+
@cpu.cycle!
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def install_dispatch
|
|
107
|
+
@cpu.install_trap(idle_address) do
|
|
108
|
+
@idle = true
|
|
109
|
+
dispatch if @pending
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Tunes live under the ROMs as often as not, so $01 has to bank out
|
|
114
|
+
# whatever covers the routine about to run. An RSID tune banks itself,
|
|
115
|
+
# and `SIDFile#bank_for` hands back nil for it.
|
|
116
|
+
def dispatch
|
|
117
|
+
bank = @tune.bank_for(@pending)
|
|
118
|
+
@bus.poke(0x01, bank) if bank
|
|
119
|
+
@bus.ram.write(stub_address + 1,
|
|
120
|
+
[low_byte(@pending), high_byte(@pending)])
|
|
121
|
+
@cpu.a = @argument
|
|
122
|
+
@cpu.stack_pointer = 0xff
|
|
123
|
+
@cpu.program_counter = stub_address
|
|
124
|
+
@pending = nil
|
|
125
|
+
@idle = false
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Audio
|
|
5
|
+
# Runs `badline-sid` once its options have parsed: picks the song and
|
|
6
|
+
# its length, then plays it or renders it to a file. Played on a
|
|
7
|
+
# terminal, the console lets the listener step between songs.
|
|
8
|
+
class CLI
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
|
|
11
|
+
# `sink` builds the audio device for playback, given the rate to ask
|
|
12
|
+
# for and whether it has to be exact.
|
|
13
|
+
def initialize(options, out: $stdout, input: $stdin, sink: SDLSink.method(:new))
|
|
14
|
+
@options = options
|
|
15
|
+
@out = out
|
|
16
|
+
@input = input
|
|
17
|
+
@sink = sink
|
|
18
|
+
@lengths = {}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run
|
|
22
|
+
@options.render? ? render : play
|
|
23
|
+
rescue Renderer::UnknownFormatError, Storage::SIDFile::FormatError => e
|
|
24
|
+
raise Error, e.message
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def tune
|
|
28
|
+
@tune ||= Storage::SIDFile.new(@options.tune_path)
|
|
29
|
+
rescue Storage::SIDFile::FormatError => e
|
|
30
|
+
raise Error, "#{@options.tune_path}: #{e.message}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def song
|
|
34
|
+
@song ||= (@options.song || tune.start_song).tap do |song|
|
|
35
|
+
raise Error, "no song #{song}: the tune has #{tune.songs}" if song > tune.songs
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def seconds = length(song)
|
|
40
|
+
|
|
41
|
+
def length(song)
|
|
42
|
+
@lengths[song] ||= @options.seconds || songlength(song) || Options::FALLBACK_SECONDS
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def sid_model = @options.sid_model || tune.sid_model
|
|
46
|
+
|
|
47
|
+
def interactive? = @options.tui? && @input.tty? && @out.tty?
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def renderer(song, rate)
|
|
52
|
+
Renderer.new(tune, seconds: length(song), song:, rate:, sid_model:).tap do |renderer|
|
|
53
|
+
renderer.filter_chunk = @options.filter_chunk if @options.filter_chunk
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def render
|
|
58
|
+
renderer = renderer(song, @options.rate)
|
|
59
|
+
@out.puts describe
|
|
60
|
+
@out.puts "Rendering #{seconds}s for the #{model_name} to #{@options.output} at #{@options.rate} Hz..."
|
|
61
|
+
started = now
|
|
62
|
+
renderer.render(@options.output) { |done| progress(done) }
|
|
63
|
+
report(now - started)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def play
|
|
67
|
+
sink = open_sink
|
|
68
|
+
interactive? ? play_interactively(sink) : play_plainly(sink)
|
|
69
|
+
ensure
|
|
70
|
+
sink&.close
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def play_plainly(sink)
|
|
74
|
+
@out.puts describe
|
|
75
|
+
@out.puts "Playing #{seconds}s on the #{model_name} at #{sink.rate} Hz. Ctrl-C stops."
|
|
76
|
+
playback = Playback.new(sink, on_underrun: -> { @out.puts "\rRunning below real time, so it will stutter." })
|
|
77
|
+
result = playback.play(renderer(song, sink.rate)) { |played| progress(played) }
|
|
78
|
+
progress(seconds) if result == :finished
|
|
79
|
+
@out.print "\n" unless @options.quiet?
|
|
80
|
+
@out.puts(result == :finished ? "Done." : "Stopped.")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def play_interactively(sink)
|
|
84
|
+
console = Console.new(input: @input, output: @out)
|
|
85
|
+
jukebox = Jukebox.new(sink, console, songs: tune.songs, renderer: method(:renderer), length: method(:length))
|
|
86
|
+
console.session do
|
|
87
|
+
console.header([tune.name, tune.author, tune.released].reject(&:empty?) +
|
|
88
|
+
["#{model_name} at #{sink.rate} Hz"])
|
|
89
|
+
jukebox.run(song)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def open_sink
|
|
94
|
+
@sink.call(rate: @options.rate, exact_rate: @options.rate_given?)
|
|
95
|
+
rescue SDLSink::Error => e
|
|
96
|
+
raise Error, "can't open the audio device: #{e.message}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def describe
|
|
100
|
+
header = [tune.name, tune.author, tune.released].reject(&:empty?).join(" / ")
|
|
101
|
+
header.empty? ? "song #{song}" : "#{header} (song #{song})"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# HVSC's database is keyed by the tune's MD5 and lists one length per
|
|
105
|
+
# song.
|
|
106
|
+
def songlength(song) = songlengths&.at(song - 1)
|
|
107
|
+
|
|
108
|
+
def songlengths
|
|
109
|
+
return @songlengths if defined?(@songlengths)
|
|
110
|
+
|
|
111
|
+
path = @options.songlengths || Storage::SongLengths.locate(@options.tune_path)
|
|
112
|
+
@songlengths = path && Storage::SongLengths.new(path).lengths(tune.md5)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def model_name = sid_model.to_s.delete_prefix("mos")
|
|
116
|
+
|
|
117
|
+
def progress(done)
|
|
118
|
+
return if @options.quiet?
|
|
119
|
+
|
|
120
|
+
@out.print format("\r%<done>6.1fs / %<total>.1fs", done:, total: seconds)
|
|
121
|
+
@out.flush
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def report(elapsed)
|
|
125
|
+
@out.print "\r" unless @options.quiet?
|
|
126
|
+
@out.puts format("Wrote %<path>s in %<elapsed>.1fs (%<speed>.2fx real time).",
|
|
127
|
+
path: @options.output, elapsed:, speed: seconds / elapsed)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
require "io/wait"
|
|
5
|
+
|
|
6
|
+
module Badline
|
|
7
|
+
module Audio
|
|
8
|
+
# The terminal face of `badline-sid`: the tune's header, a status line
|
|
9
|
+
# redrawn in place, and single keypresses read without waiting for
|
|
10
|
+
# Return. Ctrl-C still interrupts.
|
|
11
|
+
class Console
|
|
12
|
+
KEYS = {
|
|
13
|
+
"n" => :next, "\e[C" => :next,
|
|
14
|
+
"p" => :previous, "\e[D" => :previous,
|
|
15
|
+
" " => :pause,
|
|
16
|
+
"q" => :quit
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def initialize(input:, output:)
|
|
20
|
+
@input = input
|
|
21
|
+
@output = output
|
|
22
|
+
@closed = false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Raw input and a hidden cursor for the length of the block.
|
|
26
|
+
def session(&)
|
|
27
|
+
@output.print "\e[?25l"
|
|
28
|
+
@input.tty? ? @input.raw(intr: true, &) : yield
|
|
29
|
+
ensure
|
|
30
|
+
@output.print "\e[?25h\r\n"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Waits up to `seconds` for keys and returns what they ask for.
|
|
34
|
+
def wait(seconds)
|
|
35
|
+
return idle(seconds) if @closed
|
|
36
|
+
return [] unless @input.wait_readable(seconds)
|
|
37
|
+
|
|
38
|
+
case (keys = @input.read_nonblock(64, exception: false))
|
|
39
|
+
when :wait_readable then []
|
|
40
|
+
when nil then close_input(seconds)
|
|
41
|
+
else keys.scan(/\e\[[A-D]|./m).filter_map { |key| KEYS[key] }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def header(lines)
|
|
46
|
+
lines.each { |line| @output.print "#{line}\r\n" }
|
|
47
|
+
@output.print "n/→ next p/← previous space pause q quit\r\n"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def status(song:, songs:, elapsed:, length:, notes: [])
|
|
51
|
+
line = format("song %<song>d/%<songs>d %<elapsed>s / %<length>s",
|
|
52
|
+
song:, songs:, elapsed: clock(elapsed), length: clock(length))
|
|
53
|
+
@output.print "\r\e[K#{([line] + notes).join(' ')}"
|
|
54
|
+
@output.flush
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def close_input(seconds)
|
|
60
|
+
@closed = true
|
|
61
|
+
idle(seconds)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def idle(seconds)
|
|
65
|
+
sleep(seconds)
|
|
66
|
+
[]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def clock(seconds)
|
|
70
|
+
whole = seconds.floor
|
|
71
|
+
format("%<minutes>d:%<seconds>02d", minutes: whole / 60, seconds: whole % 60)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|