badline 0.3.0 → 0.4.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/hooks/session-start.sh +57 -0
  3. data/.claude/settings.json +14 -0
  4. data/CHANGELOG.md +58 -0
  5. data/CLAUDE.md +45 -16
  6. data/CONTRIBUTING.md +95 -3
  7. data/README.md +111 -61
  8. data/SECURITY.md +9 -0
  9. data/doc/pinned-behaviour.md +275 -33
  10. data/exe/badline-ruby +59 -0
  11. data/lib/badline/address_bus.rb +22 -10
  12. data/lib/badline/audio/aiff.rb +8 -3
  13. data/lib/badline/audio/cli.rb +17 -12
  14. data/lib/badline/audio/console.rb +12 -58
  15. data/lib/badline/audio/playback.rb +3 -0
  16. data/lib/badline/audio/sdl_sink.rb +20 -52
  17. data/lib/badline/audio/terminal.rb +90 -0
  18. data/lib/badline/audio.rb +2 -2
  19. data/lib/badline/cartridge/geo_ram.rb +63 -0
  20. data/lib/badline/cartridge.rb +1 -0
  21. data/lib/badline/cia/interrupt_register.rb +34 -10
  22. data/lib/badline/cia.rb +15 -8
  23. data/lib/badline/computer.rb +28 -13
  24. data/lib/badline/gui/application.rb +33 -21
  25. data/lib/badline/gui/gamepads.rb +22 -22
  26. data/lib/badline/gui/joy_map.rb +1 -1
  27. data/lib/badline/gui/key_map.rb +6 -1
  28. data/lib/badline/gui/pane.rb +7 -6
  29. data/lib/badline/gui/screen_pane.rb +9 -13
  30. data/lib/badline/gui/window.rb +35 -20
  31. data/lib/badline/gui.rb +1 -1
  32. data/lib/badline/input/mouse1351.rb +21 -6
  33. data/lib/badline/input/paddles.rb +11 -6
  34. data/lib/badline/kernal_trap/channel.rb +3 -0
  35. data/lib/badline/kernal_trap/drive/block_commands.rb +33 -4
  36. data/lib/badline/kernal_trap/drive/status.rb +3 -0
  37. data/lib/badline/kernal_trap/drive/write_file.rb +41 -0
  38. data/lib/badline/kernal_trap/drive/writes.rb +92 -0
  39. data/lib/badline/kernal_trap/drive.rb +51 -51
  40. data/lib/badline/kernal_trap/file.rb +6 -9
  41. data/lib/badline/kernal_trap/save.rb +9 -4
  42. data/lib/badline/keyboard.rb +19 -3
  43. data/lib/badline/media.rb +42 -11
  44. data/lib/badline/options.rb +209 -0
  45. data/lib/badline/ram_expansion/banking.rb +30 -0
  46. data/lib/badline/ram_expansion/plus256k.rb +38 -0
  47. data/lib/badline/ram_expansion/plus60k.rb +31 -0
  48. data/lib/badline/ram_expansion/unexpanded.rb +22 -0
  49. data/lib/badline/ram_expansion.rb +21 -0
  50. data/lib/badline/region.rb +51 -0
  51. data/lib/badline/sdl/events.rb +47 -0
  52. data/lib/badline/sdl/functions.rb +73 -0
  53. data/lib/badline/sdl.rb +67 -0
  54. data/lib/badline/sid/waveform/noise_writeback.rb +32 -14
  55. data/lib/badline/sid/waveform.rb +1 -1
  56. data/lib/badline/sid.rb +3 -1
  57. data/lib/badline/storage/d64_image.rb +10 -0
  58. data/lib/badline/storage/d71_image.rb +12 -0
  59. data/lib/badline/storage/d81_image.rb +9 -0
  60. data/lib/badline/storage/disk_image/bam.rb +111 -0
  61. data/lib/badline/storage/disk_image/directory.rb +79 -0
  62. data/lib/badline/storage/disk_image/writing.rb +110 -0
  63. data/lib/badline/storage/disk_image.rb +27 -4
  64. data/lib/badline/storage/host_directory.rb +2 -1
  65. data/lib/badline/storage.rb +15 -0
  66. data/lib/badline/time_of_day.rb +4 -2
  67. data/lib/badline/version.rb +1 -1
  68. data/lib/badline/via/control_lines.rb +104 -0
  69. data/lib/badline/via/interrupt_register.rb +58 -0
  70. data/lib/badline/via/shift_register.rb +135 -0
  71. data/lib/badline/via/timer1.rb +82 -0
  72. data/lib/badline/via/timer2.rb +84 -0
  73. data/lib/badline/via.rb +262 -0
  74. data/lib/badline/vic/bank.rb +4 -3
  75. data/lib/badline/vic/color_patches.rb +6 -3
  76. data/lib/badline/vic/display_state.rb +4 -0
  77. data/lib/badline/vic/graphics_shifter.rb +42 -7
  78. data/lib/badline/vic/registers.rb +3 -0
  79. data/lib/badline/vic/sequencer.rb +18 -18
  80. data/lib/badline/vic/sequencer_output.rb +4 -4
  81. data/lib/badline/vic/sprite/internal_bus.rb +4 -0
  82. data/lib/badline/vic/sprite/shifter.rb +25 -3
  83. data/lib/badline/vic/sprite.rb +5 -4
  84. data/lib/badline/vic/sprites.rb +8 -3
  85. data/lib/badline/vic.rb +93 -44
  86. data/lib/badline.rb +4 -0
  87. data/packaging/homebrew/badline.rb +34 -0
  88. metadata +30 -20
  89. data/exe/badline +0 -78
  90. data/exe/badline-sid +0 -34
  91. data/lib/badline/audio/options.rb +0 -137
