badline 0.1.0 → 0.2.1
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 +185 -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 +96 -25
- 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 +132 -16
- data/lib/badline/cia/interrupt_register.rb +102 -0
- data/lib/badline/cia/serial.rb +219 -0
- data/lib/badline/cia/timer.rb +43 -31
- data/lib/badline/cia.rb +138 -88
- data/lib/badline/color_memory.rb +14 -5
- data/lib/badline/computer.rb +97 -12
- data/lib/badline/control_ports.rb +54 -9
- data/lib/badline/cpu/addressing.rb +131 -0
- data/lib/badline/cpu/microcode.rb +169 -0
- data/lib/badline/cpu/operations.rb +88 -0
- data/lib/badline/cpu/stack_operations.rb +82 -0
- data/lib/badline/cpu.rb +109 -168
- 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 +51 -0
- data/lib/badline/kernal_trap/drive/status.rb +49 -0
- data/lib/badline/kernal_trap/drive.rb +277 -0
- data/lib/badline/kernal_trap/file.rb +5 -25
- data/lib/badline/kernal_trap/load.rb +86 -24
- data/lib/badline/kernal_trap/routine.rb +51 -0
- data/lib/badline/kernal_trap/save.rb +46 -14
- data/lib/badline/kernal_trap/serial.rb +171 -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/memory.rb +5 -0
- 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 +251 -23
- data/lib/badline/storage/crt_file.rb +18 -2
- data/lib/badline/storage/d64_image.rb +6 -0
- data/lib/badline/storage/d71_image.rb +4 -0
- data/lib/badline/storage/d81_image.rb +4 -0
- data/lib/badline/storage/disk_image.rb +99 -13
- data/lib/badline/storage/host_directory.rb +30 -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 +389 -55
- data/lib/badline.rb +5 -0
- data/vendor/.gitkeep +0 -0
- metadata +104 -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,45 +29,59 @@ 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
|
|
47
|
+
|
|
48
|
+
RAM_POWER_ON = [0xff, 0x07].freeze
|
|
42
49
|
|
|
43
50
|
attr_reader :io_port, :ram, :basic_rom, :character_rom, :kernal_rom,
|
|
44
|
-
:vic, :sid, :color_ram, :cia1, :cia2, :keyboard, :joystick2,
|
|
45
|
-
:cartridge, :ultimax
|
|
51
|
+
:vic, :sid, :color_ram, :cia1, :cia2, :keyboard, :joystick1, :joystick2,
|
|
52
|
+
:control_ports, :cartridge, :ultimax, :phi1_ultimax, :datasette
|
|
46
53
|
|
|
47
|
-
def initialize
|
|
48
|
-
@ram = Memory.new(
|
|
54
|
+
def initialize(sid_model: :mos6581)
|
|
55
|
+
@ram = Memory.new(RAM_POWER_ON, length: 2**16, start: 0)
|
|
49
56
|
@cartridge = nil
|
|
57
|
+
@debug_register = nil
|
|
50
58
|
|
|
51
59
|
@basic_rom = ROM.load("basic.rom", 0xa000)
|
|
52
60
|
@character_rom = ROM.load("character.rom", 0xd000)
|
|
53
61
|
@kernal_rom = ROM.load("kernal.rom", 0xe000)
|
|
54
62
|
|
|
55
63
|
@keyboard = Keyboard.new
|
|
64
|
+
@joystick1 = Joystick.new
|
|
56
65
|
@joystick2 = Joystick.new
|
|
66
|
+
@control_ports = ControlPorts.new(keyboard: @keyboard, joystick1: @joystick1, joystick2: @joystick2)
|
|
57
67
|
@vic = VIC.new(self)
|
|
58
|
-
@cia1 = CIA.new(
|
|
59
|
-
start: 0xdc00,
|
|
60
|
-
peripheral: ControlPorts.new(keyboard: @keyboard, joystick2: @joystick2)
|
|
61
|
-
)
|
|
68
|
+
@cia1 = CIA.new(start: 0xdc00, peripheral: @control_ports)
|
|
62
69
|
@cia2 = CIA.new(start: 0xdd00)
|
|
63
|
-
@
|
|
70
|
+
@control_ports.port_a_source = @cia1
|
|
71
|
+
@cia1.on_port_b4_change { |high| @vic.lightpen_level(high) }
|
|
72
|
+
@sid = SID.new(model: sid_model, pots: @control_ports)
|
|
73
|
+
|
|
74
|
+
@datasette = Datasette.new
|
|
75
|
+
@datasette.on_flag { @cia1.flag! }
|
|
76
|
+
@datasette.on_sense_change { @io_port.value = port_value }
|
|
64
77
|
|
|
65
|
-
@color_ram = ColorMemory.new(
|
|
78
|
+
@color_ram = ColorMemory.new(@vic)
|
|
79
|
+
@open_bus = OpenBus.new(@vic)
|
|
66
80
|
|
|
67
|
-
@port_ddr =
|
|
68
|
-
@port_out =
|
|
81
|
+
@port_ddr = 0x00
|
|
82
|
+
@port_out = 0x00
|
|
69
83
|
@port_floating = 0x00
|
|
70
|
-
@io_port =
|
|
84
|
+
@io_port = PortStatus.new(%i[basic kernal io tape_out tape_switch tape_motor], value: port_value)
|
|
71
85
|
|
|
72
86
|
@read_pages = Array.new(256)
|
|
73
87
|
@write_pages = Array.new(256)
|
|
@@ -76,14 +90,33 @@ module Badline
|
|
|
76
90
|
|
|
77
91
|
def attach_cartridge(cartridge)
|
|
78
92
|
@cartridge = cartridge
|
|
93
|
+
cartridge.connect(ram: @ram, open_bus: @open_bus)
|
|
79
94
|
cartridge.on_change { update_overlays! }
|
|
80
95
|
update_overlays!
|
|
81
96
|
end
|
|
82
97
|
|
|
98
|
+
def power_on!
|
|
99
|
+
@ram.clear!(RAM_POWER_ON)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The 6510's RES line clears the port's direction and output registers.
|
|
103
|
+
# The floating bits keep their charge.
|
|
104
|
+
def reset_port!
|
|
105
|
+
@port_ddr = 0x00
|
|
106
|
+
@port_out = 0x00
|
|
107
|
+
update_port!
|
|
108
|
+
end
|
|
109
|
+
|
|
83
110
|
def disable_overlays!
|
|
111
|
+
poke(0, 0x2f)
|
|
84
112
|
poke(1, 0)
|
|
85
113
|
end
|
|
86
114
|
|
|
115
|
+
def install_debug_register(&)
|
|
116
|
+
@debug_register = DebugRegister.new(@sid, &)
|
|
117
|
+
update_overlays!
|
|
118
|
+
end
|
|
119
|
+
|
|
87
120
|
def peek(addr)
|
|
88
121
|
return @port_ddr if addr.zero?
|
|
89
122
|
return @io_port.value if addr == 0x01
|
|
@@ -112,11 +145,14 @@ module Badline
|
|
|
112
145
|
driven = @port_ddr & PORT_FLOATING
|
|
113
146
|
@port_floating = (@port_floating & ~driven) | (@port_out & driven)
|
|
114
147
|
@io_port.value = port_value
|
|
148
|
+
# $01 bit 5 drives the motor through an inverter: low runs it.
|
|
149
|
+
@datasette.motor = !@io_port.tape_motor?
|
|
115
150
|
update_overlays!
|
|
116
151
|
end
|
|
117
152
|
|
|
118
153
|
def port_value
|
|
119
154
|
input = PORT_PULLUPS | (@port_floating & PORT_FLOATING)
|
|
155
|
+
input &= ~TAPE_SENSE if @datasette.sense_low?
|
|
120
156
|
(@port_out & @port_ddr) | (input & ~@port_ddr & 0xff)
|
|
121
157
|
end
|
|
122
158
|
|
|
@@ -125,6 +161,7 @@ module Badline
|
|
|
125
161
|
# of range checks.
|
|
126
162
|
def update_overlays!
|
|
127
163
|
@ultimax = @cartridge ? @cartridge.ultimax? : false
|
|
164
|
+
@phi1_ultimax = @cartridge ? @cartridge.phi1_ultimax? : false
|
|
128
165
|
@read_pages.fill(@ram)
|
|
129
166
|
@write_pages.fill(@ram)
|
|
130
167
|
|
|
@@ -133,6 +170,8 @@ module Badline
|
|
|
133
170
|
|
|
134
171
|
def map_banked_pages
|
|
135
172
|
map_rom_overlays
|
|
173
|
+
map_cartridge_ram
|
|
174
|
+
@write_pages.fill(@cartridge.romh_writes, 0xe0, 0x20) if @cartridge&.romh_writes
|
|
136
175
|
|
|
137
176
|
if io?
|
|
138
177
|
map_io_pages
|
|
@@ -151,28 +190,59 @@ module Badline
|
|
|
151
190
|
@read_pages.fill(kernal_rom, 0xe0, 0x20) if kernal?
|
|
152
191
|
end
|
|
153
192
|
|
|
193
|
+
# Cartridge RAM in the ROML or ROMH window decodes writes itself,
|
|
194
|
+
# whatever the $01 lines say.
|
|
195
|
+
def map_cartridge_ram
|
|
196
|
+
return unless @cartridge&.exrom&.zero?
|
|
197
|
+
|
|
198
|
+
map_cartridge_ram_bank(@cartridge.roml, 0x80)
|
|
199
|
+
map_cartridge_ram_bank(@cartridge.romh, 0xa0) if @cartridge.game.zero?
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def map_cartridge_ram_bank(bank, first_page)
|
|
203
|
+
@write_pages.fill(bank, first_page, 0x20) if bank.is_a?(Cartridge::RAMBank)
|
|
204
|
+
end
|
|
205
|
+
|
|
154
206
|
# Ultimax cartridges ignore the $01 lines: 4K of RAM, ROML/ROMH windows,
|
|
155
|
-
# I/O always visible and open address space everywhere else.
|
|
207
|
+
# I/O always visible and open address space everywhere else. The ROML
|
|
208
|
+
# and ROMH selects fire on writes as well, so cartridge RAM or flash in
|
|
209
|
+
# either window takes the writes there.
|
|
156
210
|
def map_ultimax_pages
|
|
157
|
-
@read_pages.fill(
|
|
158
|
-
@write_pages.fill(
|
|
211
|
+
@read_pages.fill(@open_bus, 0x10, 0xf0)
|
|
212
|
+
@write_pages.fill(@open_bus, 0x10, 0xf0)
|
|
159
213
|
@read_pages.fill(@cartridge.roml, 0x80, 0x20) if @cartridge.roml
|
|
214
|
+
map_ultimax_writes(@cartridge.roml, 0x80)
|
|
160
215
|
@read_pages.fill(@cartridge.romh, 0xe0, 0x20) if @cartridge.romh
|
|
216
|
+
map_ultimax_writes(@cartridge.romh, 0xe0)
|
|
217
|
+
map_ultimax_a000
|
|
161
218
|
map_io_pages
|
|
162
219
|
end
|
|
163
220
|
|
|
221
|
+
def map_ultimax_writes(bank, first_page)
|
|
222
|
+
@write_pages.fill(bank, first_page, 0x20) if bank.respond_to?(:poke)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def map_ultimax_a000
|
|
226
|
+
return unless (window = @cartridge.ultimax_a000)
|
|
227
|
+
|
|
228
|
+
@read_pages.fill(window, 0xa0, 0x20)
|
|
229
|
+
@write_pages.fill(window, 0xa0, 0x20)
|
|
230
|
+
end
|
|
231
|
+
|
|
164
232
|
def map_io_pages
|
|
165
233
|
{
|
|
166
234
|
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
|
|
235
|
+
cia1 => 0xdc..0xdc, cia2 => 0xdd..0xdd, @open_bus => 0xde..0xdf
|
|
169
236
|
}.each do |chip, pages|
|
|
170
237
|
pages.each { |p| @read_pages[p] = @write_pages[p] = chip }
|
|
171
238
|
end
|
|
172
|
-
|
|
239
|
+
@read_pages[0xd7] = @write_pages[0xd7] = @debug_register if @debug_register
|
|
240
|
+
map_cartridge_io if @cartridge
|
|
241
|
+
end
|
|
173
242
|
|
|
174
|
-
|
|
243
|
+
def map_cartridge_io
|
|
175
244
|
@write_pages.fill(@cartridge, 0xde, 2)
|
|
245
|
+
@cartridge.readable_io_pages.each { |p| @read_pages[p] = @cartridge }
|
|
176
246
|
end
|
|
177
247
|
|
|
178
248
|
def basic?
|
|
@@ -191,8 +261,9 @@ module Badline
|
|
|
191
261
|
@cartridge&.romh && @cartridge.exrom.zero? && @cartridge.game.zero? && io_port.kernal?
|
|
192
262
|
end
|
|
193
263
|
|
|
264
|
+
# In 16K mode LORAM alone leaves $d000 as RAM, where it still maps I/O.
|
|
194
265
|
def character?
|
|
195
|
-
(io_port.
|
|
266
|
+
(io_port.kernal? || (io_port.basic? && game_high?)) && !io_port.io?
|
|
196
267
|
end
|
|
197
268
|
|
|
198
269
|
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
|