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.
Files changed (135) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +167 -0
  3. data/CLAUDE.md +217 -0
  4. data/CODE_OF_CONDUCT.md +92 -0
  5. data/CONTRIBUTING.md +51 -0
  6. data/README.md +224 -31
  7. data/doc/pinned-behaviour.md +1117 -0
  8. data/exe/badline +22 -2
  9. data/exe/badline-sid +34 -0
  10. data/lib/badline/address_bus.rb +88 -23
  11. data/lib/badline/audio/aiff.rb +36 -0
  12. data/lib/badline/audio/bare_player.rb +129 -0
  13. data/lib/badline/audio/cli.rb +133 -0
  14. data/lib/badline/audio/console.rb +75 -0
  15. data/lib/badline/audio/jukebox.rb +82 -0
  16. data/lib/badline/audio/machine_player.rb +57 -0
  17. data/lib/badline/audio/options.rb +137 -0
  18. data/lib/badline/audio/pcm_writer.rb +57 -0
  19. data/lib/badline/audio/playback.rb +108 -0
  20. data/lib/badline/audio/renderer.rb +85 -0
  21. data/lib/badline/audio/sdl_sink.rb +102 -0
  22. data/lib/badline/audio/wav.rb +23 -0
  23. data/lib/badline/audio.rb +14 -0
  24. data/lib/badline/cartridge/action_replay.rb +111 -0
  25. data/lib/badline/cartridge/atomic_power.rb +24 -0
  26. data/lib/badline/cartridge/bank.rb +81 -0
  27. data/lib/badline/cartridge/comal80.rb +48 -0
  28. data/lib/badline/cartridge/dinamic.rb +34 -0
  29. data/lib/badline/cartridge/easy_flash.rb +122 -0
  30. data/lib/badline/cartridge/epyx_fastload.rb +71 -0
  31. data/lib/badline/cartridge/final_cartridge3.rb +77 -0
  32. data/lib/badline/cartridge/flash.rb +265 -0
  33. data/lib/badline/cartridge/freezer.rb +31 -0
  34. data/lib/badline/cartridge/fun_play.rb +38 -0
  35. data/lib/badline/cartridge/g_mod2.rb +62 -0
  36. data/lib/badline/cartridge/game_system.rb +40 -0
  37. data/lib/badline/cartridge/kcs_power.rb +72 -0
  38. data/lib/badline/cartridge/mach5.rb +34 -0
  39. data/lib/badline/cartridge/magic_desk.rb +6 -3
  40. data/lib/badline/cartridge/ocean.rb +11 -12
  41. data/lib/badline/cartridge/pagefox.rb +56 -0
  42. data/lib/badline/cartridge/retro_replay.rb +276 -0
  43. data/lib/badline/cartridge/rex_utility.rb +33 -0
  44. data/lib/badline/cartridge/rgcd.rb +48 -0
  45. data/lib/badline/cartridge/simons_basic.rb +40 -0
  46. data/lib/badline/cartridge/standard.rb +3 -19
  47. data/lib/badline/cartridge/super_games.rb +36 -0
  48. data/lib/badline/cartridge/westermann.rb +34 -0
  49. data/lib/badline/cartridge/zaxxon.rb +50 -0
  50. data/lib/badline/cartridge.rb +131 -15
  51. data/lib/badline/cia/interrupt_register.rb +88 -0
  52. data/lib/badline/cia/serial.rb +219 -0
  53. data/lib/badline/cia/timer.rb +43 -31
  54. data/lib/badline/cia.rb +109 -74
  55. data/lib/badline/color_memory.rb +14 -5
  56. data/lib/badline/computer.rb +68 -8
  57. data/lib/badline/control_ports.rb +54 -9
  58. data/lib/badline/cpu/addressing.rb +131 -0
  59. data/lib/badline/cpu/microcode.rb +171 -0
  60. data/lib/badline/cpu/operations.rb +88 -0
  61. data/lib/badline/cpu/stack_operations.rb +82 -0
  62. data/lib/badline/cpu.rb +108 -167
  63. data/lib/badline/cycleable.rb +3 -0
  64. data/lib/badline/datasette.rb +81 -0
  65. data/lib/badline/debug_register.rb +28 -0
  66. data/lib/badline/gui/application.rb +79 -18
  67. data/lib/badline/gui/gamepads.rb +97 -0
  68. data/lib/badline/gui/joy_map.rb +15 -7
  69. data/lib/badline/gui.rb +1 -0
  70. data/lib/badline/input/mouse1351.rb +41 -0
  71. data/lib/badline/input/paddles.rb +36 -0
  72. data/lib/badline/input.rb +4 -0
  73. data/lib/badline/instruction_set/arithmetic.rb +16 -21
  74. data/lib/badline/instruction_set/bitwise.rb +17 -22
  75. data/lib/badline/instruction_set/branch.rb +20 -19
  76. data/lib/badline/instruction_set/flag.rb +7 -7
  77. data/lib/badline/instruction_set/illegal.rb +14 -22
  78. data/lib/badline/instruction_set/inc_dec.rb +8 -10
  79. data/lib/badline/instruction_set/stack.rb +5 -57
  80. data/lib/badline/instruction_set/transfer.rb +9 -9
  81. data/lib/badline/instruction_set.rb +9 -31
  82. data/lib/badline/interrupts.rb +104 -0
  83. data/lib/badline/joystick.rb +9 -9
  84. data/lib/badline/kernal_trap/channel.rb +33 -0
  85. data/lib/badline/kernal_trap/drive.rb +239 -0
  86. data/lib/badline/kernal_trap/file.rb +4 -24
  87. data/lib/badline/kernal_trap/load.rb +2 -2
  88. data/lib/badline/kernal_trap/routine.rb +34 -0
  89. data/lib/badline/kernal_trap/save.rb +1 -1
  90. data/lib/badline/kernal_trap/serial.rb +139 -0
  91. data/lib/badline/kernal_trap.rb +4 -0
  92. data/lib/badline/keyboard.rb +40 -16
  93. data/lib/badline/media.rb +48 -10
  94. data/lib/badline/roms/eapi/LICENSE.md +15 -0
  95. data/lib/badline/roms/eapi/README +41 -0
  96. data/lib/badline/roms/eapi/eapi-am29f040-14 +0 -0
  97. data/lib/badline/sid/decimator.rb +46 -0
  98. data/lib/badline/sid/envelope.rb +232 -0
  99. data/lib/badline/sid/filter.rb +213 -0
  100. data/lib/badline/sid/voice.rb +61 -0
  101. data/lib/badline/sid/waveform/combined.rb +126 -0
  102. data/lib/badline/sid/waveform/fast_forward.rb +105 -0
  103. data/lib/badline/sid/waveform/noise_writeback.rb +82 -0
  104. data/lib/badline/sid/waveform.rb +289 -0
  105. data/lib/badline/sid.rb +274 -7
  106. data/lib/badline/status.rb +32 -27
  107. data/lib/badline/storage/crt_file.rb +18 -2
  108. data/lib/badline/storage/disk_image.rb +52 -5
  109. data/lib/badline/storage/host_directory.rb +18 -4
  110. data/lib/badline/storage/sid_file.rb +274 -0
  111. data/lib/badline/storage/song_lengths.rb +65 -0
  112. data/lib/badline/storage/t64.rb +67 -0
  113. data/lib/badline/storage/tap.rb +68 -0
  114. data/lib/badline/storage.rb +19 -0
  115. data/lib/badline/time_of_day.rb +105 -52
  116. data/lib/badline/version.rb +1 -1
  117. data/lib/badline/vic/bank.rb +14 -6
  118. data/lib/badline/vic/border_mask.rb +76 -0
  119. data/lib/badline/vic/collisions.rb +104 -0
  120. data/lib/badline/vic/color_patches.rb +33 -0
  121. data/lib/badline/vic/display_state.rb +92 -29
  122. data/lib/badline/vic/graphics_mode.rb +78 -53
  123. data/lib/badline/vic/graphics_shifter.rb +140 -0
  124. data/lib/badline/vic/register_log.rb +63 -0
  125. data/lib/badline/vic/registers.rb +3 -0
  126. data/lib/badline/vic/sequencer.rb +163 -137
  127. data/lib/badline/vic/sequencer_output.rb +116 -0
  128. data/lib/badline/vic/sprite/internal_bus.rb +49 -0
  129. data/lib/badline/vic/sprite/shifter.rb +129 -0
  130. data/lib/badline/vic/sprite.rb +275 -65
  131. data/lib/badline/vic/sprites.rb +220 -58
  132. data/lib/badline/vic.rb +373 -49
  133. data/lib/badline.rb +5 -0
  134. data/vendor/.gitkeep +0 -0
  135. 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 .crt cartridge, or a directory to mount as device 8.
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
- Badline::GUI::Application.new(media_path:, autostart: options[:autostart]).run
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
@@ -29,43 +29,55 @@ module Badline
29
29
  class AddressBus
30
30
  include Addressable
31
31
 
32
- # Unmapped address space for Ultimax cartridges.
33
- module OpenSpace
34
- module_function
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) = 0xff
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
- @sid = SID.new
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(start: 0xd800, length: 2**10)
76
+ @color_ram = ColorMemory.new(@vic)
77
+ @open_bus = OpenBus.new(@vic)
66
78
 
67
- @port_ddr = 0x2f
68
- @port_out = 0x37
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(OpenSpace, 0x10, 0xf0)
158
- @write_pages.fill(OpenSpace, 0x10, 0xf0)
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
- return unless @cartridge
233
+ @read_pages[0xd7] = @write_pages[0xd7] = @debug_register if @debug_register
234
+ map_cartridge_io if @cartridge
235
+ end
173
236
 
174
- @read_pages.fill(@cartridge, 0xde, 2)
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.basic? || io_port.kernal?) && !io_port.io?
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