@@ -3,13 +3,12 @@
3
3
  module Badline
4
4
  module GUI
5
5
  class Application
6
- PAL_CLOCK_HZ = 985_248
7
6
  TITLE = "Badline"
8
- TOGGLE_SYM = SDL2::Key::TAB
9
- MUTE_SYM = SDL2::Key::F10
10
- REVERSE_MOD = SDL2::Key::Mod::SHIFT
7
+ TOGGLE_SYM = SDL::KEY_TAB
8
+ MUTE_SYM = SDL::KEY_F10
9
+ REVERSE_MOD = SDL::KMOD_SHIFT
11
10
 
12
- SHARED_KEYS = %i[up left cursor_h cursor_v space w a s d lshift].freeze
11
+ SHARED_KEYS = %i[cursor_up cursor_left cursor_h cursor_v space w a s d lshift].freeze
13
12
 
14
13
  # Tab steps through the input modes and shift-Tab back; the title bar
15
14
  # names the live one. The pot devices appear once per control port, since
@@ -29,9 +28,10 @@ module Badline
29
28
  }.freeze
30
29
  MOUSE_BUTTONS = { 1 => :left, 3 => :right }.freeze
31
30
 
32
- def initialize(media_path: nil, autostart: true, song: nil, sid_model: nil, sound: false)
31
+ # The media options (autostart:, song:, disk:) go to Media.attach.
32
+ def initialize(media_path: nil, sid_model: nil, sound: false, **media)
33
33
  @computer = Computer.new(sid_model: sid_model || Media.sid_model(media_path))
34
- puts Media.attach(@computer, media_path, autostart:, song:) if media_path
34
+ puts Media.attach(@computer, media_path, **media) if media_path
35
35
 
36
36
  @mode = :keyboard
37
37
  @pot_device = nil
@@ -61,25 +61,26 @@ module Badline
61
61
  ensure
62
62
  @stream&.close
63
63
  @gamepads.close
64
+ @window.close
64
65
  puts @computer.cpu.inspect
65
66
  end
66
67
 
67
68
  private
68
69
 
69
70
  def handle_events
70
- while (event = SDL2::Event.poll)
71
+ while (event = SDL.poll_event)
71
72
  case event
72
- when SDL2::Event::Quit
73
+ when SDL::Quit
73
74
  @running = false
74
- when SDL2::Event::KeyDown
75
+ when SDL::KeyDown
75
76
  handle_key_down(event)
76
- when SDL2::Event::KeyUp
77
+ when SDL::KeyUp
77
78
  handle_key_up(event)
