badline 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -0
- data/CLAUDE.md +3 -1
- data/README.md +132 -247
- data/doc/library.md +62 -0
- data/doc/media.md +69 -0
- data/doc/sid-player.md +132 -0
- data/doc/snapshots.md +69 -0
- data/exe/badline-ruby +9 -1
- data/lib/badline/address_bus/extra_sids.rb +30 -0
- data/lib/badline/address_bus/sid_slots.rb +42 -0
- data/lib/badline/address_bus.rb +5 -0
- data/lib/badline/audio/aiff.rb +2 -2
- data/lib/badline/audio/bare_player.rb +23 -12
- data/lib/badline/audio/cli.rb +153 -58
- data/lib/badline/audio/jukebox.rb +121 -36
- data/lib/badline/audio/machine_player.rb +25 -10
- data/lib/badline/audio/pcm_writer.rb +10 -6
- data/lib/badline/audio/playback.rb +6 -5
- data/lib/badline/audio/queued_tune.rb +89 -0
- data/lib/badline/audio/renderer.rb +57 -10
- data/lib/badline/audio/stereo.rb +97 -0
- data/lib/badline/audio/terminal.rb +39 -6
- data/lib/badline/audio/wav.rb +1 -1
- data/lib/badline/audio.rb +2 -0
- data/lib/badline/cartridge/freezer.rb +2 -0
- data/lib/badline/cartridge.rb +3 -0
- data/lib/badline/frontend/app.rb +38 -35
- data/lib/badline/frontend/audio_sink.rb +6 -5
- data/lib/badline/frontend/boot.rb +9 -2
- data/lib/badline/frontend/buttons.rb +184 -0
- data/lib/badline/frontend/confirmation.rb +50 -0
- data/lib/badline/frontend/controls.rb +44 -32
- data/lib/badline/frontend/file_browser.rb +225 -0
- data/lib/badline/frontend/filter_view.rb +96 -0
- data/lib/badline/frontend/frame_report.rb +52 -0
- data/lib/badline/frontend/info_view.rb +120 -0
- data/lib/badline/frontend/menu_dialogs.rb +122 -0
- data/lib/badline/frontend/menu_media.rb +128 -0
- data/lib/badline/frontend/menu_pages.rb +198 -0
- data/lib/badline/frontend/painter.rb +185 -0
- data/lib/badline/frontend/pause_menu.rb +246 -0
- data/lib/badline/frontend/player_commands.rb +33 -0
- data/lib/badline/frontend/player_footer.rb +86 -0
- data/lib/badline/frontend/player_header.rb +60 -0
- data/lib/badline/frontend/player_screen.rb +124 -0
- data/lib/badline/frontend/player_state.rb +67 -0
- data/lib/badline/frontend/player_window.rb +228 -0
- data/lib/badline/frontend/scope.rb +42 -0
- data/lib/badline/frontend/sid_history.rb +175 -0
- data/lib/badline/frontend/sid_view.rb +140 -0
- data/lib/badline/frontend/snapshots.rb +1 -1
- data/lib/badline/frontend/sound.rb +0 -1
- data/lib/badline/frontend/timeline.rb +5 -1
- data/lib/badline/frontend/visualizer_view.rb +101 -0
- data/lib/badline/frontend.rb +21 -0
- data/lib/badline/media/disk_set.rb +108 -0
- data/lib/badline/media/queue.rb +158 -0
- data/lib/badline/media.rb +14 -8
- data/lib/badline/options/help.rb +34 -5
- data/lib/badline/options/option.rb +1 -1
- data/lib/badline/options/table.rb +5 -3
- data/lib/badline/options/validation.rb +54 -0
- data/lib/badline/options.rb +65 -46
- data/lib/badline/sdl.rb +16 -6
- data/lib/badline/sid/catch_up.rb +11 -1
- data/lib/badline/sid/filter.rb +9 -0
- data/lib/badline/sid/voice.rb +6 -0
- data/lib/badline/sid/waveform.rb +20 -5
- data/lib/badline/sid.rb +35 -3
- data/lib/badline/storage/hvsc.rb +41 -0
- data/lib/badline/storage/sid_file.rb +45 -25
- data/lib/badline/storage/song_lengths.rb +6 -29
- data/lib/badline/storage/stil/credit.rb +50 -0
- data/lib/badline/storage/stil.rb +126 -0
- data/lib/badline/storage.rb +8 -0
- data/lib/badline/version.rb +1 -1
- metadata +36 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The --verbose report every 50 frames: the frame rate, the time each
|
|
6
|
+
# stage of a frame took on average, the slowest frame's work, and how
|
|
7
|
+
# the sound's queue fared.
|
|
8
|
+
class FrameReport
|
|
9
|
+
STAGES = %w[events emulate audio blit present wait].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(sound)
|
|
12
|
+
@sound = sound
|
|
13
|
+
@reported_samples = 0
|
|
14
|
+
clear
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Adds a frame's stamps, one as each stage starts and one after the
|
|
18
|
+
# last.
|
|
19
|
+
def add(stamps)
|
|
20
|
+
STAGES.size.times { |stage| @spent[stage] += stamps[stage + 1] - stamps[stage] }
|
|
21
|
+
took = stamps[-2] - stamps.first
|
|
22
|
+
@slowest = took if took > @slowest
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Reports the 50 frames added over the last `seconds`, and starts
|
|
26
|
+
# over.
|
|
27
|
+
def show(seconds)
|
|
28
|
+
stages = STAGES.each_with_index.map { |name, stage| "#{name} #{(@spent[stage] * 20).round(2)}" }
|
|
29
|
+
puts "#{(50 / seconds).round(1)} fps, per frame ms: #{stages.join(' ')}, " \
|
|
30
|
+
"slowest work #{(@slowest * 1000).round(2)}"
|
|
31
|
+
show_sound(seconds) if @sound.on?
|
|
32
|
+
clear
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def clear
|
|
38
|
+
@spent = Array.new(STAGES.size, 0.0)
|
|
39
|
+
@slowest = 0.0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def show_sound(seconds)
|
|
43
|
+
sound = @sound
|
|
44
|
+
rate = (sound.queued - @reported_samples) / seconds
|
|
45
|
+
queue = sound.high.zero? ? "empty" : "#{(sound.low * 1000).round(1)}-#{(sound.high * 1000).round(1)} ms"
|
|
46
|
+
puts " sound #{rate.round} samples/s, queue #{queue}, #{sound.underruns} underruns, #{sound.dropped} dropped"
|
|
47
|
+
sound.reset_levels
|
|
48
|
+
@reported_samples = sound.queued
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The SID player's INFO view: the tune's STIL entry, and the playing
|
|
6
|
+
# subtune's, laid out as STIL.txt lays them out but reflowed to the
|
|
7
|
+
# window's width. A line of STIL's that runs to its width goes on into
|
|
8
|
+
# the next, and a shorter one ends a paragraph. Text longer than the view
|
|
9
|
+
# scrolls, by the wheel, the up and down keys, or a click or drag along
|
|
10
|
+
# the scroll bar beside it.
|
|
11
|
+
class InfoView
|
|
12
|
+
HEIGHT = VisualizerView::HEIGHT
|
|
13
|
+
LINE = 10
|
|
14
|
+
ROWS = HEIGHT / LINE
|
|
15
|
+
LEFT = SIDView::LEFT
|
|
16
|
+
BAR = 6
|
|
17
|
+
BAR_LEFT = PlayerHeader::WIDTH - 16 - BAR
|
|
18
|
+
COLUMNS = (BAR_LEFT - LEFT - 8) / Painter::GLYPH
|
|
19
|
+
LABEL = 9
|
|
20
|
+
|
|
21
|
+
# How long a line of STIL's is when it runs on into the next.
|
|
22
|
+
FULL = 60
|
|
23
|
+
|
|
24
|
+
attr_reader :offset
|
|
25
|
+
|
|
26
|
+
def initialize(painter, buttons, top)
|
|
27
|
+
@painter = painter
|
|
28
|
+
@buttons = buttons
|
|
29
|
+
@top = top
|
|
30
|
+
@lines = []
|
|
31
|
+
@offset = 0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Shows the tune's fields, then the subtune's under its number, from
|
|
35
|
+
# the top.
|
|
36
|
+
def show(fields, subtune, subtune_fields)
|
|
37
|
+
lines = InfoView.layout(fields)
|
|
38
|
+
lines += ["", "(##{subtune})"] + InfoView.layout(subtune_fields) unless subtune_fields.empty?
|
|
39
|
+
@lines = lines.empty? ? ["No STIL entry for this tune."] : lines
|
|
40
|
+
@offset = 0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def scroll(rows)
|
|
44
|
+
@offset = (@offset + rows).clamp(0, last_offset)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Scrolls to the place `top` points at along the scroll bar.
|
|
48
|
+
def scroll_along(top)
|
|
49
|
+
@offset = ((top - @top).to_f / HEIGHT * @lines.size).round.clamp(0, last_offset)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def draw
|
|
53
|
+
index = 0
|
|
54
|
+
while index < ROWS && @offset + index < @lines.size
|
|
55
|
+
@painter.text(LEFT, @top + (index * LINE), @lines[@offset + index], SIDView::TEXT)
|
|
56
|
+
index += 1
|
|
57
|
+
end
|
|
58
|
+
draw_bar if @lines.size > ROWS
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Each field's label, right-aligned as in STIL.txt, before its text
|
|
62
|
+
# reflowed into the columns beside it.
|
|
63
|
+
def self.layout(fields)
|
|
64
|
+
lines = []
|
|
65
|
+
fields.each do |field|
|
|
66
|
+
label = "#{field.name}:".rjust(LABEL - 1)
|
|
67
|
+
paragraphs(field.text).each do |paragraph|
|
|
68
|
+
wrap(paragraph, COLUMNS - LABEL).each do |line|
|
|
69
|
+
lines << "#{label} #{line}"
|
|
70
|
+
label = " " * (LABEL - 1)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
lines
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The text's lines joined where one ran to STIL's width.
|
|
78
|
+
def self.paragraphs(text)
|
|
79
|
+
paragraphs = []
|
|
80
|
+
full = false
|
|
81
|
+
text.split("\n").each do |line|
|
|
82
|
+
if full
|
|
83
|
+
paragraphs[-1] = "#{paragraphs[-1]} #{line.strip}"
|
|
84
|
+
else
|
|
85
|
+
paragraphs << line.strip
|
|
86
|
+
end
|
|
87
|
+
full = line.length >= FULL
|
|
88
|
+
end
|
|
89
|
+
paragraphs
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The text broken at spaces into lines of at most `columns`, and a word
|
|
93
|
+
# longer than that broken where it has to be.
|
|
94
|
+
def self.wrap(text, columns)
|
|
95
|
+
lines = []
|
|
96
|
+
rest = text
|
|
97
|
+
while rest.length > columns
|
|
98
|
+
cut = rest.rindex(" ", columns)
|
|
99
|
+
cut = columns if cut.nil? || cut.zero?
|
|
100
|
+
lines << rest[0, cut].rstrip
|
|
101
|
+
rest = rest[cut, rest.length - cut].lstrip
|
|
102
|
+
end
|
|
103
|
+
lines << rest
|
|
104
|
+
lines
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def last_offset = [@lines.size - ROWS, 0].max
|
|
110
|
+
|
|
111
|
+
def draw_bar
|
|
112
|
+
@buttons.area([BAR_LEFT, @top, BAR, HEIGHT], :scroll)
|
|
113
|
+
@painter.box(BAR_LEFT, @top, BAR, HEIGHT, SIDView::BOX)
|
|
114
|
+
thumb = [HEIGHT * ROWS / @lines.size, 8].max
|
|
115
|
+
place = (HEIGHT - thumb) * @offset / [last_offset, 1].max
|
|
116
|
+
@painter.box(BAR_LEFT, @top + place, BAR, thumb, SIDView::TEXT)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The pause menu's screens that take over its panel: the file browser
|
|
6
|
+
# INSERT and Quick open bring up, and the question before a cartridge
|
|
7
|
+
# or Quick open power cycles the machine. A disk or tape picked goes
|
|
8
|
+
# straight in.
|
|
9
|
+
class MenuDialogs
|
|
10
|
+
EXTENSIONS = {
|
|
11
|
+
disk: PauseMenu::DISKS, tape: %w[.tap], cartridge: %w[.crt],
|
|
12
|
+
program: %w[.d64 .d71 .d81 .g64 .t64 .tap .crt .prg .p00 .sid]
|
|
13
|
+
}.freeze
|
|
14
|
+
TITLES = { disk: "INSERT A DISK", tape: "INSERT A TAPE", cartridge: "INSERT A CARTRIDGE",
|
|
15
|
+
program: "QUICK OPEN" }.freeze
|
|
16
|
+
|
|
17
|
+
ESCAPE = 41
|
|
18
|
+
RETURN = 40
|
|
19
|
+
SPACE = 44
|
|
20
|
+
|
|
21
|
+
# The machine Quick open started.
|
|
22
|
+
attr_reader :computer
|
|
23
|
+
|
|
24
|
+
def initialize(painter, buttons, media, options)
|
|
25
|
+
media_path = options.media_path.to_s
|
|
26
|
+
@buttons = buttons
|
|
27
|
+
@media = media
|
|
28
|
+
@options = options
|
|
29
|
+
@browser = FileBrowser.new(painter, buttons)
|
|
30
|
+
@confirmation = Confirmation.new(painter, buttons)
|
|
31
|
+
@browsing = nil
|
|
32
|
+
@browse_from = media_path.empty? ? Dir.pwd : File.dirname(File.expand_path(media_path))
|
|
33
|
+
@computer = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def open? = !@browsing.nil? || @confirmation.open?
|
|
37
|
+
|
|
38
|
+
# Opens the browser on the files for `kind`: :disk, :tape, :cartridge
|
|
39
|
+
# or :program.
|
|
40
|
+
def browse(kind)
|
|
41
|
+
@browsing = kind
|
|
42
|
+
@browser.open(@browse_from, EXTENSIONS.fetch(kind))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def ask_remove = @confirmation.ask(:remove, @media.cartridge_name)
|
|
46
|
+
|
|
47
|
+
# Puts a dropped disk or tape in its device, or asks before a
|
|
48
|
+
# cartridge or a program starts, and says whether it asks. Other
|
|
49
|
+
# files are left alone.
|
|
50
|
+
def drop(path)
|
|
51
|
+
extension = File.extname(path).downcase
|
|
52
|
+
return @media.insert(:disk, path) && false if EXTENSIONS[:disk].include?(extension)
|
|
53
|
+
return @media.insert(:tape, path) && false if EXTENSIONS[:tape].include?(extension)
|
|
54
|
+
return false unless EXTENSIONS[:program].include?(extension)
|
|
55
|
+
|
|
56
|
+
@confirmation.ask(extension == ".crt" ? :cartridge : :start, path)
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Handles a key, and returns :resume or :swap once a question is
|
|
61
|
+
# answered yes, or nil.
|
|
62
|
+
def key(scancode)
|
|
63
|
+
return browsed(@browser.key(scancode)) unless @browsing.nil?
|
|
64
|
+
return answer(:cancel) if scancode == ESCAPE
|
|
65
|
+
return answer(@buttons.focus) if [RETURN, SPACE].include?(scancode)
|
|
66
|
+
|
|
67
|
+
direction = PauseMenu::ARROWS[scancode]
|
|
68
|
+
@buttons.shift(direction == :up ? -1 : 1, 1) if %i[up down].include?(direction)
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Handles a click on the browser's list at `top`, or on a question's
|
|
73
|
+
# answer, `action`.
|
|
74
|
+
def click(action, top) = action == :browse ? browsed(@browser.click(top)) : answer(action)
|
|
75
|
+
|
|
76
|
+
def scroll(rows) = @browser.scroll(rows)
|
|
77
|
+
|
|
78
|
+
def draw(left, top, width)
|
|
79
|
+
return @browser.draw(left, top, width, TITLES.fetch(@browsing)) unless @browsing.nil?
|
|
80
|
+
|
|
81
|
+
@confirmation.draw(left, top)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# Puts the disk or tape picked in, or asks before a cartridge or
|
|
87
|
+
# Quick open power cycles the machine, and leaves the browser, as Esc
|
|
88
|
+
# does.
|
|
89
|
+
def browsed(picked)
|
|
90
|
+
return nil if picked.nil?
|
|
91
|
+
|
|
92
|
+
kind = @browsing
|
|
93
|
+
@browse_from = @browser.directory
|
|
94
|
+
@browsing = nil
|
|
95
|
+
return nil if picked == :cancel
|
|
96
|
+
|
|
97
|
+
if %i[disk tape].include?(kind)
|
|
98
|
+
@media.insert(kind, picked)
|
|
99
|
+
else
|
|
100
|
+
@confirmation.ask(kind == :cartridge ? :cartridge : :start, picked)
|
|
101
|
+
end
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Does what the question asked about if `action` is :confirm, and
|
|
106
|
+
# returns :resume, or :swap for a new machine.
|
|
107
|
+
def answer(action)
|
|
108
|
+
question = @confirmation.close
|
|
109
|
+
return nil unless action == :confirm
|
|
110
|
+
|
|
111
|
+
path = question[1]
|
|
112
|
+
case question[0]
|
|
113
|
+
when :cartridge then @media.attach_cartridge(path) ? :resume : nil
|
|
114
|
+
when :remove then @media.remove_cartridge && :resume
|
|
115
|
+
else
|
|
116
|
+
@computer = @media.start(@options, path)
|
|
117
|
+
@computer.nil? ? nil : :swap
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# What the pause menu puts in the machine and takes out: disks, tapes
|
|
6
|
+
# and cartridges, whether disks are writable, which carries over from
|
|
7
|
+
# one disk to the next, and the disk's set, which PREVIOUS and NEXT step
|
|
8
|
+
# through. It keeps what last went wrong for the page to show.
|
|
9
|
+
class MenuMedia
|
|
10
|
+
attr_reader :disk_path, :problem
|
|
11
|
+
attr_writer :computer
|
|
12
|
+
|
|
13
|
+
def initialize(disk_path, writable)
|
|
14
|
+
@disk_path = disk_path
|
|
15
|
+
@writable = writable
|
|
16
|
+
@set_path = ""
|
|
17
|
+
@set = []
|
|
18
|
+
@problem = ""
|
|
19
|
+
@computer = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def writable? = @writable
|
|
23
|
+
|
|
24
|
+
def inserted?
|
|
25
|
+
drive = @computer.drive1541
|
|
26
|
+
drive.nil? ? @computer.mounted? : !drive.disk.nil?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Puts a disk in device 8 or a tape in the datasette, `kind` :disk or
|
|
30
|
+
# :tape.
|
|
31
|
+
def insert(kind, path)
|
|
32
|
+
@problem = ""
|
|
33
|
+
if kind == :disk
|
|
34
|
+
Media.insert_disk(@computer, path, read_only: !@writable)
|
|
35
|
+
@disk_path = path
|
|
36
|
+
else
|
|
37
|
+
@computer.datasette.insert(Storage::TAP.new(path))
|
|
38
|
+
end
|
|
39
|
+
rescue ArgumentError, SystemCallError, Storage::TAP::FormatError, Storage::T64::FormatError,
|
|
40
|
+
Storage::G64Image::FormatError => e
|
|
41
|
+
@problem = e.message
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Does what the Drive page's :eject_disk, :previous_disk, :next_disk,
|
|
45
|
+
# :protect or :unprotect asks.
|
|
46
|
+
def drive(action)
|
|
47
|
+
case action
|
|
48
|
+
when :eject_disk then eject_disk
|
|
49
|
+
when :previous_disk then step_disk(-1)
|
|
50
|
+
when :next_disk then step_disk(1)
|
|
51
|
+
else writable(action == :unprotect)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def eject_disk
|
|
56
|
+
drive = @computer.drive1541
|
|
57
|
+
drive.nil? ? @computer.unmount : drive.insert(nil)
|
|
58
|
+
@disk_path = ""
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Sets whether disks are writable, for the disk in the drive too,
|
|
62
|
+
# which goes in again as the notch now says.
|
|
63
|
+
def writable(writable)
|
|
64
|
+
@writable = writable
|
|
65
|
+
insert(:disk, @disk_path) if inserted? && !@disk_path.empty?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The disks of the set the disk in the drive belongs to.
|
|
69
|
+
def disk_set
|
|
70
|
+
return [] if @disk_path.empty?
|
|
71
|
+
|
|
72
|
+
unless @set_path == @disk_path
|
|
73
|
+
@set = Media::DiskSet.around(@disk_path)
|
|
74
|
+
@set_path = @disk_path
|
|
75
|
+
end
|
|
76
|
+
@set
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Puts the disk `step` before or after this one in its set in the
|
|
80
|
+
# drive, if there is one.
|
|
81
|
+
def step_disk(step)
|
|
82
|
+
set = disk_set
|
|
83
|
+
index = set.index(@disk_path)
|
|
84
|
+
return if index.nil? || !(index + step).between?(0, set.size - 1)
|
|
85
|
+
|
|
86
|
+
insert(:disk, set[index + step])
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Swaps the cartridge for the one at `path`, which power cycles the
|
|
90
|
+
# machine, and says whether it went in.
|
|
91
|
+
def attach_cartridge(path)
|
|
92
|
+
@problem = ""
|
|
93
|
+
Media.attach(@computer, path)
|
|
94
|
+
true
|
|
95
|
+
rescue ArgumentError, SystemCallError, Storage::CRTFile::FormatError, Cartridge::UnsupportedTypeError => e
|
|
96
|
+
@problem = e.message
|
|
97
|
+
false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The name the cartridge in the expansion port gives itself, or an
|
|
101
|
+
# empty one.
|
|
102
|
+
def cartridge_name
|
|
103
|
+
cartridge = @computer.address_bus.cartridge
|
|
104
|
+
cartridge.nil? ? "" : cartridge.name
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Takes the cartridge out with the power off.
|
|
108
|
+
def remove_cartridge
|
|
109
|
+
@computer.address_bus.detach_cartridge
|
|
110
|
+
@computer.power_cycle!
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# A new machine started on `path`, as the command line would, or nil
|
|
114
|
+
# when it won't start.
|
|
115
|
+
def start(options, path)
|
|
116
|
+
@problem = ""
|
|
117
|
+
computer = Frontend.start(options, path, @writable)
|
|
118
|
+
@disk_path = PauseMenu.disk_path(path)
|
|
119
|
+
computer
|
|
120
|
+
rescue ArgumentError, SystemCallError, Media::TrueDrive::Error, Storage::SIDFile::FormatError,
|
|
121
|
+
Storage::T64::FormatError, Storage::TAP::FormatError, Storage::CRTFile::FormatError,
|
|
122
|
+
Storage::G64Image::FormatError, Cartridge::UnsupportedTypeError => e
|
|
123
|
+
@problem = e.message
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The pause menu's sections, each showing a part of the machine's
|
|
6
|
+
# hardware as it stands, in one column: what's there, and under it a
|
|
7
|
+
# row for each thing to do, a setting showing its value on the right,
|
|
8
|
+
# which a press steps on.
|
|
9
|
+
class MenuPages
|
|
10
|
+
TEXT = PauseMenu::TEXT
|
|
11
|
+
DIM = PauseMenu::DIM
|
|
12
|
+
LINE = 12
|
|
13
|
+
COLUMNS = 25
|
|
14
|
+
ROW_WIDTH = 200
|
|
15
|
+
|
|
16
|
+
WARNING = 0xff7a6b
|
|
17
|
+
|
|
18
|
+
PORT_DEVICES = %i[joystick mouse paddles].freeze
|
|
19
|
+
PORT_NAMES = %w[JOY MOUSE PADDLES].freeze
|
|
20
|
+
PORT_ACTIONS = [%i[port1_joystick port1_mouse port1_paddles], %i[port2_joystick port2_mouse port2_paddles]].freeze
|
|
21
|
+
POTS = [%i[none mouse1 paddles1], %i[none mouse2 paddles2]].freeze
|
|
22
|
+
|
|
23
|
+
# The disks, tapes and cartridges the pages put in and take out.
|
|
24
|
+
attr_reader :media
|
|
25
|
+
|
|
26
|
+
def initialize(painter, buttons, disk_path, options)
|
|
27
|
+
@painter = painter
|
|
28
|
+
@buttons = buttons
|
|
29
|
+
@media = MenuMedia.new(disk_path, options.writable?)
|
|
30
|
+
@reu_kb = options.reu
|
|
31
|
+
@computer = nil
|
|
32
|
+
@controls = nil
|
|
33
|
+
@sound = nil
|
|
34
|
+
@left = 0
|
|
35
|
+
@top = 0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The machine the pages show and change.
|
|
39
|
+
def machine(computer, controls, sound)
|
|
40
|
+
@computer = computer
|
|
41
|
+
@media.computer = computer
|
|
42
|
+
@controls = controls
|
|
43
|
+
@sound = sound
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def draw(page, left, top)
|
|
47
|
+
problem = @media.problem
|
|
48
|
+
@painter.text(left, top + 186, problem[0, COLUMNS], WARNING) unless problem.empty?
|
|
49
|
+
@left = left
|
|
50
|
+
@top = top
|
|
51
|
+
case page
|
|
52
|
+
when :drive then draw_drive
|
|
53
|
+
when :datasette then draw_datasette
|
|
54
|
+
when :expansion then draw_expansion
|
|
55
|
+
when :ports then draw_ports
|
|
56
|
+
when :sound then draw_sound
|
|
57
|
+
else draw_power
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def perform(action)
|
|
62
|
+
case action
|
|
63
|
+
when :eject_disk, :previous_disk, :next_disk, :protect, :unprotect then @media.drive(action)
|
|
64
|
+
when :tape_stop then @computer.datasette.stop!
|
|
65
|
+
when :tape_down then @computer.datasette.play!
|
|
66
|
+
when :tape_rewind then @computer.datasette.rewind
|
|
67
|
+
when :tape_eject then @computer.datasette.eject
|
|
68
|
+
when :keys_c64, :keys_joystick then keys(action == :keys_c64 ? :keyboard : :joystick)
|
|
69
|
+
when :swap then @controls.swap_ports
|
|
70
|
+
when :sound_on, :mute then @sound.toggle_mute unless @sound.muted? == (action == :mute)
|
|
71
|
+
when :sid6581, :sid8580 then @computer.sid.model = action == :sid8580 ? :mos8580 : :mos6581
|
|
72
|
+
when :reset then @computer.reset!
|
|
73
|
+
when :power_cycle then @computer.power_cycle!
|
|
74
|
+
else pick_port(action)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def draw_drive
|
|
81
|
+
media = @media
|
|
82
|
+
inserted = media.inserted?
|
|
83
|
+
set = inserted ? media.disk_set : []
|
|
84
|
+
place = set.size < 2 ? "" : " #{set.index(media.disk_path).to_i + 1} OF #{set.size}"
|
|
85
|
+
info("DISK#{place}", inserted ? File.basename(media.disk_path) : "")
|
|
86
|
+
row("INSERT...", :insert_disk)
|
|
87
|
+
row("EJECT", :eject_disk)
|
|
88
|
+
unless set.size < 2
|
|
89
|
+
row("PREVIOUS DISK", :previous_disk)
|
|
90
|
+
row("NEXT DISK", :next_disk)
|
|
91
|
+
end
|
|
92
|
+
toggle("WRITABLE", :writable, [%w[OFF ON], %i[protect unprotect], media.writable? ? 1 : 0])
|
|
93
|
+
skip
|
|
94
|
+
info("DEVICE", @computer.drive1541.nil? ? "FAST LOADING" : "1541, TRUE DRIVE")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def draw_datasette
|
|
98
|
+
datasette = @computer.datasette
|
|
99
|
+
tape = datasette.tape
|
|
100
|
+
info("TAPE", tape.nil? ? "" : File.basename(tape.path))
|
|
101
|
+
row("INSERT...", :insert_tape)
|
|
102
|
+
row("EJECT", :tape_eject)
|
|
103
|
+
toggle("PLAY", :tape_play, [%w[UP DOWN], %i[tape_stop tape_down], datasette.playing? ? 1 : 0])
|
|
104
|
+
row("REWIND", :tape_rewind)
|
|
105
|
+
skip
|
|
106
|
+
info("MOTOR", datasette.motor? ? "ON" : "OFF")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def draw_expansion
|
|
110
|
+
cartridge = @computer.address_bus.cartridge
|
|
111
|
+
info("CARTRIDGE", cartridge.nil? ? "" : cartridge.name)
|
|
112
|
+
row("INSERT...", :insert_cartridge)
|
|
113
|
+
row("REMOVE", :remove_cartridge)
|
|
114
|
+
row("FREEZE", :freeze) if !cartridge.nil? && cartridge.freezer?
|
|
115
|
+
return if @reu_kb.nil?
|
|
116
|
+
|
|
117
|
+
skip
|
|
118
|
+
info("RAM EXPANSION", "REU, #{@reu_kb} KB")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def draw_ports
|
|
122
|
+
toggle("PORT 1", :port1, [PORT_NAMES, PORT_ACTIONS[0], PORT_DEVICES.index(port_device(1))])
|
|
123
|
+
toggle("PORT 2", :port2, [PORT_NAMES, PORT_ACTIONS[1], PORT_DEVICES.index(port_device(2))])
|
|
124
|
+
joystick = @controls.joystick_mode?
|
|
125
|
+
toggle("KEYS", :keys, [%w[C64 JOYSTICK], %i[keys_c64 keys_joystick], joystick ? 1 : 0])
|
|
126
|
+
return unless joystick
|
|
127
|
+
|
|
128
|
+
row("SWAP JOYSTICKS", :swap)
|
|
129
|
+
skip
|
|
130
|
+
arrows = @controls.arrows_port
|
|
131
|
+
info("ARROWS, SPACE", "PORT #{arrows}")
|
|
132
|
+
info("WASD, LEFT SHIFT", "PORT #{3 - arrows}")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def draw_sound
|
|
136
|
+
if @sound.on?
|
|
137
|
+
toggle("OUTPUT", :output, [%w[ON MUTED], %i[sound_on mute], @sound.muted? ? 1 : 0])
|
|
138
|
+
else
|
|
139
|
+
info("OUTPUT", "OFF")
|
|
140
|
+
end
|
|
141
|
+
toggle("SID", :sid, [%w[6581 8580], %i[sid6581 sid8580], @computer.sid.model == :mos8580 ? 1 : 0])
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def draw_power
|
|
145
|
+
row("RESET", :reset)
|
|
146
|
+
row("POWER CYCLE", :power_cycle)
|
|
147
|
+
skip
|
|
148
|
+
row("QUIT", :quit)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# A label, and its value under it, or NONE in the dim colour when the
|
|
152
|
+
# value is empty.
|
|
153
|
+
def info(label, value)
|
|
154
|
+
@painter.text(@left, @top, label, DIM)
|
|
155
|
+
if value.empty?
|
|
156
|
+
@painter.text(@left, @top + LINE, "NONE", DIM)
|
|
157
|
+
else
|
|
158
|
+
@painter.text(@left, @top + LINE, Painter.fit(value, COLUMNS), TEXT)
|
|
159
|
+
end
|
|
160
|
+
@top += (LINE * 2) + 6
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def row(label, action, value = "")
|
|
164
|
+
@buttons.row([@left, @top, ROW_WIDTH], label, action, value)
|
|
165
|
+
@top += Buttons::HEIGHT + 2
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def toggle(label, action, choices)
|
|
169
|
+
@buttons.toggle([@left, @top, ROW_WIDTH], label, action, choices)
|
|
170
|
+
@top += Buttons::HEIGHT + 2
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def skip
|
|
174
|
+
@top += LINE
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def keys(mode) = @controls.joystick_mode = mode == :joystick
|
|
178
|
+
|
|
179
|
+
def port_device(port)
|
|
180
|
+
index = POTS[port - 1].index(@controls.pot)
|
|
181
|
+
index.nil? ? :joystick : PORT_DEVICES[index]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Plugs the device a choice such as :port1_mouse names into its port.
|
|
185
|
+
def pick_port(action)
|
|
186
|
+
index = PORT_ACTIONS[0].include?(action) ? 0 : 1
|
|
187
|
+
choice = PORT_ACTIONS[index].index(action)
|
|
188
|
+
plug_device(index + 1, choice) unless choice.nil? || choice == PORT_DEVICES.index(port_device(index + 1))
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Plugs PORT_DEVICES[choice] into the port. A joystick there takes out
|
|
192
|
+
# the pot device.
|
|
193
|
+
def plug_device(port, choice)
|
|
194
|
+
@controls.plug(POTS[port - 1][choice])
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|