badline 0.2.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4f6f2a45538629646db2ddd5ff47971f36580cf1982879a6f8db7d4dc410cf4
4
- data.tar.gz: e668de00c0a93c4f7f7f9ff9494a1f875ea820bbc18ef67203382862967753e3
3
+ metadata.gz: 7150f883c604da9d4f9bd4712412a020f9352dbbdba08a5051fee95e7f8bce30
4
+ data.tar.gz: cac44823e8c8afcc4637ad40383da5c624f51d29a60a45084b2ba6b81d6ae68e
5
5
  SHA512:
6
- metadata.gz: a4ccdf301a0d5f35e87e3ac8777bbc79b1214cbde95da1cf90db901aa40ffc05eef3dd3954fd9217137bc0892359da2123c09299a47473bf7a762a7d1754914a
7
- data.tar.gz: b16f94cd511939d43132c936047280de9114ee01a3bef13ed24af13d069a04e7d86ba1d736b544ef6957f3307de856fd625a631082a58a51deec91d2581c1204
6
+ metadata.gz: d09600dbf17cfa3df1d32e6ca5b8b25cd6f3cb012fd54c0cc0f7a4f700d98aaa45e9b0ba6cde95fa6c37e7310368f692e75c0f332f55fad84c5181a07df5da61
7
+ data.tar.gz: e3b2796713ab7701ab0a536276b6e845bb1751586c7706bdbe19a918129bb978927aef395062c98331c493af515a8ef77455446ca21289e434360753bf1aa89a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.1](https://github.com/elektronaut/badline/compare/v0.2.0...v0.2.1) (2026-09-23)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * decode U commands by the 1541's user table index ([4d37a92](https://github.com/elektronaut/badline/commit/4d37a928f9109d8214294fdac21bce25f201549b))
9
+ * detect disk image error tables by exact image size ([baa2091](https://github.com/elektronaut/badline/commit/baa20918b345723003ff61936c791f476e478fe8))
10
+ * detect the error table on 42-track D64 images ([9d8be41](https://github.com/elektronaut/badline/commit/9d8be41ffd23f0984f26e8e2c3ec0ba692629df9))
11
+ * end a SAVE the host directory can't write like a failed drive write ([4d15efe](https://github.com/elektronaut/badline/commit/4d15efe815d809fb7eba84cc9d59aebb4964f4ad))
12
+ * fail writes to a disk image like a write-protected 1541 ([c4ccb46](https://github.com/elektronaut/badline/commit/c4ccb46382166a896875a4f081c13db7841aaf7d))
13
+ * hand LOADs that reach below $0334 to the ROM's byte loop ([948b816](https://github.com/elektronaut/badline/commit/948b8163d0ba16d4eb4c71ee03ab4fe59297ba56))
14
+ * power-cycle the machine when a cartridge is attached ([2e1d7a4](https://github.com/elektronaut/badline/commit/2e1d7a472f00e0ccbd7a3245db7c685162e40786))
15
+ * print the KERNAL's SAVING message from the SAVE trap ([c912986](https://github.com/elektronaut/badline/commit/c912986e1702ba9907a097aaebd62b36e83b1fe5))
16
+ * print the KERNAL's SEARCHING and LOADING messages from the LOAD trap ([fc7e719](https://github.com/elektronaut/badline/commit/fc7e719e0856b8eddafbb96402893570cf757842))
17
+ * report the DOS version on the command channel at drive power-on ([016ad3f](https://github.com/elektronaut/badline/commit/016ad3f884a2fb1160f30372acd1ae972d0b9702))
18
+ * reset the virtual drive on the U-command reset forms ([a558314](https://github.com/elektronaut/badline/commit/a558314e7f116a28c8341ca82acce65c94469b9d))
19
+ * stop LOADs and file reads at a block the error table marks bad ([457be18](https://github.com/elektronaut/badline/commit/457be18351af7241a26f76631ec9bfad0ce2306e))
20
+
3
21
  ## [0.2.0](https://github.com/elektronaut/badline/compare/v0.1.0...v0.2.0) (2026-09-23)
4
22
 
5
23
 
@@ -45,12 +45,14 @@ module Badline
45
45
  PORT_FLOATING = 0b1100_1000
46
46
  TAPE_SENSE = 0b0001_0000
47
47
 
48
+ RAM_POWER_ON = [0xff, 0x07].freeze
49
+
48
50
  attr_reader :io_port, :ram, :basic_rom, :character_rom, :kernal_rom,
49
51
  :vic, :sid, :color_ram, :cia1, :cia2, :keyboard, :joystick1, :joystick2,
50
52
  :control_ports, :cartridge, :ultimax, :phi1_ultimax, :datasette
51
53
 
52
54
  def initialize(sid_model: :mos6581)
53
- @ram = Memory.new([0xff, 0x07], length: 2**16, start: 0)
55
+ @ram = Memory.new(RAM_POWER_ON, length: 2**16, start: 0)
54
56
  @cartridge = nil
55
57
  @debug_register = nil
56
58
 
@@ -79,7 +81,7 @@ module Badline
79
81
  @port_ddr = 0x00
80
82
  @port_out = 0x00
81
83
  @port_floating = 0x00
82
- @io_port = Status.new(%i[basic kernal io tape_out tape_switch tape_motor], value: port_value)
84
+ @io_port = PortStatus.new(%i[basic kernal io tape_out tape_switch tape_motor], value: port_value)
83
85
 
84
86
  @read_pages = Array.new(256)
85
87
  @write_pages = Array.new(256)
@@ -93,6 +95,10 @@ module Badline
93
95
  update_overlays!
94
96
  end
95
97
 
98
+ def power_on!
99
+ @ram.clear!(RAM_POWER_ON)
100
+ end
101
+
96
102
  # The 6510's RES line clears the port's direction and output registers.
97
103
  # The floating bits keep their charge.
98
104
  def reset_port!
@@ -35,12 +35,12 @@ module Badline
35
35
  BANK_SIZE = 0x2000
36
36
 
37
37
  HARDWARE_TYPES = {
38
- 0 => :Standard, 1 => :ActionReplay, 2 => :KCSPower, 3 => :FinalCartridge3,
39
- 4 => :SimonsBasic, 5 => :Ocean, 7 => :FunPlay, 8 => :SuperGames,
40
- 9 => :AtomicPower, 10 => :EpyxFastload, 11 => :Westermann,
41
- 12 => :RexUtility, 15 => :GameSystem, 17 => :Dinamic, 18 => :Zaxxon,
42
- 19 => :MagicDesk, 21 => :Comal80, 32 => :EasyFlash, 36 => :RetroReplay,
43
- 51 => :Mach5, 53 => :Pagefox, 57 => :RGCD, 60 => :GMod2
38
+ 0 => Standard, 1 => ActionReplay, 2 => KCSPower, 3 => FinalCartridge3,
39
+ 4 => SimonsBasic, 5 => Ocean, 7 => FunPlay, 8 => SuperGames,
40
+ 9 => AtomicPower, 10 => EpyxFastload, 11 => Westermann,
41
+ 12 => RexUtility, 15 => GameSystem, 17 => Dinamic, 18 => Zaxxon,
42
+ 19 => MagicDesk, 21 => Comal80, 32 => EasyFlash, 36 => RetroReplay,
43
+ 51 => Mach5, 53 => Pagefox, 57 => RGCD, 60 => GMod2
44
44
  }.freeze
45
45
 
46
46
  # The EXROM and GAME line levels of each memory configuration. The lines
@@ -62,11 +62,11 @@ module Badline
62
62
  end
63
63
 
64
64
  def from_crt(crt, **)
65
- type = HARDWARE_TYPES.fetch(crt.hardware_type) do
65
+ mapper = HARDWARE_TYPES.fetch(crt.hardware_type) do
66
66
  raise UnsupportedTypeError,
67
67
  "Unsupported cartridge hardware type #{crt.hardware_type}"
68
68
  end
69
- const_get(type).new(crt, **)
69
+ mapper.new(crt, **)
70
70
  end
71
71
  end
72
72
 
@@ -9,8 +9,8 @@ module Badline
9
9
  attr_reader :status, :mask, :quiet
10
10
 
11
11
  def initialize
12
- @mask = Status.new([:timer_a, :timer_b, :alarm, :serial, :flag, 0, 0, 0])
13
- @status = Status.new([:timer_a, :timer_b, :alarm, :serial, :flag, 0, 0, :interrupt])
12
+ @mask = InterruptFlags.new([:timer_a, :timer_b, :alarm, :serial, :flag, 0, 0, 0])
13
+ @status = InterruptFlags.new([:timer_a, :timer_b, :alarm, :serial, :flag, 0, 0, :interrupt])
14
14
  @pending = 0
15
15
  @read = @read_last_cycle = @read_two_cycles_ago = nil
16
16
  @timer_b_bug = false
@@ -40,8 +40,9 @@ module Badline
40
40
 
41
41
  # Latch a source, pulling the interrupt line if it is armed.
42
42
  def flag(source)
43
- status.public_send(:"#{source}=", true)
44
- assert! if mask.public_send(:"#{source}?")
43
+ bit = source_bit(source)
44
+ @status.value |= bit
45
+ assert! if mask.value.anybits?(bit)
45
46
  end
46
47
 
47
48
  # The 6526 timer B bug: an underflow on the cycle after a read still
@@ -70,9 +71,9 @@ module Badline
70
71
  # Bit 7 picks between setting and clearing the mask bits given.
71
72
  def write(value)
72
73
  if value.nobits?(0x80)
73
- mask.value &= ~(value & 0x1f)
74
+ @mask.value &= ~(value & 0x1f)
74
75
  else
75
- mask.value |= (value & 0x1f)
76
+ @mask.value |= (value & 0x1f)
76
77
  end
77
78
  if mask.value.anybits?(status.value & 0x1f)
78
79
  assert!(2) unless interrupted?
@@ -83,6 +84,19 @@ module Badline
83
84
  @pending = 0
84
85
  end
85
86
  end
87
+
88
+ private
89
+
90
+ def source_bit(source)
91
+ case source
92
+ when :timer_a then 0x01
93
+ when :timer_b then 0x02
94
+ when :alarm then 0x04
95
+ when :serial then 0x08
96
+ when :flag then 0x10
97
+ else raise ArgumentError, "unknown interrupt source #{source}"
98
+ end
99
+ end
86
100
  end
87
101
  end
88
102
  end
data/lib/badline/cia.rb CHANGED
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "forwardable"
4
3
  require "badline/cia/interrupt_register"
5
4
  require "badline/cia/serial"
6
5
  require "badline/cia/timer"
@@ -9,22 +8,38 @@ module Badline
9
8
  # CIA (Complex Interface Adapter) chip
10
9
  class CIA
11
10
  include Addressable
12
- extend Forwardable
13
11
 
14
12
  attr_reader :start, :control_a, :control_b, :peripheral, :serial
15
13
 
16
- def_delegator :@icr, :status, :interrupt_status
17
- def_delegator :@icr, :mask, :interrupt_control
18
- def_delegator :@icr, :assert!, :interrupt!
14
+ def interrupt_status = @icr.status
19
15
 
20
- def_delegator :@ta, :counter, :timer_a
21
- def_delegator :@ta, :counter=, :timer_a=
22
- def_delegator :@ta, :latch, :timer_a_latch
23
- def_delegator :@ta, :latch=, :timer_a_latch=
24
- def_delegator :@tb, :counter, :timer_b
25
- def_delegator :@tb, :counter=, :timer_b=
26
- def_delegator :@tb, :latch, :timer_b_latch
27
- def_delegator :@tb, :latch=, :timer_b_latch=
16
+ def interrupt_control = @icr.mask
17
+
18
+ def interrupt!(delay = 1) = @icr.assert!(delay)
19
+
20
+ def timer_a = @ta.counter
21
+
22
+ def timer_a=(value)
23
+ @ta.counter = value
24
+ end
25
+
26
+ def timer_a_latch = @ta.latch
27
+
28
+ def timer_a_latch=(value)
29
+ @ta.latch = value
30
+ end
31
+
32
+ def timer_b = @tb.counter
33
+
34
+ def timer_b=(value)
35
+ @tb.counter = value
36
+ end
37
+
38
+ def timer_b_latch = @tb.latch
39
+
40
+ def timer_b_latch=(value)
41
+ @tb.latch = value
42
+ end
28
43
 
29
44
  def initialize(start: 0, peripheral: nil)
30
45
  addressable_at(start, length: 2**8)
@@ -49,10 +64,10 @@ module Badline
49
64
  @tod = TimeOfDay.new
50
65
  @icr = InterruptRegister.new
51
66
  @icr_status = @icr.status
52
- @control_a = Status.new(%i[start output out_mode run_mode load
53
- in_mode serial_mode clock_frequency])
54
- @control_b = Status.new(%i[start output out_mode run_mode load
55
- in_cnt in_timer_a alarm])
67
+ @control_a = ControlRegister.new(%i[start output out_mode run_mode load
68
+ in_mode serial_mode clock_frequency])
69
+ @control_b = ControlRegister.new(%i[start output out_mode run_mode load
70
+ in_cnt in_timer_a alarm])
56
71
  @ta = Timer.new(@control_a)
57
72
  @tb = Timer.new(@control_b)
58
73
  @serial = Serial.new(@control_a)
@@ -1,17 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "forwardable"
4
-
5
3
  module Badline
6
4
  class Computer
7
5
  include IntegerHelper
8
6
  include KeyboardBuffer
9
- extend Forwardable
10
7
 
11
8
  attr_reader :address_bus, :cpu, :cycles
12
9
 
13
- def_delegators :address_bus, :vic, :cia1, :cia2, :sid, :ram, :keyboard, :joystick1, :joystick2,
14
- :control_ports, :datasette, :install_debug_register
10
+ def vic = address_bus.vic
11
+
12
+ def cia1 = address_bus.cia1
13
+
14
+ def cia2 = address_bus.cia2
15
+
16
+ def sid = address_bus.sid
17
+
18
+ def ram = address_bus.ram
19
+
20
+ def keyboard = address_bus.keyboard
21
+
22
+ def joystick1 = address_bus.joystick1
23
+
24
+ def joystick2 = address_bus.joystick2
25
+
26
+ def control_ports = address_bus.control_ports
27
+
28
+ def datasette = address_bus.datasette
29
+
30
+ def install_debug_register(&) = address_bus.install_debug_register(&)
15
31
 
16
32
  def initialize(debug: false, sid_model: :mos6581)
17
33
  @address_bus = AddressBus.new(sid_model:)
@@ -64,6 +80,15 @@ module Badline
64
80
  cartridge.clock = -> { @cycles }
65
81
  cartridge.on_nmi_change { |level| @cartridge_nmi = level }
66
82
  address_bus.attach_cartridge(cartridge)
83
+ power_cycle!
84
+ end
85
+
86
+ # A cartridge goes in with the power off, so attaching one switches the
87
+ # machine off and on: the VIC and RAM start from their power-on state,
88
+ # and the RES line resets everything else.
89
+ def power_cycle!
90
+ vic.power_on!
91
+ address_bus.power_on!
67
92
  reset!
68
93
  end
69
94
 
@@ -96,9 +121,9 @@ module Badline
96
121
  end
97
122
 
98
123
  def mount(storage)
99
- load_trap = KernalTrap::Load.new(cpu:, bus: address_bus, storage:)
100
- cpu.install_trap(KernalTrap::Load::ADDRESS) { load_trap.call }
101
124
  drive = KernalTrap::Drive.new(storage)
125
+ load_trap = KernalTrap::Load.new(cpu:, bus: address_bus, drive:)
126
+ cpu.install_trap(KernalTrap::Load::ADDRESS) { load_trap.call }
102
127
  KernalTrap::Serial.new(cpu:, bus: address_bus, drive:).install
103
128
  return unless storage.respond_to?(:write_file)
104
129
 
@@ -107,60 +107,58 @@ module Badline
107
107
  # - +optional_dummy+: true for indexed reads, which skip the extra
108
108
  # cycle unless indexing crosses a page
109
109
  Microcode = Struct.new(:operation, :plan, :writes, :optional_dummy) do
110
- class << self
111
- def table
112
- Array.new(256) do |opcode|
113
- instruction = Instruction.find(opcode)
114
- raise InvalidOpcodeError, format("$%02x", opcode) unless instruction
115
-
116
- build(instruction)
117
- end.freeze
118
- end
110
+ def self.table
111
+ Array.new(256) do |opcode|
112
+ instruction = Instruction.find(opcode)
113
+ raise InvalidOpcodeError, format("$%02x", opcode) unless instruction
119
114
 
120
- def write_mask(plan)
121
- plan.map { |step| WRITE_STEPS.include?(step) }.freeze
122
- end
115
+ build(instruction)
116
+ end.freeze
117
+ end
123
118
 
124
- private
119
+ def self.write_mask(plan)
120
+ plan.map { |step| WRITE_STEPS.include?(step) }.freeze
121
+ end
125
122
 
126
- def build(instruction)
127
- plan = plan_for(instruction)
128
- new(instruction.name, plan, write_mask(plan),
129
- instruction.boundary_cycle?)
130
- end
123
+ def self.build(instruction)
124
+ plan = plan_for(instruction)
125
+ new(instruction.name, plan, write_mask(plan),
126
+ instruction.boundary_cycle?)
127
+ end
131
128
 
132
- def plan_for(instruction)
133
- case instruction.name
134
- when :jmp
135
- if instruction.addressing_mode == :indirect
136
- JMP_INDIRECT_PLAN
137
- else
138
- JMP_ABSOLUTE_PLAN
139
- end
140
- when *BRANCHES then BRANCH_PLAN
141
- else SPECIAL_PLANS[instruction.name] || addressed_plan(instruction)
129
+ def self.plan_for(instruction)
130
+ case instruction.name
131
+ when :jmp
132
+ if instruction.addressing_mode == :indirect
133
+ JMP_INDIRECT_PLAN
134
+ else
135
+ JMP_ABSOLUTE_PLAN
142
136
  end
137
+ when *BRANCHES then BRANCH_PLAN
138
+ else SPECIAL_PLANS[instruction.name] || addressed_plan(instruction)
143
139
  end
140
+ end
144
141
 
145
- def addressed_plan(instruction)
146
- mode = instruction.addressing_mode
147
- case mode
148
- when :implied then IMPLIED_PLAN
149
- when :accumulator then ACCUMULATOR_PLAN
150
- when :immediate then IMMEDIATE_PLAN
151
- else
152
- [:op_fetch, *ADDRESSING_STEPS.fetch(mode),
153
- *OPERATION_STEPS.fetch(operation_kind(instruction.name))].freeze
154
- end
142
+ def self.addressed_plan(instruction)
143
+ mode = instruction.addressing_mode
144
+ case mode
145
+ when :implied then IMPLIED_PLAN
146
+ when :accumulator then ACCUMULATOR_PLAN
147
+ when :immediate then IMMEDIATE_PLAN
148
+ else
149
+ [:op_fetch, *ADDRESSING_STEPS.fetch(mode),
150
+ *OPERATION_STEPS.fetch(operation_kind(instruction.name))].freeze
155
151
  end
152
+ end
156
153
 
157
- def operation_kind(name)
158
- return :write if STORES.include?(name)
159
- return :rmw if READ_MODIFY_WRITES.include?(name)
154
+ def self.operation_kind(name)
155
+ return :write if STORES.include?(name)
156
+ return :rmw if READ_MODIFY_WRITES.include?(name)
160
157
 
161
- :read
162
- end
158
+ :read
163
159
  end
160
+
161
+ private_class_method :build, :plan_for, :addressed_plan, :operation_kind
164
162
  end
165
163
 
166
164
  FETCH_WRITES = Microcode.write_mask(FETCH_PLAN)
data/lib/badline/cpu.rb CHANGED
@@ -28,7 +28,7 @@ module Badline
28
28
  @debug = debug
29
29
  @ane_constant = ane_constant
30
30
  @memory = memory || Memory.new
31
- @status = Status.new(STATUS_FLAGS, value: 0b00100000)
31
+ @status = CPUStatus.new(STATUS_FLAGS, value: 0b00100000)
32
32
  reset_registers
33
33
 
34
34
  @nmi = @irq = false
@@ -4,12 +4,30 @@ module Badline
4
4
  module KernalTrap
5
5
  # One open channel on the virtual drive, holding a file, a block buffer
6
6
  # or the command channel's status message. It gives out one byte at a
7
- # time and flags the last one, so the bus can raise EOI with it.
7
+ # time and flags the last one, so the bus can raise EOI with it. A
8
+ # channel cut short by a read error never flags one, and holds the
9
+ # error for the drive to report once its bytes run out.
8
10
  class Channel
9
11
  attr_accessor :pointer
12
+ attr_reader :error
10
13
 
11
- def initialize(bytes = [])
14
+ def initialize(bytes = [], error: nil)
12
15
  replace(bytes)
16
+ @error = error
17
+ end
18
+
19
+ # A file's bytes, up to the first block its chain can't read. The
20
+ # drive reads a block ahead before sending the last byte of the one
21
+ # before, so a bad block holds that byte back too, and a bad first
22
+ # block or a missing file leaves nothing to send.
23
+ def self.for_file(storage, name, type)
24
+ data = storage.read_file(name, type:)
25
+ return new([], error: [Drive::FILE_NOT_FOUND]) unless data
26
+
27
+ failure = storage.respond_to?(:read_error) && storage.read_error(name, type:)
28
+ return new(data) unless failure
29
+
30
+ new(data[0, failure[:offset] - 1] || [], error: failure.values_at(:error, :track, :sector))
13
31
  end
14
32
 
15
33
  def replace(bytes)
@@ -26,7 +44,7 @@ module Badline
26
44
 
27
45
  byte = @bytes[@pointer]
28
46
  @pointer += 1
29
- [byte, exhausted?]
47
+ [byte, exhausted? && !@error]
30
48
  end
31
49
  end
32
50
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module KernalTrap
5
+ class Drive
6
+ # The command channel's side of the drive: the last DOS error with the
7
+ # track and sector it happened at, or the bytes an M-R read. Once its
8
+ # last byte has been read, it holds OK again.
9
+ class Status
10
+ MESSAGES = [20, 21, 22, 23, 24, 27].to_h { |code| [code, "READ ERROR"] }.merge(
11
+ 0 => " OK",
12
+ 25 => "WRITE ERROR",
13
+ 26 => "WRITE PROTECT ON",
14
+ 28 => "WRITE ERROR",
15
+ 29 => "DISK ID MISMATCH",
16
+ 30 => "SYNTAX ERROR",
17
+ 62 => "FILE NOT FOUND",
18
+ 63 => "FILE EXISTS",
19
+ 66 => "ILLEGAL TRACK OR SECTOR",
20
+ 70 => "NO CHANNEL",
21
+ 73 => "CBM DOS V2.6 1541",
22
+ 74 => "DRIVE NOT READY"
23
+ ).freeze
24
+
25
+ def initialize
26
+ @channel = Channel.new
27
+ end
28
+
29
+ def report(code, track = 0, sector = 0)
30
+ message = format("%<code>02d,%<message>s,%<track>02d,%<sector>02d\r",
31
+ code:, message: MESSAGES.fetch(code),
32
+ track: track.to_i, sector: sector.to_i)
33
+ @channel.replace(message.bytes)
34
+ nil
35
+ end
36
+
37
+ def replace(bytes)
38
+ @channel.replace(bytes)
39
+ end
40
+
41
+ def read
42
+ result = @channel.read
43
+ report(0) if result&.last
44
+ result
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end