78
- when SDL2::Event::MouseMotion
79
+ when SDL::MouseMotion
79
80
  @pot_device&.move(event.xrel, event.yrel)
80
- when SDL2::Event::MouseButton
81
+ when SDL::MouseButton
81
82
  handle_mouse_button(event)
82
- when SDL2::Event::ControllerDevice
83
+ when SDL::ControllerDevice
83
84
  @gamepads.rescan
84
85
  end
85
86
  end
@@ -93,10 +94,19 @@ module Badline
93
94
  if port
94
95
  joystick(port).press(dir)
95
96
  else
96
- @computer.keyboard.press(KeyMap.parse(event))
97
+ press_key(KeyMap.parse(event), event.repeat)
97
98
  end
98
99
  end
99
100
 
101
+ # RESTORE isn't in the key matrix, so it goes to the machine instead,
102
+ # once per press as the real key's one-shot fires.
103
+ def press_key(key, repeat)
104
+ return @computer.press_restore if key == :restore && !repeat
105
+ return if key == :restore
106
+
107
+ @computer.keyboard.press(key)
108
+ end
109
+
100
110
  def handle_key_up(event)
101
111
  return if [TOGGLE_SYM, MUTE_SYM].include?(event.sym)
102
112
 
@@ -112,9 +122,10 @@ module Badline
112
122
  button = MOUSE_BUTTONS[event.button]
113
123
  return unless button && @pot_device
114
124
 
115
- case event
116
- when SDL2::Event::MouseButtonDown then @pot_device.press(button)
117
- when SDL2::Event::MouseButtonUp then @pot_device.release(button)
125
+ if event.pressed
126
+ @pot_device.press(button)
127
+ else
128
+ @pot_device.release(button)
118
129
  end
119
130
  end
120
131
 
@@ -153,8 +164,9 @@ module Badline
153
164
 
154
165
  def fit_frame
155
166
  rate = @window.refresh_rate
156
- @cycles_per_frame = PAL_CLOCK_HZ / rate
157
- @frame_seconds = @cycles_per_frame.fdiv(PAL_CLOCK_HZ)
167
+ clock_hz = @computer.region.clock_hz
168
+ @cycles_per_frame = clock_hz / rate
169
+ @frame_seconds = @cycles_per_frame.fdiv(clock_hz)
158
170
  puts "Display #{rate} Hz -> #{@cycles_per_frame} cycles/frame"
159
171
  end
160
172
 
@@ -164,7 +176,7 @@ module Badline
164
176
  ports = @computer.control_ports
165
177
  ports.device1 = port == 1 ? @pot_device : nil
166
178
  ports.device2 = port == 2 ? @pot_device : nil
167
- SDL2::Mouse.relative_mode = !@pot_device.nil?
179
+ SDL::SetRelativeMouseMode.call(@pot_device ? 1 : 0)
168
180
  end
169
181
 
170
182
  def release_inputs
@@ -9,8 +9,8 @@ module Badline
9
9
  # face and shoulder button fires.
10
10
  #
11
11
  # SDL announces arrivals by device index but reports button presses by
12
- # instance id, and the binding gives no way to line the two up, so this
13
- # class polls the controllers every frame. It only releases directions it
12
+ # instance id, so rather than line the two up, this class polls the
13
+ # controllers every frame. It only releases directions it
14
14
  # pressed itself, so the keyboard mapping keeps working with a pad
15
15
  # connected.
16
16
  class Gamepads
@@ -18,39 +18,39 @@ module Badline
18
18
  PORTS = [2, 1].freeze
19
19
 
20
20
  BUTTONS = {
21
- up: [SDL2::GameController::Button::DPAD_UP],
22
- down: [SDL2::GameController::Button::DPAD_DOWN],
23
- left: [SDL2::GameController::Button::DPAD_LEFT],
24
- right: [SDL2::GameController::Button::DPAD_RIGHT],
25
- fire: [SDL2::GameController::Button::A, SDL2::GameController::Button::B,
26
- SDL2::GameController::Button::X, SDL2::GameController::Button::Y,
27
- SDL2::GameController::Button::LEFTSHOULDER,
28
- SDL2::GameController::Button::RIGHTSHOULDER]
21
+ up: [SDL::CONTROLLER_BUTTON_DPAD_UP],
22
+ down: [SDL::CONTROLLER_BUTTON_DPAD_DOWN],
23
+ left: [SDL::CONTROLLER_BUTTON_DPAD_LEFT],
24
+ right: [SDL::CONTROLLER_BUTTON_DPAD_RIGHT],
25
+ fire: [SDL::CONTROLLER_BUTTON_A, SDL::CONTROLLER_BUTTON_B,
26
+ SDL::CONTROLLER_BUTTON_X, SDL::CONTROLLER_BUTTON_Y,
27
+ SDL::CONTROLLER_BUTTON_LEFTSHOULDER, SDL::CONTROLLER_BUTTON_RIGHTSHOULDER]
29
28
  }.freeze
