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/lib/badline/media.rb CHANGED
@@ -3,37 +3,75 @@
3
3
  module Badline
4
4
  module Media
5
5
  AUTOSTART = %(lO"*",8,1\rrun\r)
6
+ TAPE_AUTOSTART = %(lO\rrun\r)
6
7
  BASIC_START = 0x0801
7
8
 
8
- DISK_TYPES = {
9
+ MOUNT_TYPES = {
9
10
  ".d64" => Storage::D64Image,
10
11
  ".d71" => Storage::D71Image,
11
- ".d81" => Storage::D81Image
12
+ ".d81" => Storage::D81Image,
13
+ ".t64" => Storage::T64
12
14
  }.freeze
13
15
 
14
16
  class << self
15
- def attach(computer, path, autostart: true)
17
+ # `cartridge` sets the jumpers of a .crt cartridge that has them, such
18
+ # as `{ flash_jumper: true }` for the Retro Replay's flash mode.
19
+ def attach(computer, path, autostart: true, song: nil, cartridge: {})
16
20
  if File.directory?(path)
17
21
  computer.mount(Storage::HostDirectory.new(path))
18
22
  "Mounted #{path} as device 8"
19
23
  elsif File.extname(path).downcase == ".crt"
20
- attach_cartridge(computer, path)
21
- elsif (image = DISK_TYPES[File.extname(path).downcase])
22
- attach_disk(computer, image.new(path), path, autostart:)
24
+ attach_cartridge(computer, path, cartridge)
25
+ elsif File.extname(path).downcase == ".sid"
26
+ attach_sid(computer, path, autostart:, song:)
27
+ elsif File.extname(path).downcase == ".tap"
28
+ attach_tape(computer, path, autostart:)
29
+ elsif (storage = MOUNT_TYPES[File.extname(path).downcase])
30
+ attach_storage(computer, storage.new(path), path, autostart:)
23
31
  else
24
32
  attach_prg(computer, path, autostart:)
25
33
  end
26
34
  end
27
35
 
36
+ # The SID a machine for `path` should be built with. A .sid tune names
37
+ # its own; everything else gets the 6581.
38
+ def sid_model(path)
39
+ return :mos6581 unless path && File.extname(path).downcase == ".sid"
40
+
41
+ Storage::SIDFile.new(path).sid_model
42
+ end
43
+
28
44
  private
29
45
 
30
- def attach_cartridge(computer, path)
31
- computer.attach_cartridge(Cartridge.from_file(path))
46
+ def attach_cartridge(computer, path, options)
47
+ computer.attach_cartridge(Cartridge.from_file(path, **options))
32
48
  "Attached cartridge #{path}"
33
49
  end
34
50
 
35
- def attach_disk(computer, image, path, autostart:)
36
- computer.mount(image)
51
+ def attach_sid(computer, path, autostart:, song:)
52
+ tune = Storage::SIDFile.new(path)
53
+ song = (song || tune.start_song).clamp(1, tune.songs)
54
+ computer.on_init { start_tune(computer, tune, autostart:, song:) }
55
+ title = tune.name.empty? ? path : tune.name
56
+ title += " (song #{song})" if tune.songs > 1
57
+ autostart ? "Playing #{title}" : "Loaded #{title}"
58
+ end
59
+
60
+ def start_tune(computer, tune, autostart:, song:)
61
+ computer.ram.write(tune.load_address, tune.data)
62
+ computer.ram.write(tune.driver_address, tune.driver(song:))
63
+ computer.type_text("sys#{tune.driver_address}\r") if autostart
64
+ end
65
+
66
+ def attach_tape(computer, path, autostart:)
67
+ computer.datasette.insert(Storage::TAP.new(path))
68
+ computer.datasette.play!
69
+ computer.type_text(TAPE_AUTOSTART) if autostart
70
+ "Inserted #{path} in the datasette"
71
+ end
72
+
73
+ def attach_storage(computer, storage, path, autostart:)
74
+ computer.mount(storage)
37
75
  computer.type_text(AUTOSTART) if autostart
38
76
  "Mounted #{path} as device 8"
39
77
  end
@@ -0,0 +1,15 @@
1
+ This software is provided 'as-is', without any express or implied
2
+ warranty. In no event will the authors be held liable for any damages
3
+ arising from the use of this software.
4
+
5
+ Permission is granted to anyone to use this software for any purpose,
6
+ including commercial applications, and to alter it and redistribute it
7
+ freely, subject to the following restrictions:
8
+
9
+ 1. The origin of this software must not be misrepresented; you must not
10
+ claim that you wrote the original software. If you use this software
11
+ in a product, an acknowledgment in the product documentation would be
12
+ appreciated but is not required.
13
+ 2. Altered source versions must be plainly marked as such, and must not be
14
+ misrepresented as being the original software.
15
+ 3. This notice may not be removed or altered from any source distribution.
@@ -0,0 +1,41 @@
1
+ EasyFlash EAPI for the Am29F040
2
+ ===============================
3
+
4
+ eapi-am29f040-14 is the EasyFlash flash driver (EAPI) for the AMD Am29F040,
5
+ version 1.4, by Thomas 'skoe' Giesel. It is third-party software, not part
6
+ of badline, and is distributed under the zlib licence in LICENSE.md.
7
+
8
+ It is the unaltered output of assembling the upstream source, a C64 PRG
9
+ (a two-byte load address, $C000, then 768 bytes):
10
+
11
+ Source: https://gitlab.com/easyflash/eapi
12
+ Revision: 1b4ea15d44974d18f6f82156b935f59d2f85596c
13
+ File: eapi-am29f040.s (includes eapi_defs.s)
14
+ Built: acme -l out/eapi-am29f040-14.labels -o out/eapi-am29f040-14 eapi-am29f040.s
15
+ (the repository's Makefile rule, with ACME 0.96.4)
16
+ SHA-256: 4c32d425d7b0a1f659e007bccad5c43603a93236974ef19743b2eefb812ab9ed
17
+
18
+ The source file's notice:
19
+
20
+ (c) 2009-2010 Thomas 'skoe' Giesel
21
+
22
+ This software is provided 'as-is', without any express or implied
23
+ warranty. In no event will the authors be held liable for any damages
24
+ arising from the use of this software.
25
+
26
+ Permission is granted to anyone to use this software for any purpose,
27
+ including commercial applications, and to alter it and redistribute it
28
+ freely, subject to the following restrictions:
29
+
30
+ 1. The origin of this software must not be misrepresented; you must not
31
+ claim that you wrote the original software. If you use this software
32
+ in a product, an acknowledgment in the product documentation would be
33
+ appreciated but is not required.
34
+ 2. Altered source versions must be plainly marked as such, and must not be
35
+ misrepresented as being the original software.
36
+ 3. This notice may not be removed or altered from any source distribution.
37
+
38
+ When an EasyFlash image carries an EAPI, as its "eapi" signature at $B800
39
+ in bank 0 of ROMH shows, badline replaces those 768 bytes with this driver
40
+ on attach, the way VICE does, so the image drives the flash chip badline
41
+ emulates.
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class SID
5
+ # Reduces the SID's one-sample-per-cycle stream to an audio rate by
6
+ # averaging each window. Point sampling would be cheaper, but the pulse
7
+ # and noise waveforms carry enough energy above the output Nyquist to
8
+ # alias audibly.
9
+ #
10
+ # A sample can stand for several cycles at once, when the DSP has run
11
+ # them as one step.
12
+ class Decimator
13
+ def initialize(clock_hz:, rate:)
14
+ @clock_hz = clock_hz
15
+ @rate = rate
16
+ @phase = 0
17
+ @sum = 0
18
+ @count = 0
19
+ end
20
+
21
+ # Cycles until the current window closes, counting the next one as 1.
22
+ def cycles_to_close = (@clock_hz - @phase + @rate - 1) / @rate
23
+
24
+ # Returns the averaged sample when these cycles close a window, and
25
+ # nil otherwise. They must not run past the window's end.
26
+ def push(sample, cycles = 1)
27
+ @sum += sample * cycles
28
+ @count += cycles
29
+ @phase += @rate * cycles
30
+ return unless @phase >= @clock_hz
31
+
32
+ @phase -= @clock_hz
33
+ average
34
+ end
35
+
36
+ private
37
+
38
+ def average
39
+ value = @sum.fdiv(@count).round
40
+ @sum = 0
41
+ @count = 0
42
+ value
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class SID
5
+ # ADSR envelope generator.
6
+ #
7
+ # A 15-bit rate counter divides the 1 MHz clock down to one envelope step
8
+ # per period. Attack steps linearly; decay and release run through a
9
+ # second, level-dependent divider that approximates an exponential
10
+ # curve.
11
+ #
12
+ # Each stage runs a cycle or more behind the one feeding it, after
13
+ # reSID 1.0's single-cycle pipeline: the rate counter resets the cycle
14
+ # after it matches, the envelope counter steps two cycles after that,
15
+ # and a gate edge only switches the rate over on its second cycle.
16
+ class Envelope
17
+ # Rate counter comparison values for each ADSR nibble. The counter
18
+ # takes a cycle to reset, so the period is one cycle longer (2ms to
19
+ # 8s over the attack range).
20
+ PERIODS = [8, 31, 62, 94, 148, 219, 266, 312,
21
+ 391, 976, 1953, 3125, 3906, 11_719, 19_531, 31_250].freeze
22
+
23
+ # Envelope levels where the exponential divider changes, and the
24
+ # divider it changes to.
25
+ EXPONENTIAL_PERIODS = { 0xff => 1, 0x5d => 2, 0x36 => 4, 0x1a => 8,
26
+ 0x0e => 16, 0x06 => 30, 0x00 => 1 }.freeze
27
+
28
+ attr_reader :state, :counter, :env3
29
+
30
+ def initialize
31
+ @attack = @decay = @sustain = @release = 0x0
32
+ @gate = false
33
+ @state = @next_state = :release
34
+ @counter = @env3 = 0x00
35
+ @rate_counter = 0
36
+ @rate_period = PERIODS[0]
37
+ @reset_rate_counter = false
38
+ @exponential_counter = 0
39
+ @exponential_period = 1
40
+ @state_pipeline = @envelope_pipeline = @exponential_pipeline = 0
41
+ @hold_zero = true
42
+ end
43
+
44
+ def output = @counter
45
+
46
+ # The gate edge switches the state over two cycles. Rising, it runs
47
+ # the decay rate for the first of them. The rate counter is left
48
+ # running, so the first step lands somewhere inside the current
49
+ # period.
50
+ def control=(value)
51
+ gate = value.anybits?(0x01)
52
+ return if gate == @gate
53
+
54
+ @gate = gate
55
+ gate ? gate_on : gate_off
56
+ end
57
+
58
+ def attack_decay=(value)
59
+ @attack = (value >> 4) & 0x0f
60
+ @decay = value & 0x0f
61
+ @rate_period = PERIODS[@attack] if @state == :attack
62
+ @rate_period = PERIODS[@decay] if @state == :decay_sustain
63
+ end
64
+
65
+ def sustain_release=(value)
66
+ @sustain = (value >> 4) & 0x0f
67
+ @release = value & 0x0f
68
+ @rate_period = PERIODS[@release] if @state == :release
69
+ end
70
+
71
+ # ENV3 reads the counter as it stood at the start of the cycle.
72
+ #
73
+ # Lowering the rate period below the current counter sends it the long
74
+ # way round through 2^15 before the envelope can step, the ADSR delay
75
+ # bug that hard restarts rely on. Counting from 0x7fff wraps to 1.
76
+ def cycle!
77
+ @env3 = @counter
78
+ run_pipeline if pipelined?
79
+ return @reset_rate_counter = true if @rate_counter == @rate_period
80
+
81
+ @rate_counter += 1
82
+ @rate_counter = 1 if @rate_counter == 0x8000
83
+ end
84
+
85
+ # Runs `cycles` cycles as #cycle! would. Between steps only the rate
86
+ # counter moves, so it jumps from one match to the next and the cycles
87
+ # around each step run whole. Frozen at zero, a period changes
88
+ # nothing, so whole periods are counted off at once.
89
+ def fast_forward(cycles)
90
+ while cycles.positive?
91
+ if pipelined?
92
+ cycle!
93
+ cycles -= 1
94
+ next
95
+ end
96
+
97
+ @env3 = @counter
98
+ idle = cycles_to_match
99
+ return advance_rate_counter(cycles) if cycles < idle
100
+
101
+ advance_rate_counter(idle)
102
+ cycles = skip_frozen_periods(cycles - idle)
103
+ end
104
+ end
105
+
106
+ private
107
+
108
+ def land_step
109
+ @envelope_pipeline -= 1
110
+ step if @envelope_pipeline.zero? && !@hold_zero
111
+ end
112
+
113
+ def gate_on
114
+ @next_state = :attack
115
+ @state = :decay_sustain
116
+ @rate_period = PERIODS[@decay]
117
+ @state_pipeline = 2
118
+ if @reset_rate_counter || @exponential_pipeline == 2
119
+ @envelope_pipeline = @exponential_period == 1 || @exponential_pipeline == 2 ? 2 : 4
120
+ elsif @exponential_pipeline == 1
121
+ @state_pipeline = 3
122
+ end
123
+ end
124
+
125
+ def gate_off
126
+ @next_state = :release
127
+ @state_pipeline = @envelope_pipeline.positive? ? 3 : 2
128
+ end
129
+
130
+ def state_change
131
+ @state_pipeline -= 1
132
+ if @next_state == :attack
133
+ enter_attack if @state_pipeline.zero?
134
+ elsif (@state == :attack && @state_pipeline.zero?) ||
135
+ (@state == :decay_sustain && @state_pipeline == 1)
136
+ @state = :release
137
+ @rate_period = PERIODS[@release]
138
+ end
139
+ end
140
+
141
+ def enter_attack
142
+ @state = :attack
143
+ @rate_period = PERIODS[@attack]
144
+ @hold_zero = false
145
+ end
146
+
147
+ # The first attack step also resets the exponential divider.
148
+ def reset_rate_counter
149
+ @rate_counter = 0
150
+ @reset_rate_counter = false
151
+ if @state == :attack
152
+ @exponential_counter = 0
153
+ @envelope_pipeline = 2
154
+ elsif !@hold_zero && (@exponential_counter += 1) == @exponential_period
155
+ @exponential_pipeline = @exponential_period == 1 ? 1 : 2
156
+ end
157
+ end
158
+
159
+ def exponential_step
160
+ @exponential_counter = 0
161
+ return unless @state == :release || (@state == :decay_sustain && @counter != sustain_level)
162
+
163
+ @envelope_pipeline = 1
164
+ end
165
+
166
+ def pipelined?
167
+ @reset_rate_counter || (@state_pipeline | @envelope_pipeline | @exponential_pipeline) != 0
168
+ end
169
+
170
+ def run_pipeline
171
+ state_change if @state_pipeline.positive?
172
+ land_step if @envelope_pipeline.positive?
173
+ if @exponential_pipeline.positive? && (@exponential_pipeline -= 1).zero?
174
+ exponential_step
175
+ elsif @reset_rate_counter
176
+ reset_rate_counter
177
+ end
178
+ end
179
+
180
+ # Cycles that only count before the rate counter matches its period.
181
+ def cycles_to_match
182
+ return @rate_period - @rate_counter if @rate_counter <= @rate_period
183
+
184
+ 0x7fff - @rate_counter + @rate_period
185
+ end
186
+
187
+ def advance_rate_counter(cycles)
188
+ @rate_counter += cycles
189
+ @rate_counter -= 0x7fff if @rate_counter > 0x7fff
190
+ end
191
+
192
+ # With the counter on its period, a frozen envelope comes back to the
193
+ # same state every period plus one cycles. Otherwise the next cycle
194
+ # starts a step.
195
+ def skip_frozen_periods(cycles)
196
+ cycles %= @rate_period + 1 if @hold_zero && @state != :attack
197
+ return cycles unless cycles.positive?
198
+
199
+ cycle!
200
+ cycles - 1
201
+ end
202
+
203
+ def step
204
+ if @state == :attack
205
+ attack_step
206
+ else
207
+ @counter = (@counter - 1) & 0xff
208
+ end
209
+ set_exponential_period
210
+ end
211
+
212
+ def attack_step
213
+ @counter = (@counter + 1) & 0xff
214
+ return unless @counter == 0xff
215
+
216
+ @state = :decay_sustain
217
+ @rate_period = PERIODS[@decay]
218
+ end
219
+
220
+ def sustain_level = (@sustain << 4) | @sustain
221
+
222
+ # Reaching zero freezes the envelope until the gate is raised again.
223
+ def set_exponential_period
224
+ period = EXPONENTIAL_PERIODS[@counter]
225
+ return unless period
226
+
227
+ @exponential_period = period
228
+ @hold_zero = true if @counter.zero?
229
+ end
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class SID
5
+ # The SID's multimode filter: a state-variable design integrating low-,
6
+ # band- and high-pass in parallel, with the mixer and master volume on
7
+ # its output, and the C64 board's own RC network after that.
8
+ #
9
+ # Voices are routed through it or around it per the RES/FILT register,
10
+ # and the outputs selected by MODE/VOL are summed back together with the
11
+ # unfiltered ones.
12
+ class Filter
13
+ # Cutoff frequency in Hz at each chip's breakpoints. The 6581 curve was
14
+ # sampled from a real chip by reSID and bends sharply around $300; the
15
+ # 8580's is close to a straight line from 0 to 12.5 kHz. The curve
16
+ # between breakpoints is interpolated linearly; reSID fits a spline, so
17
+ # the two differ by a few Hz mid-segment.
18
+ CUTOFF_POINTS = {
19
+ mos6581: [[0, 220], [128, 230], [256, 250], [384, 300],
20
+ [512, 420], [640, 780], [768, 1600], [832, 2300],
21
+ [896, 3200], [960, 4300], [1024, 5000], [1088, 5400],
22
+ [1152, 5800], [1216, 6000], [1280, 6200], [1344, 6400],
23
+ [1408, 6600], [1472, 6800], [1536, 7000], [1600, 7200],
24
+ [1664, 7400], [1728, 7600], [1792, 7800], [1856, 8000],
25
+ [1920, 8200], [1984, 8400], [2047, 8600]].freeze,
26
+ mos8580: [[0, 0], [128, 800], [256, 1600], [384, 2500],
27
+ [512, 3300], [640, 4100], [768, 4800], [896, 5600],
28
+ [1024, 6500], [1152, 7500], [1280, 8400], [1408, 9200],
29
+ [1536, 9800], [1664, 10_500], [1792, 11_000],
30
+ [1920, 11_700], [2047, 12_500]].freeze
31
+ }.freeze
32
+
33
+ # 2^20 / 1 MHz, so one cycle of integration is a plain shift.
34
+ SCALE = (2**20) / 1_000_000.0
35
+
36
+ # The integrator goes unstable above ~16 kHz at a one-cycle step.
37
+ W0_MAX = (2 * Math::PI * 16_000 * SCALE).to_i
38
+
39
+ # A step of several cycles scales w0 by their count. reSID keeps such
40
+ # a step stable by capping the cutoff at 4 kHz over 8 cycles; this is
41
+ # the same bound on the product, so a single cycle is never capped.
42
+ W0_STEP_MAX = (2 * Math::PI * 4_000 * 8 * SCALE).to_i
43
+
44
+ def self.build_w0(points)
45
+ table = Array.new(2048)
46
+ points.each_cons(2) do |(fc0, hz0), (fc1, hz1)|
47
+ (fc0..fc1).each do |register|
48
+ hz = hz0 + (((hz1 - hz0) * (register - fc0)).to_f / (fc1 - fc0))
49
+ table[register] = [(2 * Math::PI * hz * SCALE).to_i, W0_MAX].min
50
+ end
51
+ end
52
+ table.freeze
53
+ end
54
+ private_class_method :build_w0
55
+
56
+ W0 = CUTOFF_POINTS.transform_values { |points| build_w0(points) }.freeze
57
+
58
+ # 1024/Q, with Q sweeping from 0.707 up to 1.707 across the register.
59
+ RESONANCE = (0x0..0xf).map { |res| (1024.0 / (0.707 + (res / 15.0))).to_i }.freeze
60
+
61
+ # The mixer's own input DC offset, derived by reSID from the 6581's
62
+ # zero-volume and full-volume output levels. The 8580 has none.
63
+ MIXER_DC = { mos6581: -((0xfff * 0xff) / 18) >> 7, mos8580: 0 }.freeze
64
+
65
+ # Fractional bits carried in the integrators. At low cutoffs a cycle's
66
+ # step is only a few units, which truncating to whole units would
67
+ # swallow, and always toward minus infinity.
68
+ FRACTION = 12
69
+
70
+ # The RC network between the SID's audio pin and the C64's output jack:
71
+ # a 10kΩ/1000pF low-pass at ~16 kHz, then a 1kΩ/10µF high-pass at
72
+ # ~16 Hz that strips the 6581's DC offset off the mix.
73
+ class External
74
+ W0_LOWPASS = (100_000 * SCALE).round
75
+ W0_HIGHPASS = (100 * SCALE).round
76
+
77
+ # The high-pass step is W0_HIGHPASS/2^20 of the gap, so in whole
78
+ # units it stalls on any gap under ~10k, as reSID's does. Sixteen
79
+ # fractional bits bring that under one unit.
80
+ FRACTION = 16
81
+
82
+ # Its input arrives with the filter's fractional bits still on.
83
+ INPUT_SHIFT = FRACTION - Filter::FRACTION
84
+
85
+ attr_reader :output
86
+
87
+ def initialize
88
+ @lowpass = 0
89
+ @highpass = 0
90
+ @output = 0
91
+ end
92
+
93
+ # Takes the input in 1/2^Filter::FRACTION units, and reads the
94
+ # output out in whole ones.
95
+ def cycle!(input, cycles = 1)
96
+ delta_lowpass = ((W0_LOWPASS >> 8) * cycles * ((input << INPUT_SHIFT) - @lowpass)) >> 12
97
+ delta_highpass = (W0_HIGHPASS * cycles * (@lowpass - @highpass)) >> 20
98
+ @output = (@lowpass - @highpass) >> FRACTION
99
+ @lowpass += delta_lowpass
100
+ @highpass += delta_highpass
101
+ @output
102
+ end
103
+ end
104
+
105
+ attr_reader :model, :cutoff, :routing, :mode, :volume
106
+
107
+ def initialize(model: :mos6581)
108
+ @model = model
109
+ @w0_table = W0.fetch(model)
110
+ @mixer_dc = MIXER_DC.fetch(model)
111
+ @external = External.new
112
+ reset
113
+ end
114
+
115
+ def reset
116
+ @cutoff = 0x000
117
+ @routing = 0x0
118
+ @mode = 0x0
119
+ @volume = 0x0
120
+ @voice3_off = false
121
+ @w0 = @w0_table[0]
122
+ @resonance = RESONANCE[0]
123
+ @lowpass = @bandpass = @highpass = 0
124
+ @input = @unfiltered = 0
125
+ end
126
+
127
+ def write(reg, value)
128
+ case reg
129
+ when 0x15 then self.cutoff = (@cutoff & 0x7f8) | (value & 0x07)
130
+ when 0x16 then self.cutoff = (value << 3) | (@cutoff & 0x007)
131
+ when 0x17 then write_resonance(value)
132
+ when 0x18 then write_mode_volume(value)
133
+ end
134
+ end
135
+
136
+ def cutoff=(value)
137
+ @cutoff = value & 0x7ff
138
+ @w0 = @w0_table[@cutoff]
139
+ end
140
+
141
+ def voice3_off? = @voice3_off
142
+
143
+ def lowpass = @lowpass >> FRACTION
144
+ def bandpass = @bandpass >> FRACTION
145
+ def highpass = @highpass >> FRACTION
146
+
147
+ # Integrates `cycles` cycles in one step, from the voices' output at
148
+ # the end of them.
149
+ def cycle!(voices, cycles = 1)
150
+ route(voices)
151
+ w0 = @w0 * cycles
152
+ w0 = W0_STEP_MAX if w0 > W0_STEP_MAX
153
+ @bandpass -= (w0 * @highpass) >> 20
154
+ @lowpass -= (w0 * @bandpass) >> 20
155
+ @highpass = ((@bandpass * @resonance) >> 10) - @lowpass - (@input << FRACTION)
156
+ @external.cycle!(fine_mix, cycles)
157
+ end
158
+
159
+ # The SID's own audio pin, before the board's RC network.
160
+ def mix = fine_mix >> FRACTION
161
+
162
+ def output = @external.output
163
+
164
+ private
165
+
166
+ def write_resonance(value)
167
+ @routing = value & 0x0f
168
+ @resonance = RESONANCE[(value >> 4) & 0x0f]
169
+ end
170
+
171
+ def write_mode_volume(value)
172
+ @volume = value & 0x0f
173
+ @mode = (value >> 4) & 0x07
174
+ @voice3_off = value.anybits?(0x80)
175
+ end
176
+
177
+ # Unrolled over the three voices, like SID#clock!. Voice 3 is silenced
178
+ # by MODE/VOL bit 7 only while it bypasses the filter.
179
+ def route(voices)
180
+ voice1, voice2, voice3 = voices
181
+ @input = 0
182
+ @unfiltered = 0
183
+ route_voice(voice1.output >> 7, 0x1)
184
+ route_voice(voice2.output >> 7, 0x2)
185
+ if @routing.anybits?(0x4)
186
+ @input += voice3.output >> 7
187
+ elsif !@voice3_off
188
+ @unfiltered += voice3.output >> 7
189
+ end
190
+ end
191
+
192
+ def route_voice(output, bit)
193
+ if @routing.anybits?(bit)
194
+ @input += output
195
+ else
196
+ @unfiltered += output
197
+ end
198
+ end
199
+
200
+ # The mix with the integrators' fractional bits kept, so the RC
201
+ # network isn't fed the filter's output floored to whole units.
202
+ def fine_mix = (((@unfiltered + @mixer_dc) << FRACTION) + filtered) * @volume
203
+
204
+ def filtered
205
+ value = 0
206
+ value += @lowpass if @mode.anybits?(0x1)
207
+ value += @bandpass if @mode.anybits?(0x2)
208
+ value += @highpass if @mode.anybits?(0x4)
209
+ value
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class SID
5
+ # One of the three voices: a waveform generator amplitude-modulated by
6
+ # its envelope.
7
+ #
8
+ # The 6581 waveform DAC swings around a midpoint well above ground, so
9
+ # even a silent voice carries a large DC offset into the mixer. The
10
+ # 8580's is centred and carries none.
11
+ class Voice
12
+ WAVE_ZERO = { mos6581: 0x380, mos8580: 0x800 }.freeze
13
+ DC_OFFSET = { mos6581: 0x800 * 0xff, mos8580: 0 }.freeze
14
+
15
+ attr_reader :waveform, :envelope
16
+
17
+ # Each voice hard-syncs and ring-modulates against the previous one,
18
+ # wrapping from the first back round to the last.
19
+ def self.linked(count, model:)
20
+ voices = Array.new(count) { new(model:) }
21
+ waveforms = voices.map(&:waveform)
22
+ waveforms.each_with_index do |waveform, i|
23
+ waveform.sync_source = waveforms[i - 1]
24
+ waveform.sync_dest = waveforms[(i + 1) % count]
25
+ end
26
+ voices
27
+ end
28
+
29
+ def initialize(model: :mos6581)
30
+ @waveform = Waveform.new(model:)
31
+ @envelope = Envelope.new
32
+ @wave_zero = WAVE_ZERO.fetch(model)
33
+ @dc_offset = DC_OFFSET.fetch(model)
34
+ end
35
+
36
+ def reset!
37
+ @waveform.reset!
38
+ @envelope = Envelope.new
39
+ end
40
+
41
+ def control=(value)
42
+ @waveform.control = value
43
+ @envelope.control = value
44
+ end
45
+
46
+ def cycle!
47
+ @envelope.cycle!
48
+ @waveform.cycle!
49
+ end
50
+
51
+ def fast_forward(cycles)
52
+ @envelope.fast_forward(cycles)
53
+ @waveform.fast_forward(cycles)
54
+ end
55
+
56
+ def output
57
+ ((@waveform.output - @wave_zero) * @envelope.output) + @dc_offset
58
+ end
59
+ end
60
+ end
61
+ end