badline 0.6.0 → 0.6.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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/CLAUDE.md +10 -9
  4. data/README.md +24 -10
  5. data/doc/pinned-behaviour.md +131 -30
  6. data/exe/badline-ruby +20 -24
  7. data/lib/badline/address_bus.rb +10 -1
  8. data/lib/badline/audio/cli.rb +18 -5
  9. data/lib/badline/audio/console.rb +2 -1
  10. data/lib/badline/audio.rb +0 -3
  11. data/lib/badline/computer.rb +6 -3
  12. data/lib/badline/cpu/boundary.rb +54 -0
  13. data/lib/badline/cpu/core.rb +165 -0
  14. data/lib/badline/cpu.rb +3 -214
  15. data/lib/badline/drive1541/cpu.rb +29 -0
  16. data/lib/badline/drive1541.rb +1 -0
  17. data/lib/badline/ffi.rb +157 -0
  18. data/lib/badline/frontend/app.rb +225 -0
  19. data/lib/badline/frontend/audio_sink.rb +72 -0
  20. data/lib/badline/frontend/boot.rb +52 -0
  21. data/lib/badline/frontend/controls.rb +163 -0
  22. data/lib/badline/frontend/drive_led.rb +51 -0
  23. data/lib/badline/frontend/frame_rate.rb +83 -0
  24. data/lib/badline/frontend/gamepads.rb +76 -0
  25. data/lib/badline/frontend/keys.rb +48 -0
  26. data/lib/badline/frontend/pacer.rb +100 -0
  27. data/lib/badline/frontend/pad_port.rb +61 -0
  28. data/lib/badline/frontend/screen.rb +65 -0
  29. data/lib/badline/frontend/screenshot.rb +22 -0
  30. data/lib/badline/frontend/snapshots.rb +60 -0
  31. data/lib/badline/frontend/sound.rb +153 -0
  32. data/lib/badline/frontend/timeline.rb +171 -0
  33. data/lib/badline/frontend.rb +18 -0
  34. data/lib/badline/options/event.rb +94 -0
  35. data/lib/badline/options/help.rb +78 -0
  36. data/lib/badline/options/option.rb +29 -0
  37. data/lib/badline/options/table.rb +62 -0
  38. data/lib/badline/options.rb +181 -145
  39. data/lib/badline/reu/dma/saved_state.rb +50 -0
  40. data/lib/badline/reu/dma.rb +56 -47
  41. data/lib/badline/reu/saved_state.rb +2 -1
  42. data/lib/badline/reu.rb +19 -10
  43. data/lib/badline/sdl.rb +123 -51
  44. data/lib/badline/sid/waveform/noise_writeback.rb +33 -11
  45. data/lib/badline/sid/waveform.rb +2 -3
  46. data/lib/badline/snapshot/state.rb +1 -1
  47. data/lib/badline/version.rb +1 -1
  48. data/lib/badline/vic/bank.rb +21 -0
  49. data/lib/badline/vic.rb +15 -6
  50. metadata +26 -19
  51. data/.claude/hooks/session-start.sh +0 -57
  52. data/.claude/settings.json +0 -14
  53. data/lib/badline/audio/sdl_sink.rb +0 -70
  54. data/lib/badline/audio/stream.rb +0 -123
  55. data/lib/badline/gui/application.rb +0 -243
  56. data/lib/badline/gui/drive_led_pane.rb +0 -29
  57. data/lib/badline/gui/gamepads.rb +0 -104
  58. data/lib/badline/gui/joy_map.rb +0 -27
  59. data/lib/badline/gui/key_map.rb +0 -39
  60. data/lib/badline/gui/palette.rb +0 -35
  61. data/lib/badline/gui/pane.rb +0 -36
  62. data/lib/badline/gui/screen_pane.rb +0 -46
  63. data/lib/badline/gui/snapshots.rb +0 -67
  64. data/lib/badline/gui/window.rb +0 -61
  65. data/lib/badline/gui.rb +0 -14
  66. data/lib/badline/sdl/events.rb +0 -49
  67. data/lib/badline/sdl/functions.rb +0 -74
  68. data/vendor/.gitkeep +0 -0
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The events of --at and --script. Each runs once its number of frames
6
+ # has run: a screenshot saves that frame, and the rest act on the
7
+ # machine before the next frame is clocked. A key or the freeze button
8
+ # is let go Options::Event::HOLD frames after it is pressed. An event
9
+ # that fails ends the run.
10
+ class Timeline
11
+ # Takes the events, --screenshot's at the last frame and whether to
12
+ # insert disks write-protected from Options.
13
+ def initialize(options)
14
+ @events = options.timeline
15
+ unless options.screenshot.empty? || options.frames.zero?
16
+ @events += [Options::Event.new(options.frames, "screenshot", options.screenshot)]
17
+ end
18
+ @read_only = options.read_only?
19
+ @program = options.program
20
+ @releases = []
21
+ @failed = false
22
+ end
23
+
24
+ # What's wrong with the events, the first key that names nothing,
25
+ # or an empty string.
26
+ def self.error(events)
27
+ event = events.find { |candidate| candidate.action == "key" && !key?(candidate.argument) }
28
+ event.nil? ? "" : "no such key: #{event.argument}"
29
+ end
30
+
31
+ def self.key?(name) = name == "restore" || !keyboard_key(name).nil? || !joystick_direction(name).nil?
32
+
33
+ # The Keyboard's key for a name such as space, return or a.
34
+ def self.keyboard_key(name)
35
+ key = Keyboard.new.matrix.flatten.find { |candidate| candidate.to_s == name }
36
+ key.nil? ? Keyboard::COMBINATIONS.keys.find { |candidate| candidate.to_s == name } : key
37
+ end
38
+
39
+ # The direction of joy1-NAME or joy2-NAME, such as up or fire.
40
+ def self.joystick_direction(name)
41
+ return nil unless name.start_with?("joy1-") || name.start_with?("joy2-")
42
+
43
+ Joystick::DIRECTIONS.keys.find { |direction| direction.to_s == name[5, name.size - 5] }
44
+ end
45
+
46
+ # The screenshot's file name, with the frame's number in place of %d,
47
+ # or of %Nd or %0Nd padded to N digits.
48
+ def self.path(pattern, frame)
49
+ start = pattern.index("%")
50
+ finish = start.nil? ? nil : pattern.index("d", start)
51
+ return pattern if finish.nil?
52
+
53
+ width = pattern[start + 1, finish - start - 1]
54
+ return pattern unless width.match?(/\A\d*\z/)
55
+
56
+ number = frame.to_s.rjust(width.to_i, width.start_with?("0") ? "0" : " ")
57
+ pattern[0, start] + number + pattern[finish + 1, pattern.size - finish - 1]
58
+ end
59
+
60
+ # The files the frame's screenshots go to.
61
+ def screenshots(frame)
62
+ @events.select { |event| event.frame == frame && event.action == "screenshot" }
63
+ .map { |event| Timeline.path(event.argument, frame) }
64
+ end
65
+
66
+ # Whether the run ends at the frame: at a quit, or once an event failed.
67
+ def quit?(frame) = @failed || @events.any? { |event| event.frame == frame && event.action == "quit" }
68
+
69
+ # Whether an event failed, which ends the run.
70
+ def failed? = @failed
71
+
72
+ # Lets go of what was held until this frame, then runs its events, up
73
+ # to one that fails.
74
+ def run(computer, frame)
75
+ @releases.each { |event| release(computer, event) if event.frame == frame }
76
+ @releases.reject! { |event| event.frame == frame }
77
+ @events.each { |event| perform(computer, event) if event.frame == frame && !@failed }
78
+ end
79
+
80
+ private
81
+
82
+ def perform(computer, event)
83
+ case event.action
84
+ when "key" then press(computer, event)
85
+ when "type" then computer.type_text(event.argument.gsub("\\n", "\r").gsub("\\r", "\r"))
86
+ when "insert" then insert(computer, event.argument)
87
+ when "eject" then eject(computer, event.argument)
88
+ when "reset" then computer.reset!
89
+ when "freeze" then freeze(computer, event)
90
+ end
91
+ end
92
+
93
+ def press(computer, event)
94
+ name = event.argument
95
+ if name == "restore"
96
+ computer.press_restore
97
+ return
98
+ end
99
+
100
+ direction = Timeline.joystick_direction(name)
101
+ if direction.nil?
102
+ computer.keyboard.press(Timeline.keyboard_key(name))
103
+ else
104
+ joystick(computer, name).press(direction)
105
+ end
106
+ hold(event)
107
+ end
108
+
109
+ def freeze(computer, event)
110
+ computer.press_cartridge_button
111
+ hold(event)
112
+ end
113
+
114
+ def hold(event)
115
+ @releases << Options::Event.new(event.frame + Options::Event::HOLD, event.action, event.argument)
116
+ end
117
+
118
+ def release(computer, event)
119
+ return computer.release_cartridge_button if event.action == "freeze"
120
+
121
+ name = event.argument
122
+ direction = Timeline.joystick_direction(name)
123
+ if direction.nil?
124
+ computer.keyboard.release(Timeline.keyboard_key(name))
125
+ else
126
+ joystick(computer, name).release(direction)
127
+ end
128
+ end
129
+
130
+ def joystick(computer, name) = name.start_with?("joy1-") ? computer.joystick1 : computer.joystick2
131
+
132
+ def insert(computer, path)
133
+ puts Media.attach(computer, path, autostart: false, disk: { read_only: @read_only })
134
+ rescue StandardError => e
135
+ warn "#{@program}: #{path}: #{e.message}"
136
+ @failed = true
137
+ end
138
+
139
+ def eject(computer, what)
140
+ return puts "No #{what} to eject" unless inserted?(computer, what)
141
+
142
+ if what == "disk"
143
+ eject_disk(computer)
144
+ elsif what == "tape"
145
+ computer.datasette.eject
146
+ else
147
+ computer.address_bus.detach_cartridge
148
+ computer.power_cycle!
149
+ end
150
+ puts "Ejected the #{what}"
151
+ end
152
+
153
+ def inserted?(computer, what)
154
+ return !computer.datasette.tape.nil? if what == "tape"
155
+ return !computer.address_bus.cartridge.nil? if what == "cartridge"
156
+
157
+ drive = Media::TrueDrive.drive(computer)
158
+ drive.nil? ? computer.mounted? : !drive.disk.nil?
159
+ end
160
+
161
+ def eject_disk(computer)
162
+ drive = Media::TrueDrive.drive(computer)
163
+ if drive.nil?
164
+ computer.unmount
165
+ else
166
+ drive.insert(nil)
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "badline/sdl"
4
+ require "badline/frontend/boot"
5
+ require "badline/frontend/frame_rate"
6
+ require "badline/frontend/screen"
7
+ require "badline/frontend/drive_led"
8
+ require "badline/frontend/keys"
9
+ require "badline/frontend/snapshots"
10
+ require "badline/frontend/screenshot"
11
+ require "badline/frontend/timeline"
12
+ require "badline/frontend/sound"
13
+ require "badline/frontend/pacer"
14
+ require "badline/frontend/controls"
15
+ require "badline/frontend/pad_port"
16
+ require "badline/frontend/gamepads"
17
+ require "badline/frontend/app"
18
+ require "badline/frontend/audio_sink"
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class Options
5
+ # One event of --at or --script: what to do, with its argument, once
6
+ # `frame` frames have run. An event given at several frames becomes
7
+ # one Event per frame.
8
+ class Event
9
+ # The actions, and whether each takes an argument.
10
+ ACTIONS = {
11
+ "key" => true, "type" => true, "insert" => true, "eject" => true, "screenshot" => true,
12
+ "reset" => false, "freeze" => false, "quit" => false
13
+ }.freeze
14
+
15
+ # The frames a key or the freeze button is held for.
16
+ HOLD = 5
17
+
18
+ EJECTABLE = %w[disk tape cartridge].freeze
19
+
20
+ # What insert takes: disk images, tapes and cartridges, or a
21
+ # directory to mount as device 8.
22
+ INSERTABLE = %w[.d64 .d71 .d81 .g64 .t64 .tap .crt].freeze
23
+
24
+ attr_reader :frame, :action, :argument
25
+
26
+ def initialize(frame, action, argument)
27
+ @frame = frame
28
+ @action = action
29
+ @argument = argument
30
+ end
31
+
32
+ # The events of --at's value, or of --script's file.
33
+ def self.given(flag, value)
34
+ return script(value) if flag == "--script"
35
+
36
+ events = parse(value)
37
+ raise Error, "invalid argument: #{flag} #{value}" if events.empty?
38
+
39
+ events
40
+ end
41
+
42
+ # The events of a --script file, one a line in --at's form. Blank
43
+ # lines and lines starting with # are skipped.
44
+ def self.script(path)
45
+ raise Error, "no such file: #{path}" unless File.file?(path)
46
+
47
+ events = []
48
+ File.foreach(path) do |line|
49
+ text = line.strip
50
+ events.concat(given("--script #{path}:", text)) unless text.empty? || text.start_with?("#")
51
+ end
52
+ events
53
+ end
54
+
55
+ # The events of `FRAME[,FRAME...]:ACTION[=ARGUMENT]`, or an empty list
56
+ # when the text isn't one.
57
+ def self.parse(text)
58
+ colon = text.index(":")
59
+ numbers = colon.nil? ? [] : frames(text[0, colon])
60
+ return [] if numbers.empty?
61
+
62
+ event = text[colon + 1, text.size - colon - 1]
63
+ equals = event.index("=")
64
+ action = equals.nil? ? event : event[0, equals]
65
+ argument = equals.nil? ? "" : event[equals + 1, event.size - equals - 1]
66
+ return [] unless valid?(action, argument)
67
+
68
+ numbers.map { |frame| new(frame, action, argument) }
69
+ end
70
+
71
+ # The frame numbers of `FRAME[,FRAME...]`, or none unless each is a
72
+ # positive number.
73
+ def self.frames(text)
74
+ numbers = text.split(",")
75
+ return [] unless !numbers.empty? && numbers.all? { |number| number.match?(/\A\d+\z/) && number.to_i.positive? }
76
+
77
+ numbers.map(&:to_i)
78
+ end
79
+
80
+ def self.valid?(action, argument)
81
+ return false unless ACTIONS.key?(action)
82
+ return argument.empty? unless ACTIONS[action]
83
+ return EJECTABLE.include?(argument) if action == "eject"
84
+ return insertable?(argument) if action == "insert"
85
+
86
+ !argument.empty?
87
+ end
88
+
89
+ def self.insertable?(path)
90
+ File.directory?(path) || (File.exist?(path) && INSERTABLE.include?(File.extname(path).downcase))
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class Options
5
+ # How fast the headless player runs, which the builds' help tells apart.
6
+ NATIVE_SPEED = <<~TEXT.chomp
7
+ Without the window, PSID tunes run on a bare CPU and SID, and RSID
8
+ tunes boot the whole machine. --filter-chunk 1 runs the filter
9
+ cycle by cycle, which is exact but slower.
10
+ TEXT
11
+
12
+ RUBY_SPEED = <<~TEXT.chomp
13
+ Without the window, PSID tunes run on a bare CPU and SID, faster than
14
+ real time. RSID tunes boot the whole machine and run at about half
15
+ real time, so they stutter when played. --filter-chunk 1 runs the
16
+ filter cycle by cycle, which is exact but takes about twice as long.
17
+ TEXT
18
+
19
+ # What --help prints: the usage, then the options this build takes,
20
+ # section by section, laid out as OptionParser did.
21
+ def help
22
+ lines = [banner]
23
+ SECTIONS.each do |section|
24
+ options = TABLE.select { |option| option.section == section && takes?(option) }
25
+ next if options.empty?
26
+
27
+ lines << "" if lines.size > 1
28
+ lines << "#{section}:"
29
+ options.each { |option| lines << " #{option.usage.ljust(32)} #{describe(option)}" }
30
+ end
31
+ "#{lines.join("\n")}\n"
32
+ end
33
+
34
+ private
35
+
36
+ def describe(option) = @native && option.name == "--sound" ? "#{option.text} (default)" : option.text
37
+
38
+ def banner
39
+ <<~BANNER
40
+ Usage: #{@program} [options] [media]
41
+ #{@program} --headless [options] tune.sid
42
+ #{@program} [options] tune.sid --audio-out FILE
43
+
44
+ Media can be a .prg/.p00 program, a .d64/.d71/.d81 disk image, a
45
+ .g64 disk image for the true 1541, a .t64 tape archive, a .tap
46
+ tape, a .crt cartridge, a .sid tune, a .vsf snapshot, or a directory
47
+ to mount as device 8. It opens in the emulator window, where F11
48
+ saves a snapshot and F12 restores it. Device 8 answers through
49
+ traps on the KERNAL's disk routines, unless --true-drive puts an
50
+ emulated 1541 there, which runs its own DOS and reads .d64 and .g64
51
+ images only.
52
+
53
+ --headless plays a .sid tune on the host's audio device without the
54
+ window, and --audio-out renders it to 16-bit PCM instead. The
55
+ container follows the file's extension, .wav or .aiff. Played on a
56
+ terminal, n or → skips to the next song, p or ← to the previous one,
57
+ space pauses and q quits.
58
+
59
+ A .sid file carries no length of its own. Without --seconds the tune is
60
+ looked up by MD5 in HVSC's Songlengths.md5, taken from --songlengths, from
61
+ the DOCUMENTS directory of an HVSC collection above the tune, or from
62
+ $HVSC_BASE. Failing all of those it runs for #{FALLBACK_SECONDS.to_i} seconds.
63
+
64
+ #{@native ? NATIVE_SPEED : RUBY_SPEED}
65
+
66
+ --at and --script run events once a number of frames has run, as
67
+ in --at 3500:key=space or --at 250,500:screenshot=shot%05d.bmp.
68
+ key=NAME presses a C64 key (space, return, a, f1, run_stop, restore)
69
+ or a joystick's direction or fire (joy1-up, joy2-fire) for #{Event::HOLD}
70
+ frames. type=TEXT types it, with \\n for RETURN. insert=FILE swaps
71
+ in a disk, tape or cartridge, and eject=disk, tape or cartridge
72
+ takes one out. screenshot=FILE saves the frame as a .bmp, with the
73
+ frame's number in place of %d. reset, freeze and quit take no
74
+ argument.
75
+ BANNER
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class Options
5
+ # One option of the table: its flags and value as the help shows them
6
+ # (`-s, --song N`), what it does, the help section it's listed in, the
7
+ # mode it needs (:window, :headless or :either), and the build that
8
+ # takes it (:native, :ruby or :both).
9
+ class Option
10
+ attr_reader :usage, :name, :short, :value, :text, :section, :needs, :build
11
+
12
+ def initialize(usage, text, section:, needs: :either, build: :both)
13
+ @usage = usage.start_with?("--") ? " #{usage}" : usage
14
+ words = usage.split
15
+ @short = words.size > 1 && words[1].start_with?("--") ? words[0].delete_suffix(",") : ""
16
+ @name = @short.empty? ? words[0] : words[1]
17
+ @value = words.last.start_with?("-") ? "" : words.last
18
+ @text = text
19
+ @section = section
20
+ @needs = needs
21
+ @build = build
22
+ end
23
+
24
+ def valued? = !@value.empty?
25
+
26
+ def named?(flag) = flag == @name || (!@short.empty? && flag == @short)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class Options
5
+ DEFAULT_RATE = 44_100
6
+
7
+ SECTIONS = ["Options", "Window options", "Options without the window", "Testing options"].freeze
8
+
9
+ # Every option of both builds, in the order the help lists them.
10
+ TABLE = [
11
+ Option.new("-s, --song N", "Subtune of a .sid, from 1 (default: the tune's own)", section: "Options"),
12
+ Option.new("--sid MODEL", "SID to fit: 6581 or 8580 (default: a .sid tune's own, else 6581)",
13
+ section: "Options"),
14
+ Option.new("--disable-jit", "Run without enabling YJIT", section: "Options", build: :ruby),
15
+ Option.new("-h, --help", "Show this help", section: "Options"),
16
+ Option.new("--version", "Show the version and what built it", section: "Options", build: :native),
17
+ Option.new("--no-autostart", "Boot to READY. instead of running the program",
18
+ section: "Window options", needs: :window),
19
+ Option.new("--read-only", "Mount a disk image write-protected, leaving its file unchanged",
20
+ section: "Window options", needs: :window),
21
+ Option.new("--sound", "Play the SID through the host's audio device (F10 mutes)",
22
+ section: "Window options", needs: :window),
23
+ Option.new("--no-sound", "Don't play the SID", section: "Window options", needs: :window, build: :native),
24
+ Option.new("--true-drive", "Put a true 1541 on device 8 instead of the KERNAL traps",
25
+ section: "Window options", needs: :window),
26
+ Option.new("--reu SIZE", "Plug in an REU of SIZE K: 128, 256, 512 (a 1750) or up to 16384",
27
+ section: "Window options", needs: :window),
28
+ Option.new("--ntsc", "Run an NTSC C64, with the 6567R8 VIC-II, instead of a PAL one",
29
+ section: "Window options", needs: :window),
30
+ Option.new("--no-vsync", "Pace frames by the timer or the sound instead of the display",
31
+ section: "Window options", needs: :window),
32
+ Option.new("--verbose", "Print the display, sound and gamepad setup and the frame timing",
33
+ section: "Window options", needs: :window),
34
+ Option.new("--headless", "Play a .sid tune in the terminal instead of the window",
35
+ section: "Options without the window"),
36
+ Option.new("--audio-out FILE", "Render a .sid tune to a .wav or .aiff file",
37
+ section: "Options without the window"),
38
+ Option.new("--seconds N", "Length to play (default: the tune's own)",
39
+ section: "Options without the window", needs: :headless),
40
+ Option.new("--songlengths PATH", "HVSC Songlengths.md5 to take the length from",
41
+ section: "Options without the window", needs: :headless),
42
+ Option.new("--rate HZ", "Sample rate (default: #{DEFAULT_RATE}, or the audio device's own)",
43
+ section: "Options without the window", needs: :headless),
44
+ Option.new("--filter-chunk N", "Filter step in cycles, 1 is exact (default: 4)",
45
+ section: "Options without the window", needs: :headless),
46
+ Option.new("--quiet", "Don't report progress", section: "Options without the window", needs: :headless),
47
+ Option.new("--no-tui", "Play without the interactive display, even on a terminal",
48
+ section: "Options without the window", needs: :headless),
49
+ Option.new("--frames N", "Quit after N frames", section: "Testing options", needs: :window),
50
+ Option.new("--unpaced", "Run as fast as it can, without vsync or pacing",
51
+ section: "Testing options", needs: :window),
52
+ Option.new("--screenshot FILE", "Save the last frame as a .bmp",
53
+ section: "Testing options", needs: :window),
54
+ Option.new("--save-snapshot FILE", "Save the machine as a .vsf snapshot after the last frame",
55
+ section: "Testing options", needs: :window),
56
+ Option.new("--at FRAME:EVENT", "Run EVENT once FRAME frames have run (see above)",
57
+ section: "Testing options", needs: :window),
58
+ Option.new("--script FILE", "Run the events in FILE, one FRAME:EVENT a line",
59
+ section: "Testing options", needs: :window)
60
+ ].freeze
61
+ end
62
+ end