badline 0.7.0 → 0.9.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 +48 -0
- data/CLAUDE.md +3 -1
- data/README.md +104 -292
- data/doc/library.md +69 -0
- data/doc/media.md +78 -0
- data/doc/pinned-behaviour.md +53 -9
- data/doc/sid-player.md +136 -0
- data/doc/snapshots.md +102 -0
- data/lib/badline/address_bus/roms.rb +32 -0
- data/lib/badline/address_bus.rb +4 -4
- data/lib/badline/audio/bare_player.rb +24 -0
- data/lib/badline/audio/checkpoints.rb +42 -0
- data/lib/badline/audio/jukebox.rb +11 -4
- data/lib/badline/audio/machine_player.rb +16 -0
- data/lib/badline/audio/renderer.rb +80 -9
- data/lib/badline/audio/stereo.rb +3 -5
- data/lib/badline/audio.rb +1 -0
- data/lib/badline/cartridge/freezer.rb +2 -0
- data/lib/badline/cartridge.rb +3 -0
- data/lib/badline/computer/attachments.rb +5 -5
- data/lib/badline/computer.rb +12 -10
- data/lib/badline/core.rb +48 -0
- data/lib/badline/data_path.rb +37 -0
- data/lib/badline/datasette.rb +24 -9
- data/lib/badline/drive1541/disk.rb +3 -3
- data/lib/badline/drive1541/disk_state.rb +14 -5
- data/lib/badline/frontend/app.rb +41 -37
- data/lib/badline/frontend/boot.rb +16 -4
- data/lib/badline/frontend/buttons.rb +101 -7
- data/lib/badline/frontend/confirmation.rb +56 -0
- data/lib/badline/frontend/controls.rb +44 -32
- data/lib/badline/frontend/file_browser.rb +225 -0
- data/lib/badline/frontend/frame_report.rb +52 -0
- data/lib/badline/frontend/menu_dialogs.rb +160 -0
- data/lib/badline/frontend/menu_media.rb +157 -0
- data/lib/badline/frontend/menu_pages.rb +220 -0
- data/lib/badline/frontend/name_field.rb +88 -0
- data/lib/badline/frontend/painter.rb +10 -0
- data/lib/badline/frontend/pause_menu.rb +264 -0
- data/lib/badline/frontend/player_window.rb +3 -3
- data/lib/badline/frontend/snapshots.rb +146 -18
- data/lib/badline/frontend/sound.rb +11 -6
- data/lib/badline/frontend/timeline.rb +5 -1
- data/lib/badline/frontend.rb +8 -0
- data/lib/badline/kernal_trap/load.rb +10 -0
- data/lib/badline/kernal_trap/routine.rb +28 -0
- data/lib/badline/kernal_trap/serial.rb +19 -7
- data/lib/badline/media/disk_list.rb +75 -0
- data/lib/badline/media/disk_set.rb +118 -0
- data/lib/badline/media.rb +9 -3
- data/lib/badline/model.rb +62 -0
- data/lib/badline/options/event.rb +5 -3
- data/lib/badline/options/help.rb +16 -12
- data/lib/badline/options/table.rb +6 -2
- data/lib/badline/options/validation.rb +9 -0
- data/lib/badline/options.rb +10 -7
- data/lib/badline/region.rb +16 -4
- data/lib/badline/reu/ram.rb +27 -0
- data/lib/badline/reu/saved_state.rb +27 -0
- data/lib/badline/roms/README +10 -0
- data/lib/badline/roms/kernal-sx64.rom +0 -0
- data/lib/badline/sid/decimator.rb +13 -0
- data/lib/badline/sid/filter.rb +6 -3
- data/lib/badline/sid.rb +19 -0
- data/lib/badline/snapshot/fields.rb +1 -0
- data/lib/badline/snapshot/setup.rb +20 -8
- data/lib/badline/snapshot/state.rb +14 -7
- data/lib/badline/snapshot/vice/main_cpu.rb +1 -1
- data/lib/badline/snapshot/vice/peripherals.rb +12 -2
- data/lib/badline/snapshot/vice/restore.rb +3 -1
- data/lib/badline/snapshot/vice/reu1764.rb +55 -0
- data/lib/badline/snapshot/vice/vicii.rb +59 -13
- data/lib/badline/snapshot/vice/vicii_import.rb +14 -3
- data/lib/badline/snapshot/vice.rb +25 -23
- data/lib/badline/storage/t64.rb +7 -4
- data/lib/badline/storage/tap.rb +7 -5
- data/lib/badline/storage/unavailable.rb +1 -1
- data/lib/badline/storage.rb +5 -5
- data/lib/badline/version.rb +1 -1
- data/lib/badline/vic/sprite/timing.rb +5 -9
- data/lib/badline/vic/sprite.rb +4 -3
- data/lib/badline.rb +2 -38
- metadata +22 -1
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The emulator's pause menu, which F9 opens over the frozen picture.
|
|
6
|
+
# The machine stands still while it's open. Its sections follow the
|
|
7
|
+
# machine's hardware: the drive, the datasette, the expansion port, the
|
|
8
|
+
# control ports, the sound and the power. It's drawn in colours outside
|
|
9
|
+
# the C64's palette, so it reads as the emulator's and not the
|
|
10
|
+
# machine's, and F9 or Esc closes it again.
|
|
11
|
+
#
|
|
12
|
+
# INSERT on a page opens the file browser for that device. Quick open
|
|
13
|
+
# starts a file as the command line does, in a new machine, and it and
|
|
14
|
+
# a cartridge going in or out ask first, as each power cycles the
|
|
15
|
+
# machine.
|
|
16
|
+
class PauseMenu
|
|
17
|
+
SECTIONS = ["SNAPSHOTS", "DRIVE 8", "DATASETTE", "EXPANSION PORT", "PORTS", "SOUND", "POWER"].freeze
|
|
18
|
+
PAGES = %i[snapshots drive datasette expansion ports sound power].freeze
|
|
19
|
+
DISKS = %w[.d64 .d71 .d81 .g64 .t64].freeze
|
|
20
|
+
|
|
21
|
+
PANEL = 0x1d2230
|
|
22
|
+
EDGE = 0x3a4256
|
|
23
|
+
TEXT = 0xc9d1e0
|
|
24
|
+
BRIGHT = 0xffc66d
|
|
25
|
+
DIM = 0x6b7489
|
|
26
|
+
FILL = 0x343c50
|
|
27
|
+
|
|
28
|
+
LEFT = 16
|
|
29
|
+
TOP = 16
|
|
30
|
+
WIDTH = Screen::WIDTH - 32
|
|
31
|
+
HEIGHT = Screen::HEIGHT - 32
|
|
32
|
+
BODY = 154
|
|
33
|
+
|
|
34
|
+
ESCAPE = 41
|
|
35
|
+
RETURN = 40
|
|
36
|
+
SPACE = 44
|
|
37
|
+
SNAPSHOT_ACTIONS = %i[quicksave_now save_as load_save].freeze
|
|
38
|
+
ARROWS = { 79 => :right, 80 => :left, 81 => :down, 82 => :up }.freeze
|
|
39
|
+
MOUSEWHEEL = 0x403
|
|
40
|
+
|
|
41
|
+
# Takes the media, the REU and whether disks are writable from Options.
|
|
42
|
+
def initialize(painter, options, snapshots)
|
|
43
|
+
media_path = options.media_path.to_s
|
|
44
|
+
@painter = painter
|
|
45
|
+
@buttons = Buttons.new(painter, [TEXT, BRIGHT, PANEL, FILL])
|
|
46
|
+
@pages = MenuPages.new(painter, @buttons, media_path, options, snapshots)
|
|
47
|
+
@snapshots = snapshots
|
|
48
|
+
@dialogs = MenuDialogs.new(painter, @buttons, @pages.media, options, snapshots)
|
|
49
|
+
@open = false
|
|
50
|
+
@section = 0
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The path of the disk the command line puts in device 8, a list's
|
|
54
|
+
# first, or an empty one for other media.
|
|
55
|
+
def self.disk_path(path)
|
|
56
|
+
return "" if path.empty?
|
|
57
|
+
return path if File.directory?(path)
|
|
58
|
+
return Media::DiskList.disks(path).first.to_s if Media::DiskList.list?(path)
|
|
59
|
+
|
|
60
|
+
DISKS.include?(File.extname(path).downcase) ? path : ""
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def open? = @open
|
|
64
|
+
|
|
65
|
+
# The machine Quick open started, which the app runs in place of the
|
|
66
|
+
# one before once #frame returns :swap.
|
|
67
|
+
def computer = @dialogs.computer
|
|
68
|
+
|
|
69
|
+
def show(computer, controls, sound)
|
|
70
|
+
@open = true
|
|
71
|
+
@buttons.focus = PAGES[@section]
|
|
72
|
+
@pages.machine(computer, controls, sound)
|
|
73
|
+
SDL.SDL_SetRelativeMouseMode(0)
|
|
74
|
+
draw
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def close
|
|
78
|
+
@open = false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Opens on the file the SDL event says was dropped on the window,
|
|
82
|
+
# and says whether the menu stays open to ask about it: a disk or tape
|
|
83
|
+
# goes straight in, and anything else asks first.
|
|
84
|
+
def drop(computer, controls, sound)
|
|
85
|
+
show(computer, controls, sound)
|
|
86
|
+
file = SDL.event_file(SDL.event)
|
|
87
|
+
asks = @dialogs.drop(LibC.strstr(file, ""))
|
|
88
|
+
LibC.free(file)
|
|
89
|
+
asks
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Handles a key pressed while the menu is open: the arrows move
|
|
93
|
+
# between the buttons, and a section opens as they reach it, Return
|
|
94
|
+
# or Space presses the button, and F9 or Esc closes the menu. While
|
|
95
|
+
# the file browser or a question is open, the keys go to it, and Esc
|
|
96
|
+
# goes back to the page. Returns the action the app has to take on,
|
|
97
|
+
# as #click does.
|
|
98
|
+
def key(scancode)
|
|
99
|
+
return :resume if scancode == Keys::F9
|
|
100
|
+
return @dialogs.key(scancode) if @dialogs.open?
|
|
101
|
+
return :resume if scancode == ESCAPE
|
|
102
|
+
return choose(@buttons.focus) if [RETURN, SPACE].include?(scancode)
|
|
103
|
+
|
|
104
|
+
direction = ARROWS[scancode]
|
|
105
|
+
move(direction) unless direction.nil?
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Handles a click, and returns the action the app has to take on, if
|
|
110
|
+
# any: :resume, :quit, :freeze or :swap.
|
|
111
|
+
def click(left, top)
|
|
112
|
+
action = @buttons.action_at(left, top)
|
|
113
|
+
return @dialogs.click(action, top) if %i[browse confirm cancel].include?(action)
|
|
114
|
+
|
|
115
|
+
choose(action)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Handles the events that came in, draws the menu over the frozen
|
|
119
|
+
# picture, dimmed, and returns the action the app has to take on, if
|
|
120
|
+
# any, leaving the events after one for the machine. Key releases
|
|
121
|
+
# still reach the machine, so no key stays held down while it stands
|
|
122
|
+
# still.
|
|
123
|
+
def frame(renderer, texture, controls)
|
|
124
|
+
action = nil
|
|
125
|
+
action = event(SDL.event_type(SDL.event), controls) while action.nil? && SDL.SDL_PollEvent(SDL.event) != 0
|
|
126
|
+
SDL.SDL_SetTextureColorMod(texture, 96, 96, 96)
|
|
127
|
+
SDL.SDL_RenderClear(renderer)
|
|
128
|
+
SDL.SDL_RenderCopy(renderer, texture, nil, nil)
|
|
129
|
+
SDL.SDL_SetTextureColorMod(texture, 255, 255, 255)
|
|
130
|
+
draw
|
|
131
|
+
SDL.SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255)
|
|
132
|
+
SDL.SDL_RenderPresent(renderer)
|
|
133
|
+
SDL.SDL_Delay(10)
|
|
134
|
+
action
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def draw
|
|
138
|
+
painter = @painter
|
|
139
|
+
@buttons.forget
|
|
140
|
+
painter.box(LEFT, TOP, WIDTH, HEIGHT, PANEL)
|
|
141
|
+
painter.box(LEFT, TOP + 24, WIDTH, 1, EDGE)
|
|
142
|
+
painter.text(LEFT + 8, TOP + 9, "PAUSED", BRIGHT)
|
|
143
|
+
resume = "RESUME"
|
|
144
|
+
@buttons.plain(LEFT + WIDTH - 8 - Painter.width(resume) - (Buttons::PAD * 2), TOP + 7, resume, :resume)
|
|
145
|
+
return @dialogs.draw(LEFT + 8, TOP + 34, WIDTH - 16) if @dialogs.open?
|
|
146
|
+
|
|
147
|
+
painter.box(BODY - 8, TOP + 32, 1, HEIGHT - 56, EDGE)
|
|
148
|
+
draw_sections
|
|
149
|
+
@buttons.plain(LEFT + 6, TOP + HEIGHT - 28, "QUICK OPEN...", :quick_open)
|
|
150
|
+
painter.text(LEFT + 8, TOP + HEIGHT - 12, "F9/ESC: RESUME", DIM)
|
|
151
|
+
@pages.draw(PAGES[@section], BODY, TOP + 34)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def choose(pressed)
|
|
157
|
+
return nil if pressed.nil?
|
|
158
|
+
|
|
159
|
+
action = @buttons.resolve(pressed)
|
|
160
|
+
index = PAGES.index(action)
|
|
161
|
+
unless index.nil?
|
|
162
|
+
@section = index
|
|
163
|
+
return nil
|
|
164
|
+
end
|
|
165
|
+
return action if %i[resume quit freeze].include?(action)
|
|
166
|
+
|
|
167
|
+
menu_action(action)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def menu_action(action)
|
|
171
|
+
return snapshot_action(action) if SNAPSHOT_ACTIONS.include?(action) || MenuPages::RECENT.include?(action)
|
|
172
|
+
|
|
173
|
+
case action
|
|
174
|
+
when :insert_disk then @dialogs.browse(:disk)
|
|
175
|
+
when :insert_tape then @dialogs.browse(:tape)
|
|
176
|
+
when :insert_cartridge then @dialogs.browse(:cartridge)
|
|
177
|
+
when :quick_open then @dialogs.browse(:program)
|
|
178
|
+
when :remove_cartridge then @dialogs.ask_remove
|
|
179
|
+
else @pages.perform(action)
|
|
180
|
+
end
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Saves now, asks for a name to save under, opens the saves folder,
|
|
185
|
+
# or loads one of the page's quicksaves and autosaves, returning
|
|
186
|
+
# :swap once a save has loaded.
|
|
187
|
+
def snapshot_action(action)
|
|
188
|
+
case action
|
|
189
|
+
when :quicksave_now then @snapshots.quicksave
|
|
190
|
+
when :save_as then @dialogs.ask_name(@snapshots.free_name(@pages.media.game_name))
|
|
191
|
+
when :load_save then @dialogs.browse(:snapshot)
|
|
192
|
+
else return @dialogs.load(@pages.recent(action))
|
|
193
|
+
end
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Moves the focus: down from RESUME to the sections, through them,
|
|
198
|
+
# where each opens as it's reached, on to Quick open, right into a
|
|
199
|
+
# page, up and down it, and left out of it again.
|
|
200
|
+
def move(direction)
|
|
201
|
+
focus = @buttons.focus
|
|
202
|
+
section = focus == :quick_open ? PAGES.size : PAGES.index(focus)
|
|
203
|
+
if focus == :resume
|
|
204
|
+
@buttons.focus = PAGES[@section] if %i[down left].include?(direction)
|
|
205
|
+
elsif section.nil?
|
|
206
|
+
move_in_page(direction)
|
|
207
|
+
else
|
|
208
|
+
move_in_sections(section, direction)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Up and down go through the page's rows, and left goes back to its
|
|
213
|
+
# section.
|
|
214
|
+
def move_in_page(direction)
|
|
215
|
+
case direction
|
|
216
|
+
when :up, :down then @buttons.shift(direction == :up ? -1 : 1, PAGES.size + 2)
|
|
217
|
+
when :left then @buttons.focus = PAGES[@section]
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def move_in_sections(section, direction)
|
|
222
|
+
case direction
|
|
223
|
+
when :up then section.zero? ? @buttons.focus = :resume : open_section(section - 1)
|
|
224
|
+
when :down then section < PAGES.size - 1 ? open_section(section + 1) : @buttons.focus = :quick_open
|
|
225
|
+
when :right then @buttons.focus = @buttons.action(PAGES.size + 2) || @buttons.focus if section < PAGES.size
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Opens a section, laid out at once, so keys that come before the next
|
|
230
|
+
# frame find its rows.
|
|
231
|
+
def open_section(index)
|
|
232
|
+
@section = index
|
|
233
|
+
@buttons.focus = PAGES[index]
|
|
234
|
+
draw
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def event(type, controls)
|
|
238
|
+
event = SDL.event
|
|
239
|
+
case type
|
|
240
|
+
when SDL::QUIT then :quit
|
|
241
|
+
when SDL::KEYDOWN then key_down(SDL.event_scancode(event), SDL.event_repeat(event))
|
|
242
|
+
when SDL::KEYUP then controls.key(SDL.event_scancode(event), false) && nil
|
|
243
|
+
when SDL::MOUSEMOTION then @buttons.point(SDL.event_x(event), SDL.event_y(event)) && nil
|
|
244
|
+
when SDL::MOUSEBUTTONDOWN
|
|
245
|
+
SDL.event_button(event) == 1 ? click(SDL.event_x(event), SDL.event_y(event)) : nil
|
|
246
|
+
when MOUSEWHEEL then @dialogs.scroll(-3 * SDL.event_x(event)) && nil
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# A key held down repeats only the arrows.
|
|
251
|
+
def key_down(scancode, repeat)
|
|
252
|
+
repeat.zero? || ARROWS.key?(scancode) ? key(scancode) : nil
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def draw_sections
|
|
256
|
+
top = TOP + 32
|
|
257
|
+
SECTIONS.each_with_index do |name, index|
|
|
258
|
+
@buttons.plain(LEFT + 6, top, name.ljust(14), PAGES[index], on: index == @section)
|
|
259
|
+
top += Buttons::HEIGHT + 2
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -45,7 +45,7 @@ module Badline
|
|
|
45
45
|
@state = PlayerState.new
|
|
46
46
|
@commands = PlayerCommands.new(@state)
|
|
47
47
|
@next_draw = 0.0
|
|
48
|
-
@
|
|
48
|
+
@seek_to = 0.0
|
|
49
49
|
@dropped = []
|
|
50
50
|
@credits = []
|
|
51
51
|
@stil = []
|
|
@@ -55,7 +55,7 @@ module Badline
|
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
# The seconds into the subtune the last click on the bar asked for.
|
|
58
|
-
|
|
58
|
+
attr_reader :seek_to
|
|
59
59
|
|
|
60
60
|
# The files dropped on the window since it last said :drop.
|
|
61
61
|
def dropped
|
|
@@ -175,7 +175,7 @@ module Badline
|
|
|
175
175
|
action = @screen.buttons.action_at(left, SDL.event_y(SDL.event))
|
|
176
176
|
return if action.nil?
|
|
177
177
|
|
|
178
|
-
@
|
|
178
|
+
@seek_to = PlayerFooter.seek(left, @state.length) if action == :seek
|
|
179
179
|
return scroll_along if action == :scroll
|
|
180
180
|
|
|
181
181
|
command(action, actions)
|
|
@@ -2,20 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module Badline
|
|
4
4
|
module Frontend
|
|
5
|
-
# The window's snapshots: F11 saves the machine to
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
5
|
+
# The window's snapshots: F11 saves the machine to the next of five
|
|
6
|
+
# quicksave slots in the data folder, replacing the oldest, and F12
|
|
7
|
+
# restores the newest quicksave or named save in a new machine. A run
|
|
8
|
+
# without a frame limit also autosaves every AUTOSAVE_FRAMES frames to
|
|
9
|
+
# the next of three autosave slots, which only the pause menu loads.
|
|
10
|
+
# --save-snapshot saves one after the last frame.
|
|
9
11
|
class Snapshots
|
|
10
12
|
KEYS = [Keys::F11, Keys::F12].freeze
|
|
13
|
+
SLOTS = 5
|
|
14
|
+
AUTOSAVES = 3
|
|
15
|
+
AUTOSAVE_FRAMES = 6000
|
|
11
16
|
|
|
12
17
|
# The machine running, which F12 replaces with the restored one.
|
|
13
|
-
|
|
18
|
+
attr_accessor :computer
|
|
14
19
|
|
|
15
20
|
def initialize(computer, options)
|
|
16
21
|
@computer = computer
|
|
17
|
-
@last = options.snapshot? ? options.media_path.to_s : ""
|
|
18
22
|
@at_finish = options.save_snapshot
|
|
23
|
+
@autosave = options.frames.zero?
|
|
24
|
+
@autosave_at = AUTOSAVE_FRAMES
|
|
19
25
|
end
|
|
20
26
|
|
|
21
27
|
# Saves or restores for F11 or F12, and says whether a restored
|
|
@@ -23,38 +29,160 @@ module Badline
|
|
|
23
29
|
def key(scancode)
|
|
24
30
|
return restore if scancode == Keys::F12
|
|
25
31
|
|
|
26
|
-
|
|
32
|
+
quicksave if scancode == Keys::F11
|
|
27
33
|
false
|
|
28
34
|
end
|
|
29
35
|
|
|
30
|
-
# Saves the machine,
|
|
31
|
-
|
|
36
|
+
# Saves the machine to the first empty quicksave slot, or else to the
|
|
37
|
+
# one saved longest ago, and warns when it can't.
|
|
38
|
+
def quicksave
|
|
39
|
+
folder = Badline.data_folder("quicksaves")
|
|
40
|
+
slot = next_slot(folder, "quicksave", SLOTS)
|
|
41
|
+
save(slot_path(folder, "quicksave", slot), "quicksave #{slot}")
|
|
42
|
+
rescue SystemCallError => e
|
|
43
|
+
warn "badline: quicksave: #{e.message}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Saves the machine, and warns when it can't. Says whether it saved.
|
|
47
|
+
def save(path, name = "")
|
|
32
48
|
@computer.save_snapshot(path)
|
|
33
|
-
|
|
34
|
-
|
|
49
|
+
puts(name.empty? ? "Saved #{path}" : "Saved #{name} to #{path}")
|
|
50
|
+
true
|
|
35
51
|
rescue Snapshot::FormatError, SystemCallError => e
|
|
36
52
|
warn "badline: #{path}: #{e.message}"
|
|
53
|
+
false
|
|
37
54
|
end
|
|
38
55
|
|
|
39
|
-
# Restores the
|
|
56
|
+
# Restores the newest quicksave or named save into a new machine, and
|
|
40
57
|
# warns when it fails, leaving the machine running as it was.
|
|
41
58
|
def restore
|
|
42
|
-
|
|
43
|
-
|
|
59
|
+
path = newest
|
|
60
|
+
if path.empty?
|
|
61
|
+
puts "No quicksave to restore. F11: Quicksave"
|
|
44
62
|
return false
|
|
45
63
|
end
|
|
46
64
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
65
|
+
restore_from(path)
|
|
66
|
+
rescue SystemCallError => e
|
|
67
|
+
warn "badline: quicksave: #{e.message}"
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Autosaves once the machine has run AUTOSAVE_FRAMES frames since the
|
|
72
|
+
# last, quietly. A machine that can't be saved stops autosaving after
|
|
73
|
+
# one warning.
|
|
74
|
+
def tick(frames)
|
|
75
|
+
return unless @autosave && frames >= @autosave_at
|
|
76
|
+
|
|
77
|
+
@autosave_at = frames + AUTOSAVE_FRAMES
|
|
78
|
+
folder = Badline.data_folder("autosaves")
|
|
79
|
+
@computer.save_snapshot(slot_path(folder, "autosave", next_slot(folder, "autosave", AUTOSAVES)))
|
|
50
80
|
rescue Snapshot::FormatError, SystemCallError => e
|
|
51
|
-
|
|
81
|
+
@autosave = false
|
|
82
|
+
warn "badline: autosave: #{e.message}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The snapshots in one of the data folders, `quicksaves`, `autosaves`
|
|
86
|
+
# or `saves`, newest first.
|
|
87
|
+
def list(name)
|
|
88
|
+
folder = Badline.data_folder(name)
|
|
89
|
+
paths = Dir.children(folder).select { |child| File.extname(child).casecmp?(".vsf") }
|
|
90
|
+
paths.map { |child| File.join(folder, child) }.sort_by { |path| -modified(path) }
|
|
91
|
+
rescue SystemCallError
|
|
92
|
+
[]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Whether a save in the saves folder has the name.
|
|
96
|
+
def named?(name) = File.exist?(File.join(Badline.data_folder("saves"), "#{name}.vsf"))
|
|
97
|
+
|
|
98
|
+
# Saves the machine as `name` in the saves folder, and says whether it
|
|
99
|
+
# did: a name already taken is overwritten only with `replace`.
|
|
100
|
+
def save_named(name, replace: false)
|
|
101
|
+
path = File.join(Badline.data_folder("saves"), "#{name}.vsf")
|
|
102
|
+
return false if File.exist?(path) && !replace
|
|
103
|
+
|
|
104
|
+
save(path, name)
|
|
105
|
+
rescue SystemCallError => e
|
|
106
|
+
warn "badline: #{name}: #{e.message}"
|
|
52
107
|
false
|
|
53
108
|
end
|
|
54
109
|
|
|
110
|
+
# `base` and the lowest number no named save has yet.
|
|
111
|
+
def free_name(base)
|
|
112
|
+
folder = Badline.data_folder("saves")
|
|
113
|
+
number = 1
|
|
114
|
+
number += 1 while File.exist?(File.join(folder, "#{base} #{number}.vsf"))
|
|
115
|
+
"#{base} #{number}"
|
|
116
|
+
rescue SystemCallError
|
|
117
|
+
"#{base} 1"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Restores the snapshot at `path` into a new machine, and says whether
|
|
121
|
+
# it did.
|
|
122
|
+
def load(path) = restore_from(path)
|
|
123
|
+
|
|
124
|
+
# When the snapshot at `path` was saved, as HH:MM:SS.
|
|
125
|
+
def time_of(path)
|
|
126
|
+
time = File.mtime(path)
|
|
127
|
+
format("%<hour>02d:%<minute>02d:%<second>02d", hour: time.hour, minute: time.min, second: time.sec)
|
|
128
|
+
end
|
|
129
|
+
|
|
55
130
|
def finish
|
|
56
131
|
save(@at_finish) unless @at_finish.empty?
|
|
57
132
|
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def restore_from(path)
|
|
137
|
+
@computer = Snapshot.load(path) { |line| puts line }
|
|
138
|
+
puts "Restored #{name_of(path)} from #{path}"
|
|
139
|
+
true
|
|
140
|
+
rescue Snapshot::FormatError, SystemCallError => e
|
|
141
|
+
warn "badline: #{path}: #{e.message}"
|
|
142
|
+
false
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def next_slot(folder, kind, count)
|
|
146
|
+
empty = (1..count).find { |slot| !File.exist?(slot_path(folder, kind, slot)) }
|
|
147
|
+
return empty unless empty.nil?
|
|
148
|
+
|
|
149
|
+
(1..count).min_by { |slot| modified(slot_path(folder, kind, slot)) }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def slot_path(folder, kind, slot) = File.join(folder, "#{kind}-#{slot}.vsf")
|
|
153
|
+
|
|
154
|
+
# The .vsf in quicksaves/ or saves/ written last, or "" when there's
|
|
155
|
+
# none.
|
|
156
|
+
def newest
|
|
157
|
+
found = ""
|
|
158
|
+
found_at = 0
|
|
159
|
+
%w[quicksaves saves].each do |name|
|
|
160
|
+
folder = Badline.data_folder(name)
|
|
161
|
+
Dir.children(folder).each do |child|
|
|
162
|
+
next unless File.extname(child).casecmp?(".vsf")
|
|
163
|
+
|
|
164
|
+
path = File.join(folder, child)
|
|
165
|
+
at = modified(path)
|
|
166
|
+
next unless found.empty? || at > found_at
|
|
167
|
+
|
|
168
|
+
found = path
|
|
169
|
+
found_at = at
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
found
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def name_of(path)
|
|
176
|
+
folder = Badline.data_folder("quicksaves")
|
|
177
|
+
slot = (1..SLOTS).find { |n| slot_path(folder, "quicksave", n) == path }
|
|
178
|
+
slot.nil? ? File.basename(path, ".vsf") : "quicksave #{slot}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# The modification time in nanoseconds.
|
|
182
|
+
def modified(path)
|
|
183
|
+
time = File.mtime(path)
|
|
184
|
+
(time.tv_sec * 1_000_000_000) + time.nsec
|
|
185
|
+
end
|
|
58
186
|
end
|
|
59
187
|
end
|
|
60
188
|
end
|
|
@@ -13,6 +13,8 @@ module Badline
|
|
|
13
13
|
# only happens unpaced.
|
|
14
14
|
#
|
|
15
15
|
# Each frame's samples go to SDL through an IO::Buffer of 16-bit values.
|
|
16
|
+
# The SID's cycles are converted at `clock_hz`, the machine's clock, so
|
|
17
|
+
# an NTSC or Drean machine plays at its own pitch.
|
|
16
18
|
class Sound
|
|
17
19
|
RATE = 44_100
|
|
18
20
|
AHEAD = 0.08
|
|
@@ -20,8 +22,9 @@ module Badline
|
|
|
20
22
|
|
|
21
23
|
attr_reader :rate, :underruns, :dropped, :queued, :low, :high
|
|
22
24
|
|
|
23
|
-
def initialize(sid, wanted, verbose)
|
|
25
|
+
def initialize(sid, clock_hz, wanted, verbose)
|
|
24
26
|
@sid = sid
|
|
27
|
+
@clock_hz = clock_hz
|
|
25
28
|
@verbose = verbose
|
|
26
29
|
@device = 0
|
|
27
30
|
@rate = RATE
|
|
@@ -37,10 +40,11 @@ module Badline
|
|
|
37
40
|
|
|
38
41
|
def on? = @device != 0
|
|
39
42
|
|
|
40
|
-
# Plays another machine's SID from here on.
|
|
41
|
-
def
|
|
43
|
+
# Plays another machine's SID from here on, at that machine's clock.
|
|
44
|
+
def switch(sid, clock_hz)
|
|
42
45
|
@sid = sid
|
|
43
|
-
|
|
46
|
+
@clock_hz = clock_hz
|
|
47
|
+
record if on?
|
|
44
48
|
end
|
|
45
49
|
|
|
46
50
|
def muted? = @muted
|
|
@@ -102,10 +106,12 @@ module Badline
|
|
|
102
106
|
return puts "No sound: #{SDL.SDL_GetError}" unless on?
|
|
103
107
|
|
|
104
108
|
@rate = SDL.read_i32(SDL.obtained)
|
|
105
|
-
|
|
109
|
+
record
|
|
106
110
|
puts "Sound at #{@rate} Hz" if @verbose
|
|
107
111
|
end
|
|
108
112
|
|
|
113
|
+
def record = @sid.record(rate: @rate, clock_hz: @clock_hz)
|
|
114
|
+
|
|
109
115
|
def queued_seconds = SDL.SDL_GetQueuedAudioSize(@device) / (2.0 * @rate)
|
|
110
116
|
|
|
111
117
|
def play(samples, level)
|
|
@@ -145,7 +151,6 @@ module Badline
|
|
|
145
151
|
|
|
146
152
|
def underrun!
|
|
147
153
|
@underruns += 1
|
|
148
|
-
puts "Running below real time, so the sound will stutter." if @underruns == 1
|
|
149
154
|
halt
|
|
150
155
|
end
|
|
151
156
|
end
|
|
@@ -15,7 +15,7 @@ module Badline
|
|
|
15
15
|
unless options.screenshot.empty? || options.frames.zero?
|
|
16
16
|
@events += [Options::Event.new(options.frames, "screenshot", options.screenshot)]
|
|
17
17
|
end
|
|
18
|
-
@read_only = options.
|
|
18
|
+
@read_only = !options.writable?
|
|
19
19
|
@program = options.program
|
|
20
20
|
@releases = []
|
|
21
21
|
@failed = false
|
|
@@ -63,6 +63,10 @@ module Badline
|
|
|
63
63
|
.map { |event| Timeline.path(event.argument, frame) }
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
+
# Presses the freeze button after `frame`, and lets go of it as the
|
|
67
|
+
# events do.
|
|
68
|
+
def press_freeze(computer, frame) = freeze(computer, Options::Event.new(frame, "freeze", ""))
|
|
69
|
+
|
|
66
70
|
# Whether the run ends at the frame: at a quit, or once an event failed.
|
|
67
71
|
def quit?(frame) = @failed || @events.any? { |event| event.frame == frame && event.action == "quit" }
|
|
68
72
|
|
data/lib/badline/frontend.rb
CHANGED
|
@@ -11,6 +11,7 @@ require "badline/frontend/screenshot"
|
|
|
11
11
|
require "badline/frontend/timeline"
|
|
12
12
|
require "badline/frontend/sound"
|
|
13
13
|
require "badline/frontend/pacer"
|
|
14
|
+
require "badline/frontend/frame_report"
|
|
14
15
|
require "badline/frontend/controls"
|
|
15
16
|
require "badline/frontend/pad_port"
|
|
16
17
|
require "badline/frontend/gamepads"
|
|
@@ -30,3 +31,10 @@ require "badline/frontend/info_view"
|
|
|
30
31
|
require "badline/frontend/player_commands"
|
|
31
32
|
require "badline/frontend/player_screen"
|
|
32
33
|
require "badline/frontend/player_window"
|
|
34
|
+
require "badline/frontend/pause_menu"
|
|
35
|
+
require "badline/frontend/file_browser"
|
|
36
|
+
require "badline/frontend/confirmation"
|
|
37
|
+
require "badline/frontend/name_field"
|
|
38
|
+
require "badline/frontend/menu_dialogs"
|
|
39
|
+
require "badline/frontend/menu_media"
|
|
40
|
+
require "badline/frontend/menu_pages"
|
|
@@ -79,15 +79,25 @@ module Badline
|
|
|
79
79
|
def finish(data, complete)
|
|
80
80
|
if data.empty?
|
|
81
81
|
@bus.poke(0x90, EOI | READ_TIMEOUT)
|
|
82
|
+
leave_bus(ACPTR_TIMEOUT)
|
|
82
83
|
continue_with(SEARCHING_MESSAGE, FILE_NOT_FOUND_EXIT)
|
|
83
84
|
else
|
|
84
85
|
deliver(data)
|
|
85
86
|
@bus.poke(0x90, @bus.peek(0x90) & ~EOI) unless complete
|
|
87
|
+
leave_bus(ISOUR_TIMEOUT)
|
|
86
88
|
continue_with(SEARCHING_MESSAGE, LOADING_MESSAGE, complete ? LOAD_DONE : BYTE_LOOP)
|
|
87
89
|
end
|
|
88
90
|
@bus.poke(0xb9, LOAD_SECONDARY)
|
|
89
91
|
end
|
|
90
92
|
|
|
93
|
+
# A load ends with the UNLISTEN that closes the file, a byte sent,
|
|
94
|
+
# and a missing file with the ACPTR that timed out. Either way it
|
|
95
|
+
# releases ATN, the clock and the data line on CIA 2's port A.
|
|
96
|
+
def leave_bus(timer_high)
|
|
97
|
+
time_serial_byte(timer_high)
|
|
98
|
+
release_serial_lines
|
|
99
|
+
end
|
|
100
|
+
|
|
91
101
|
def low_memory?(data)
|
|
92
102
|
load? && data.length > 2 && load_address(data) < 0x0334
|
|
93
103
|
end
|
|
@@ -10,6 +10,12 @@ module Badline
|
|
|
10
10
|
|
|
11
11
|
DEVICE = 8
|
|
12
12
|
|
|
13
|
+
# Timer B's latch high byte as ISOUR and ACPTR set it, and the
|
|
14
|
+
# control value both start it with: force load, one-shot, start.
|
|
15
|
+
ISOUR_TIMEOUT = 0x04
|
|
16
|
+
ACPTR_TIMEOUT = 0x01
|
|
17
|
+
TIMER_ONE_SHOT_START = 0x19
|
|
18
|
+
|
|
13
19
|
def initialize(cpu:, bus:)
|
|
14
20
|
@cpu = cpu
|
|
15
21
|
@bus = bus
|
|
@@ -21,6 +27,28 @@ module Badline
|
|
|
21
27
|
@bus.io_port.kernal?
|
|
22
28
|
end
|
|
23
29
|
|
|
30
|
+
# The ROM times each byte on the serial bus with CIA 1's timer B,
|
|
31
|
+
# started one-shot with the high byte of its latch set: ISOUR_TIMEOUT
|
|
32
|
+
# for a byte sent, ACPTR_TIMEOUT for one received.
|
|
33
|
+
def time_serial_byte(timer_high)
|
|
34
|
+
@bus.poke(0xdc07, timer_high)
|
|
35
|
+
@bus.poke(0xdc0f, TIMER_ONE_SHOT_START)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The ROM's release of the serial bus ($EE03): ATN ($EDBE), then the
|
|
39
|
+
# clock ($EE85), then the data line ($EE97), each a read of CIA 2's
|
|
40
|
+
# port A, a mask and a write back, so the input bits written back
|
|
41
|
+
# follow the lines as each one is let go. Returns the last value
|
|
42
|
+
# written, which the ROM leaves in A.
|
|
43
|
+
def release_serial_lines
|
|
44
|
+
value = 0
|
|
45
|
+
[0xf7, 0xef, 0xdf].each do |mask|
|
|
46
|
+
value = @bus.peek(0xdd00) & mask
|
|
47
|
+
@bus.poke(0xdd00, value)
|
|
48
|
+
end
|
|
49
|
+
value
|
|
50
|
+
end
|
|
51
|
+
|
|
24
52
|
def return_to_caller
|
|
25
53
|
@cpu.program_counter = (uint16(pull_byte, pull_byte) + 1) & 0xffff
|
|
26
54
|
end
|