30
29
 
31
30
  AXES = {
32
- up: [SDL2::GameController::Axis::LEFTY, -1],
33
- down: [SDL2::GameController::Axis::LEFTY, 1],
34
- left: [SDL2::GameController::Axis::LEFTX, -1],
35
- right: [SDL2::GameController::Axis::LEFTX, 1]
31
+ up: [SDL::CONTROLLER_AXIS_LEFTY, -1],
32
+ down: [SDL::CONTROLLER_AXIS_LEFTY, 1],
33
+ left: [SDL::CONTROLLER_AXIS_LEFTX, -1],
34
+ right: [SDL::CONTROLLER_AXIS_LEFTX, 1]
36
35
  }.freeze
37
36
 
38
37
  def initialize(computer)
39
- SDL2.init(SDL2::INIT_GAMECONTROLLER)
38
+ SDL.check(SDL::InitSubSystem.call(SDL::INIT_GAMECONTROLLER))
40
39
  @computer = computer
41
40
  @controllers = []
42
41
  @pressed = Array.new(PORTS.size) { [] }
43
42
  rescan
44
43
  end
45
44
 
46
- def names = @controllers.map(&:name)
45
+ def names = @controllers.map { |controller| SDL::GameControllerName.call(controller) }
47
46
 
48
47
  def rescan
49
48
  close
50
- @controllers = (0...SDL2::Joystick.num_connected_joysticks)
51
- .select { |index| SDL2::Joystick.game_controller?(index) }
49
+ @controllers = (0...SDL::NumJoysticks.call)
50
+ .select { |index| SDL::IsGameController.call(index) == 1 }
52
51
  .first(PORTS.size)
53
- .map { |index| SDL2::GameController.open(index) }
52
+ .map { |index| SDL::GameControllerOpen.call(index) }
53
+ .reject(&:null?)
54
54
  end
55
55
 
56
56
  def poll
@@ -58,7 +58,7 @@ module Badline
58
58
  end
59
59
 
60
60
  def close
61
- @controllers.each { |controller| controller.destroy unless controller.destroy? }
61
+ @controllers.each { |controller| SDL::GameControllerClose.call(controller) }
62
62
  @controllers = []
63
63
  @pressed.size.times { |index| release(index) }
64
64
  end
@@ -78,7 +78,7 @@ module Badline
78
78
  end
79
79
 
80
80
  def active?(controller, direction)
81
- BUTTONS.fetch(direction).any? { |button| controller.button_pressed?(button) } ||
81
+ BUTTONS.fetch(direction).any? { |button| SDL::GameControllerGetButton.call(controller, button) == 1 } ||
82
82
  tilted?(controller, direction)
83
83
  end
84
84
 
@@ -86,7 +86,7 @@ module Badline
86
86
  axis, sign = AXES[direction]
87
87
  return false unless axis
88
88
 
89
- controller.axis(axis) * sign > DEADZONE
89
+ SDL::GameControllerGetAxis.call(controller, axis) * sign > DEADZONE
90
90
  end
91
91
 
92
92
  def joystick(index)
@@ -13,7 +13,7 @@ module Badline
13
13
  }.freeze
14
14
 
15
15
  def self.parse(event)
16
- name = SDL2::Key.name_of(event.sym)
16
+ name = SDL.key_name(event.sym)
17
17
 
18
18
  PORTS.each do |port, map|
19
19
  direction = map[name]
@@ -11,6 +11,11 @@ module Badline
11
11
  "Return" => :return,
12
12
  "Right" => :cursor_h,
13
13
  "Down" => :cursor_v,
14
+ "Left" => :cursor_left,
15
+ "Up" => :cursor_up,
16
+ "`" => :left,
17
+ "]" => :up,
18
+ "PageUp" => :restore,
14
19
  "Space" => :space,
15
20
  "Left Ctrl" => :control,
16
21
  "Left Alt" => :cbm,
@@ -26,7 +31,7 @@ module Badline
26
31
  }.freeze
27
32
 
28
33
  def self.parse(event)
29
- name = SDL2::Key.name_of(event.sym)
34
+ name = SDL.key_name(event.sym)
30
35
  MAP[name] || name.downcase.to_sym
31
36
  end
32
37
  end
@@ -10,7 +10,7 @@ module Badline
10
10
  @height = height
11
11
  @left = left
12
12
  @top = top
13
- @rect = SDL2::Rect.new(left, top, width, height)
13
+ @rect = [left, top, width, height].pack("l4")
14
14
  end
15
15
 
16
16
  def render(_renderer)
@@ -19,16 +19,17 @@ module Badline
19
19
 
20
20
  private
21
21
 
22
+ # The pixels are little-endian RGBA dwords, red in the low byte.
22
23
  def blit(renderer, pixels)
23
- surface = SDL2::Surface.from_string(
24
+ surface = SDL::CreateRGBSurfaceFrom.call(
24
25
  pixels, width, height, 32, width * 4,
25
26
  0x0000_00ff, 0x0000_ff00, 0x00ff_0000, 0xff00_0000
26
27
  )
27
- texture = renderer.create_texture_from(surface)
28
- renderer.copy(texture, nil, @rect)
28
+ texture = SDL::CreateTextureFromSurface.call(renderer, surface)
29
+ SDL::RenderCopy.call(renderer, texture, nil, @rect)
29
30
  ensure
30
- texture&.destroy
31
- surface&.destroy
31
+ SDL::DestroyTexture.call(texture) if texture
32
+ SDL::FreeSurface.call(surface) if surface
32
33
  end
33
34
  end
34
35
  end
@@ -2,19 +2,15 @@
2
2
 
3
3
  module Badline
4
4
  module GUI
5
+ # The part of the VIC's raster the machine's region crops out.
5
6
  class ScreenPane < Pane
6
- WIDTH = 384
7
- HEIGHT = 272
8
- COL_OFFSET = 96
9
- ROW_OFFSET = 20
10
-
11
- ROW_BYTES = WIDTH * 4
12
-
13
7
  def initialize(computer, left: 0, top: 0, palette: Palette.new)
14
- super(width: WIDTH, height: HEIGHT, left:, top:)
8
+ @col_offset, @row_offset, crop_width, crop_height = computer.region.crop
9
+ super(width: crop_width, height: crop_height, left:, top:)
15
10
  @computer = computer
11
+ @row_bytes = width * 4
16
12
  @palette = palette.dwords
17
- @buffer = ("\x00" * (HEIGHT * ROW_BYTES)).b
13
+ @buffer = ("\x00" * (height * @row_bytes)).b
18
14
  end
19
15
 
20
16
  def render(renderer)
@@ -30,10 +26,10 @@ module Badline
30
26
  vic_width = vic.width
31
27
  dirty = vic.dirty_lines
32
28
 
33
- HEIGHT.times do |row|
34
- next unless dirty[row + ROW_OFFSET]
29
+ height.times do |row|
30
+ next unless dirty[row + @row_offset]
35
31
 
36
- @buffer[row * ROW_BYTES, ROW_BYTES] =
32
+ @buffer[row * @row_bytes, @row_bytes] =
37
33
  pack_row(display, vic_width, row)
38
34
  end
39
35
  vic.clear_dirty_lines!
@@ -41,7 +37,7 @@ module Badline
41
37
  end
42
38
 
43
39
  def pack_row(display, vic_width, row)
44
- line = display[((row + ROW_OFFSET) * vic_width) + COL_OFFSET, WIDTH]
40
+ line = display[((row + @row_offset) * vic_width) + @col_offset, width]
45
41
  line.map! { |c| @palette[c] }
46
42
  line.pack("V*")
47
43
  end
@@ -5,41 +5,56 @@ module Badline
5
5
  class Window
6
6
  DEFAULT_REFRESH_RATE = 60
7
7
 
8
+ # SDL_DisplayMode: Uint32 format; int w, h, refresh_rate; then a pointer.
9
+ DISPLAY_MODE_SIZE = 24
10
+
8
11
  attr_reader :renderer
9
12
 
10
13
  def initialize(title:, width:, height:, scale: 2, vsync: true)
11
- SDL2.init(SDL2::INIT_VIDEO | SDL2::INIT_EVENTS)
12
- SDL2::Hints["SDL_RENDER_SCALE_QUALITY"] = "0" # nearest-neighbour
13
-
14
- @window = SDL2::Window.create(
15
- title,
16
- SDL2::Window::POS_CENTERED, SDL2::Window::POS_CENTERED,
17
- width * scale, height * scale,
18
- SDL2::Window::Flags::RESIZABLE
19
- )
20
-
21
- flags = SDL2::Renderer::Flags::ACCELERATED
22
- flags |= SDL2::Renderer::Flags::PRESENTVSYNC if vsync
23
- @renderer = @window.create_renderer(-1, flags)
24
- @renderer.logical_size = [width, height]
14
+ SDL.check(SDL::InitSubSystem.call(SDL::INIT_VIDEO | SDL::INIT_EVENTS))
15
+ SDL::SetHint.call("SDL_RENDER_SCALE_QUALITY", "0") # nearest-neighbour
16
+ SDL::SetHint.call("SDL_MOUSE_RELATIVE_SCALING", "0") # host pixels, whatever the window size
17
+
18
+ @window = SDL.check_pointer(SDL::CreateWindow.call(
19
+ title,
20
+ SDL::WINDOWPOS_CENTERED, SDL::WINDOWPOS_CENTERED,
21
+ width * scale, height * scale,
22
+ SDL::WINDOW_RESIZABLE
23
+ ))
24
+
25
+ flags = SDL::RENDERER_ACCELERATED
26
+ flags |= SDL::RENDERER_PRESENTVSYNC if vsync
27
+ @renderer = SDL.check_pointer(SDL::CreateRenderer.call(@window, -1, flags))
28
+ SDL.check(SDL::RenderSetLogicalSize.call(@renderer, width, height))
25
29
  end
26
30
 
27
31
  def title=(title)
28
- @window.title = title
32
+ SDL::SetWindowTitle.call(@window, title)
29
33
  end
30
34
 
31
35
  def refresh_rate
32
- rate = SDL2::Display.displays.first.current_mode.refresh_rate
36
+ mode = "\0".b * DISPLAY_MODE_SIZE
37
+ SDL.check(SDL::GetCurrentDisplayMode.call(0, mode))
38
+ rate = mode.unpack1("l", offset: 12)
33
39
  rate.positive? ? rate : DEFAULT_REFRESH_RATE
34
- rescue StandardError
40
+ rescue SDL::Error
35
41
  DEFAULT_REFRESH_RATE
36
42
  end
37
43
 
38
44
  def draw(panes)
39
- @renderer.draw_color = [0, 0, 0]
40
- @renderer.clear
45
+ SDL::SetRenderDrawColor.call(@renderer, 0, 0, 0, 255)
46
+ SDL::RenderClear.call(@renderer)
41
47
  panes.each { |pane| pane.render(@renderer) }
42
- @renderer.present
48
+ SDL::RenderPresent.call(@renderer)
49
+ end
50
+
51
+ def close
52
+ return unless @window
53
+
54
+ SDL::DestroyRenderer.call(@renderer)
55
+ SDL::DestroyWindow.call(@window)
56
+ SDL::QuitSubSystem.call(SDL::INIT_VIDEO | SDL::INIT_EVENTS)
57
+ @window = nil
43
58
  end
44
59
  end
45
60
  end
data/lib/badline/gui.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "sdl2"
3
+ require "badline/sdl"
4
4
 
5
5
  require "badline/gui/palette"
6
6
  require "badline/gui/key_map"
@@ -10,16 +10,23 @@ module Badline
10
10
  # wrap, so a single reading says nothing on its own, and the driver has to
11
11
  # sample before the mouse travels 32 counts.
12
12
  #
13
- # #move takes host deltas with y counting down the screen; the counter
14
- # counts the other way. The left button sits on the joystick fire line, the
13
+ # #move takes host pixel deltas with y counting down the screen; the counter
14
+ # counts the other way. Two host pixels make one count, as in VICE, and the
15
+ # counters keep the odd half count so slow movement adds up. Motion waits
16
+ # until the pot register is read, and at most 31 counts of it reach the
17
+ # counter per read, so a fast flick can't wrap the counter into the
18
+ # opposite direction. The left button sits on the joystick fire line, the
15
19
  # right button on the up line.
16
20
  class Mouse1351
17
21
  BUTTONS = { left: :fire, right: :up }.freeze
22
+ MAX_TRAVEL = 62
18
23
 
19
24
  def initialize
20
25
  @lines = Joystick.new
21
26
  @x = 0
22
27
  @y = 0
28
+ @pending_x = 0
29
+ @pending_y = 0
23
30
  end
24
31
 
25
32
  def press(button) = @lines.press(BUTTONS[button])
@@ -29,13 +36,21 @@ module Badline
29
36
  def port_bits = @lines.port_bits
30
37
 
31
38
  def move(delta_x, delta_y)
32
- @x = (@x + delta_x) & 0x3f
33
- @y = (@y - delta_y) & 0x3f
39
+ @pending_x = (@pending_x + delta_x).clamp(-MAX_TRAVEL, MAX_TRAVEL)
40
+ @pending_y = (@pending_y - delta_y).clamp(-MAX_TRAVEL, MAX_TRAVEL)
34
41
  end
35
42
 
36
- def pot_x = @x << 1
43
+ def pot_x
44
+ @x = (@x + @pending_x) & 0x7f
45
+ @pending_x = 0
46
+ @x & 0x7e
47
+ end
37
48
 
38
- def pot_y = @y << 1
49
+ def pot_y
50
+ @y = (@y + @pending_y) & 0x7f
51
+ @pending_y = 0
52
+ @y & 0x7e
53
+ end
39
54
  end
40
55
  end
41
56
  end
@@ -8,17 +8,18 @@ module Badline
8
8
  # on POTY with its button on the right line. Positions are the 0-255 counts
9
9
  # SID reads back, and the knobs stop at either end.
10
10
  #
11
+ # #move takes host pixel deltas, two to a count, and the knobs keep the odd
12
+ # half count so slow turns add up.
13
+ #
11
14
  # Buttons are named after the host mouse buttons, as on the 1351: the left
12
15
  # one fires paddle A, the right one paddle B.
13
16
  class Paddles
14
17
  BUTTONS = { left: :left, right: :right }.freeze
15
18
 
16
- attr_reader :pot_x, :pot_y
17
-
18
19
  def initialize
19
20
  @lines = Joystick.new
20
- @pot_x = 0x80
21
- @pot_y = 0x80
21
+ @half_x = 0x100
22
+ @half_y = 0x100
22
23
  end
23
24
 
24
25
  def press(button) = @lines.press(BUTTONS[button])
@@ -28,9 +29,13 @@ module Badline
28
29
  def port_bits = @lines.port_bits
29
30
 
30
31
  def move(turn_a, turn_b)
31
- @pot_x = (@pot_x + turn_a).clamp(0, 0xff)
32
- @pot_y = (@pot_y + turn_b).clamp(0, 0xff)
32
+ @half_x = (@half_x + turn_a).clamp(0, 0x1ff)
33
+ @half_y = (@half_y + turn_b).clamp(0, 0x1ff)
33
34
  end
35
+
36
+ def pot_x = @half_x >> 1
37
+
38
+ def pot_y = @half_y >> 1
34
39
  end
35
40
  end
36
41
  end
@@ -98,6 +98,9 @@ module Badline
98
98
 
99
99
  def writable? = @writable
100
100
 
101
+ # A buffer channel's block, as a block write puts it on the disk.
102
+ def block = @bytes[@base, Drive::BLOCK_SIZE]
103
+
101
104
  def exhausted?
102
105
  @pointer >= @length
103
106
  end
@@ -19,14 +19,43 @@ module Badline
19
19
  report_block_error(track, sector)
20
20
  end
21
21
 
22
- # The disk is write-protected, so a block write that gets as far as
23
- # the disk fails at the block it names.
22
+ # A block write puts the buffer's block on the disk. On a
23
+ # write-protected disk it fails at the block it names.
24
24
  def block_write(arguments, counted: false)
25
25
  channel, _drive, track, sector = arguments
26
26
  return unless fetch_block(channel, track, sector)
27
27
 
28
- store_count(@channels[channel]) if counted
29
- report(WRITE_PROTECT_ON, track, sector)
28
+ buffer = @channels[channel]
29
+ store_count(buffer) if counted
30
+ return report(WRITE_PROTECT_ON, track, sector) unless writable_disk?
31
+
32
+ writing { @storage.write_block(track, sector, buffer.block) }
33
+ end
34
+
35
+ # B-A marks a block in use. One already in use fails as NO BLOCK,
36
+ # naming the next free one.
37
+ def block_allocate(arguments)
38
+ _drive, track, sector = arguments
39
+ return unless bam_entry(track, sector)
40
+ return report(NO_BLOCK, *@storage.next_free_block(track, sector)) unless @storage.block_free?(track, sector)
41
+
42
+ writing { @storage.allocate_block(track, sector) }
43
+ end
44
+
45
+ # B-F marks a block free.
46
+ def block_free(arguments)
47
+ _drive, track, sector = arguments
48
+ writing { @storage.free_block(track, sector) } if bam_entry(track, sector)
49
+ end
50
+
51
+ # A BAM change needs a disk that takes writes and a block the BAM
52
+ # has an entry for. Returns whether both hold, once it has reported
53
+ # why not.
54
+ def bam_entry(track, sector)
55
+ return report(WRITE_PROTECT_ON) unless writable_disk?
56
+ return true if sector && @storage.bam_block?(track, sector)
57
+
58
+ report(ILLEGAL_TRACK_OR_SECTOR, track, sector)
30
59
  end
31
60
 
32
61
  # B-W first stores the index of the last byte written, the pointer
@@ -9,6 +9,7 @@ module Badline
9
9
  class Status
10
10
  MESSAGES = [20, 21, 22, 23, 24, 27].to_h { |code| [code, "READ ERROR"] }.merge(
11
11
  0 => " OK",
12
+ 1 => " FILES SCRATCHED",
12
13
  25 => "WRITE ERROR",
13
14
  26 => "WRITE PROTECT ON",
14
15
  28 => "WRITE ERROR",
@@ -16,8 +17,10 @@ module Badline
16
17
  30 => "SYNTAX ERROR",
17
18
  62 => "FILE NOT FOUND",
18
19
  63 => "FILE EXISTS",
20
+ 65 => "NO BLOCK",
19
21
  66 => "ILLEGAL TRACK OR SECTOR",
20
22
  70 => "NO CHANNEL",
23
+ 72 => "DISK FULL",
21
24
  73 => "CBM DOS V2.6 1541",
22
25
  74 => "DRIVE NOT READY"
23
26
  ).freeze
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module KernalTrap
5
+ class Drive
6
+ # A channel open for writing a file. It gathers the bytes sent to it,
7
+ # and the drive writes the file to the disk when the channel closes.
8
+ # An append starts from the file's end, a replace writes over the
9
+ # file of the same name.
10
+ class WriteFile
11
+ attr_accessor :buffer
12
+ attr_reader :name, :type, :mode
13
+
14
+ def initialize(name, type, mode: :write)
15
+ @name = name
16
+ @type = type
17
+ @mode = mode
18
+ @bytes = []
19
+ end
20
+
21
+ def write(bytes) = @bytes.concat(bytes)
22
+
23
+ def writable? = true
24
+
25
+ def failed? = false
26
+
27
+ # A file open for writing has nothing to send.
28
+ def read = nil
29
+
30
+ # Writes the gathered bytes to the storage.
31
+ def store(storage)
32
+ if @mode == :append
33
+ storage.append_file(@name, @bytes, type: @type)
34
+ else
35
+ storage.write_file(@name, @bytes, type: @type, replace: @mode == :replace)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end