mos6502-workbench 1.0.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.
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOS6502
4
+ # Base class for memory-mapped peripherals and storage devices.
5
+ #
6
+ # Device implementations are expected to respond to:
7
+ #
8
+ # - {#read_byte} for bus reads
9
+ # - {#write_byte} for bus writes
10
+ # - {#reset} for power-on style reinitialisation
11
+ # - {#tick} for advancing time in cycle-driven machines
12
+ #
13
+ # Subclasses can expose an optional `size` reader when the bus should validate
14
+ # the mapped range against the device capacity.
15
+ class Device
16
+ # Creates a new device.
17
+ #
18
+ # The base implementation exists so subclasses can call `super()` cleanly in
19
+ # their own initializers.
20
+ #
21
+ # @return [void]
22
+ def initialize; end
23
+
24
+ # Resets the device to its power-on state.
25
+ #
26
+ # The base implementation is a no-op so simple devices only need to override
27
+ # it when they maintain internal state.
28
+ #
29
+ # @return [Device] the device
30
+ def reset
31
+ self
32
+ end
33
+
34
+ # Advances the device clock by a number of CPU cycles.
35
+ #
36
+ # The base implementation is a no-op. Timed peripherals can override this to
37
+ # model timers, serial shifting, video scan-out, or similar behaviour.
38
+ #
39
+ # @param _cycles [Integer] the number of CPU cycles elapsed
40
+ # @return [Device] the device
41
+ def tick(_cycles)
42
+ self
43
+ end
44
+
45
+ # Reads a byte from the device-local address space.
46
+ #
47
+ # @param _address [Integer] the device-local byte offset
48
+ # @return [Integer] the device byte value
49
+ # @raise [NotImplementedError] unless a subclass implements the method
50
+ def read_byte(_address)
51
+ raise NotImplementedError, "#{self.class} must implement #read_byte"
52
+ end
53
+
54
+ # Writes a byte into the device-local address space.
55
+ #
56
+ # @param _address [Integer] the device-local byte offset
57
+ # @param _value [Integer] the value to store
58
+ # @return [Integer] the stored byte value
59
+ # @raise [NotImplementedError] unless a subclass implements the method
60
+ def write_byte(_address, _value)
61
+ raise NotImplementedError, "#{self.class} must implement #write_byte"
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOS6502
4
+ # Raised when bytes cannot be disassembled because the decode metadata is invalid.
5
+ class DisassemblyError < StandardError; end
6
+
7
+ # A small disassembler for the implemented NMOS 6502 opcode table.
8
+ #
9
+ # The disassembler intentionally derives its decode information from
10
+ # {CPU::OPCODES} so the emulator and listing output stay in sync.
11
+ class Disassembler
12
+ # A single decoded instruction or raw data byte.
13
+ Line = Struct.new(
14
+ :address,
15
+ :bytes,
16
+ :opcode,
17
+ :mnemonic,
18
+ :mode,
19
+ :operand,
20
+ :byte_size,
21
+ :target_address,
22
+ :text
23
+ )
24
+
25
+ # The number of bytes occupied by each supported addressing mode.
26
+ #
27
+ # @return [Hash{Symbol => Integer}]
28
+ INSTRUCTION_SIZES = {
29
+ implied: 1,
30
+ accumulator: 1,
31
+ immediate: 2,
32
+ zero_page: 2,
33
+ zero_page_x: 2,
34
+ zero_page_y: 2,
35
+ relative: 2,
36
+ indirect_x: 2,
37
+ indirect_y: 2,
38
+ absolute: 3,
39
+ absolute_x: 3,
40
+ absolute_y: 3,
41
+ indirect: 3
42
+ }.freeze
43
+
44
+ # @param opcodes [Hash{Integer => Array<Symbol>}] the opcode table to use
45
+ def initialize(opcodes: CPU::OPCODES)
46
+ @opcodes = opcodes
47
+ end
48
+
49
+ # Disassembles a byte stream into line objects.
50
+ #
51
+ # Unknown opcodes are rendered as `.byte` directives so arbitrary binaries can
52
+ # still be listed without raising.
53
+ #
54
+ # @param bytes [String, #to_a] the raw bytes to decode
55
+ # @param start_address [Integer] the load address of the first byte
56
+ # @param max_instructions [Integer, nil] an optional instruction limit
57
+ # @return [Array<Line>] the decoded listing
58
+ def disassemble(bytes, start_address: CPU::DEFAULT_LOAD_ADDRESS, max_instructions: nil)
59
+ stream = bytes.is_a?(String) ? bytes.bytes : bytes.to_a
60
+ lines = []
61
+ offset = 0
62
+
63
+ while offset < stream.length && within_instruction_budget?(lines.length, max_instructions)
64
+ address = (start_address + offset) & 0xffff
65
+ opcode = stream[offset]
66
+ mnemonic, mode = @opcodes[opcode]
67
+
68
+ if mnemonic.nil?
69
+ lines << data_line(address, opcode)
70
+ offset += 1
71
+ next
72
+ end
73
+
74
+ size = instruction_size(mode)
75
+ instruction_bytes = stream.slice(offset, size) || []
76
+ operand_bytes = instruction_bytes.drop(1)
77
+ operand, target_address = format_operand(mode, operand_bytes, address)
78
+
79
+ lines << Line.new(
80
+ address:,
81
+ bytes: instruction_bytes,
82
+ opcode:,
83
+ mnemonic:,
84
+ mode:,
85
+ operand:,
86
+ byte_size: size,
87
+ target_address:,
88
+ text: join_instruction_text(mnemonic, operand)
89
+ )
90
+
91
+ offset += size
92
+ end
93
+
94
+ lines
95
+ end
96
+
97
+ # Formats a decoded listing into a column-aligned string.
98
+ #
99
+ # @param lines [Array<Line>] the lines to render
100
+ # @return [String] the formatted listing text
101
+ def format_listing(lines)
102
+ lines.map do |line|
103
+ bytes = line.bytes.map { |byte| format('%02X', byte) }.join(' ').ljust(8)
104
+ "$#{format('%04X', line.address)}: #{bytes} #{line.text}"
105
+ end.join("\n")
106
+ end
107
+
108
+ private
109
+
110
+ # @param count [Integer] how many instructions have already been emitted
111
+ # @param max_instructions [Integer, nil] the optional instruction limit
112
+ # @return [Boolean] whether another instruction may be decoded
113
+ def within_instruction_budget?(count, max_instructions)
114
+ max_instructions.nil? || count < max_instructions
115
+ end
116
+
117
+ # Builds a `.byte` line for an unknown opcode.
118
+ #
119
+ # @param address [Integer] where the byte lives
120
+ # @param opcode [Integer] the unknown opcode value
121
+ # @return [Line] the formatted data line
122
+ def data_line(address, opcode)
123
+ operand = format('$%02X', opcode)
124
+ Line.new(
125
+ address:,
126
+ bytes: [opcode],
127
+ opcode:,
128
+ mnemonic: nil,
129
+ mode: nil,
130
+ operand:,
131
+ byte_size: 1,
132
+ target_address: nil,
133
+ text: ".byte #{operand}"
134
+ )
135
+ end
136
+
137
+ # Joins mnemonic and operand text into a single listing field.
138
+ #
139
+ # @param mnemonic [Symbol] the decoded mnemonic
140
+ # @param operand [String, nil] the formatted operand
141
+ # @return [String] the combined text
142
+ def join_instruction_text(mnemonic, operand)
143
+ operand ? "#{mnemonic} #{operand}" : mnemonic.to_s
144
+ end
145
+
146
+ # Returns the width of the decoded instruction.
147
+ #
148
+ # @param mode [Symbol] the addressing mode
149
+ # @return [Integer] the instruction size in bytes
150
+ # @raise [DisassemblyError] if the mode is unknown
151
+ def instruction_size(mode)
152
+ INSTRUCTION_SIZES.fetch(mode)
153
+ rescue KeyError
154
+ raise DisassemblyError, "Unknown instruction size for #{mode}"
155
+ end
156
+
157
+ # Formats an operand for listing output.
158
+ #
159
+ # @param mode [Symbol] the addressing mode
160
+ # @param bytes [Array<Integer>] the operand bytes
161
+ # @param address [Integer] the instruction address
162
+ # @return [Array<(String, Integer, nil)>] the operand text and optional target
163
+ # @raise [DisassemblyError] if the mode cannot be rendered
164
+ def format_operand(mode, bytes, address)
165
+ case mode
166
+ when :implied
167
+ [nil, nil]
168
+ when :accumulator
169
+ ['A', nil]
170
+ when :immediate
171
+ ["#$#{hex_byte(bytes[0])}", nil]
172
+ when :zero_page
173
+ ["$#{hex_byte(bytes[0])}", nil]
174
+ when :zero_page_x
175
+ ["$#{hex_byte(bytes[0])}, X", nil]
176
+ when :zero_page_y
177
+ ["$#{hex_byte(bytes[0])}, Y", nil]
178
+ when :absolute
179
+ ["$#{hex_word(bytes[0], bytes[1])}", nil]
180
+ when :absolute_x
181
+ ["$#{hex_word(bytes[0], bytes[1])}, X", nil]
182
+ when :absolute_y
183
+ ["$#{hex_word(bytes[0], bytes[1])}, Y", nil]
184
+ when :indirect
185
+ ["($#{hex_word(bytes[0], bytes[1])})", nil]
186
+ when :indirect_x
187
+ ["($#{hex_byte(bytes[0])}, X)", nil]
188
+ when :indirect_y
189
+ ["($#{hex_byte(bytes[0])}), Y", nil]
190
+ when :relative
191
+ target = branch_target(bytes[0], address)
192
+ [target ? format('$%04X', target) : '$????', target]
193
+ else
194
+ raise DisassemblyError, "Cannot format operand for #{mode}"
195
+ end
196
+ end
197
+
198
+ # Formats a byte as uppercase hex, using `??` for missing data.
199
+ #
200
+ # @param value [Integer, nil] the raw byte
201
+ # @return [String] the formatted byte
202
+ def hex_byte(value)
203
+ value.nil? ? '??' : format('%02X', value & 0xff)
204
+ end
205
+
206
+ # Formats a little-endian word as uppercase hex, preserving missing bytes.
207
+ #
208
+ # @param low [Integer, nil] the low byte
209
+ # @param high [Integer, nil] the high byte
210
+ # @return [String] the formatted word
211
+ def hex_word(low, high)
212
+ "#{hex_byte(high)}#{hex_byte(low)}"
213
+ end
214
+
215
+ # Resolves a relative branch target from an 8-bit signed displacement.
216
+ #
217
+ # @param offset [Integer, nil] the raw branch displacement
218
+ # @param address [Integer] the instruction address
219
+ # @return [Integer, nil] the absolute branch target
220
+ def branch_target(offset, address)
221
+ return nil if offset.nil?
222
+
223
+ ((address + 2 + signed_byte(offset)) & 0xffff)
224
+ end
225
+
226
+ # Converts an unsigned byte to a signed relative displacement.
227
+ #
228
+ # @param value [Integer] the raw byte
229
+ # @return [Integer] the signed offset
230
+ def signed_byte(value)
231
+ value >= 0x80 ? value - 0x100 : value
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOS6502
4
+ # Status register helpers for the processor flags.
5
+ #
6
+ # The bit order matches the packed 6502 status register:
7
+ # `N V 1 B D I Z C`.
8
+ module Flags
9
+ FLAGS = %w[negative overflow high break decimal interrupt zero carry].freeze
10
+ FLAG_MASKS = {
11
+ 'negative' => 0b1000_0000,
12
+ 'overflow' => 0b0100_0000,
13
+ 'break' => 0b0001_0000,
14
+ 'decimal' => 0b0000_1000,
15
+ 'interrupt' => 0b0000_0100,
16
+ 'zero' => 0b0000_0010,
17
+ 'carry' => 0b0000_0001
18
+ }.freeze
19
+ # Clears all mutable processor-status flags.
20
+ #
21
+ # Bit 5 (`high`) is implicit in the packed status register and is therefore
22
+ # not exposed as a mutable Ruby accessor.
23
+ #
24
+ # @return [void]
25
+ def flags_reset
26
+ FLAGS.each { |flag_name| instance_variable_set("@#{flag_name}", false) }
27
+ end
28
+
29
+ # Defines predicate and writer methods for each mutable flag when included.
30
+ #
31
+ # @param base [Class] the including class
32
+ # @return [void]
33
+ def self.included(base)
34
+ FLAGS.each do |flag_name|
35
+ next if flag_name == 'high'
36
+
37
+ base.define_method(:"#{flag_name}?") { instance_variable_get("@#{flag_name}") }
38
+ base.define_method(:"#{flag_name}=") { |value| instance_variable_set("@#{flag_name}", value) }
39
+ end
40
+ end
41
+
42
+ # Packs the current flags into an 8-bit processor-status value.
43
+ #
44
+ # @return [Integer] the encoded processor status register
45
+ def flags_encode
46
+ FLAGS.reduce(0) do |memo, flag_name|
47
+ bit = if flag_name == 'high'
48
+ 1
49
+ else
50
+ bit_value(instance_variable_get("@#{flag_name}"))
51
+ end
52
+ (memo << 1) + bit
53
+ end
54
+ end
55
+
56
+ # Unpacks an 8-bit processor-status value into the individual flags.
57
+ #
58
+ # @param value [Integer] the packed processor status register
59
+ # @return [void]
60
+ def flags_decode(value)
61
+ FLAG_MASKS.each do |flag_name, mask|
62
+ instance_variable_set("@#{flag_name}", value.anybits?(mask))
63
+ end
64
+ end
65
+
66
+ # Converts a Ruby boolean into the bit value used by the status register.
67
+ #
68
+ # @param bool [Boolean] the flag value
69
+ # @return [Integer] `1` when true, otherwise `0`
70
+ def bit_value(bool)
71
+ bool ? 1 : 0
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOS6502
4
+ # Raised when Intel HEX text is malformed or unsupported.
5
+ class IntelHexError < StandardError; end
6
+
7
+ # A minimal Intel HEX parser and loader for 16-bit 6502 address space.
8
+ #
9
+ # The current implementation supports:
10
+ # - data records (`00`)
11
+ # - end-of-file records (`01`)
12
+ #
13
+ # Extended address records are intentionally rejected for now because the
14
+ # emulator only exposes a 16-bit address bus.
15
+ class IntelHex
16
+ # A single parsed Intel HEX record.
17
+ Record = Struct.new(:address, :record_type, :data)
18
+
19
+ # A parsed Intel HEX document ready to load into a CPU.
20
+ Document = Struct.new(:records) do
21
+ # Returns only the data-bearing records in the document.
22
+ #
23
+ # @return [Array<Record>] the parsed data records
24
+ def data_records
25
+ records.select { |record| record.record_type.zero? }
26
+ end
27
+
28
+ # Returns the lowest data address present in the document.
29
+ #
30
+ # @return [Integer, nil] the first occupied address, if any
31
+ def start_address
32
+ data_records.map(&:address).min
33
+ end
34
+
35
+ # Loads the parsed records into a CPU.
36
+ #
37
+ # @param cpu [CPU] the destination CPU
38
+ # @param set_reset_vector [Boolean] whether to update the reset vector
39
+ # @return [Integer, nil] the lowest loaded address
40
+ def load_into(cpu, set_reset_vector: true)
41
+ first_address = start_address
42
+
43
+ data_records.each do |record|
44
+ cpu.load(record.data, start_address: record.address, set_reset_vector: false)
45
+ end
46
+
47
+ cpu.write_word(CPU::RESET_VECTOR, first_address) if set_reset_vector && first_address
48
+ first_address
49
+ end
50
+ end
51
+
52
+ # Parses Intel HEX text into a loadable document.
53
+ #
54
+ # @param source [String] the Intel HEX text
55
+ # @return [Document] the parsed document
56
+ def parse(source)
57
+ records = []
58
+ seen_eof = false
59
+
60
+ source.each_line.with_index(1) do |line, line_number|
61
+ stripped = line.strip
62
+ next if stripped.empty?
63
+
64
+ raise IntelHexError, "Unexpected data after EOF record on line #{line_number}" if seen_eof
65
+
66
+ record = parse_record(stripped, line_number)
67
+ records << record
68
+ seen_eof = true if record.record_type == 0x01
69
+ end
70
+
71
+ raise IntelHexError, 'Intel HEX document is missing an EOF record' unless seen_eof
72
+
73
+ Document.new(records:)
74
+ end
75
+
76
+ private
77
+
78
+ # Parses a single Intel HEX line and validates its checksum.
79
+ #
80
+ # @param line [String] the raw line content
81
+ # @param line_number [Integer] the source line number
82
+ # @return [Record] the parsed record
83
+ def parse_record(line, line_number)
84
+ raise IntelHexError, "Intel HEX line #{line_number} must start with ':'" unless line.start_with?(':')
85
+
86
+ hex = line[1..]
87
+ raise IntelHexError, "Intel HEX line #{line_number} has an odd number of hex digits" if hex.length.odd?
88
+
89
+ bytes = hex.scan(/../).map do |pair|
90
+ Integer(pair, 16)
91
+ rescue ArgumentError
92
+ raise IntelHexError, "Intel HEX line #{line_number} contains invalid hex"
93
+ end
94
+
95
+ raise IntelHexError, "Intel HEX line #{line_number} is too short" if bytes.length < 5
96
+
97
+ byte_count = bytes[0]
98
+ address = (bytes[1] << 8) | bytes[2]
99
+ record_type = bytes[3]
100
+ data = bytes[4...-1]
101
+ checksum = bytes[-1]
102
+
103
+ expected_length = byte_count + 5
104
+ raise IntelHexError, "Intel HEX line #{line_number} length does not match byte count" if bytes.length != expected_length
105
+
106
+ expected_checksum = ((-bytes[0...-1].sum) & 0xff)
107
+ raise IntelHexError, "Intel HEX line #{line_number} has an invalid checksum" if checksum != expected_checksum
108
+
109
+ raise IntelHexError, format('Intel HEX line %d uses unsupported record type 0x%02X', line_number, record_type) unless [0x00, 0x01].include?(record_type)
110
+
111
+ raise IntelHexError, "Intel HEX EOF record on line #{line_number} must not contain data" if record_type == 0x01 && !data.empty?
112
+
113
+ Record.new(address:, record_type:, data:)
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'bus'
4
+ require_relative 'cpu'
5
+
6
+ module MOS6502
7
+ # Small composition root for building a 6502-based computer.
8
+ #
9
+ # A machine owns a {Bus}, a {CPU}, and any mapped devices. It provides a
10
+ # cleaner place than {CPU} to describe whole-system memory maps such as
11
+ # "RAM here, ROM there, I/O in the middle".
12
+ class Machine
13
+ # @return [Bus] the machine address bus
14
+ attr_reader :bus
15
+ # @return [CPU] the attached CPU core
16
+ attr_reader :cpu
17
+
18
+ # Creates a new machine around a bus and CPU.
19
+ #
20
+ # @param bus [Bus] the machine bus
21
+ # @param cpu [CPU, nil] an optional pre-built CPU
22
+ # @param install_default_reset_vector [Boolean] whether to install the CPU's standalone reset vector
23
+ def initialize(bus: Bus.new, cpu: nil, install_default_reset_vector: false)
24
+ @bus = bus
25
+ @cpu = cpu || CPU.new(bus:, install_default_reset_vector:)
26
+ end
27
+
28
+ # Maps a RAM device into the machine address space.
29
+ #
30
+ # @param start_address [Integer] the first mapped address
31
+ # @param end_address [Integer] the last mapped address
32
+ # @param fill_byte [Integer] the byte used on RAM reset
33
+ # @param size [Integer, nil] an optional explicit RAM size
34
+ # @param device_offset [Integer] the first exposed device-local byte
35
+ # @return [RAM] the mapped RAM device
36
+ def map_ram(start_address:, end_address:, fill_byte: 0x00, size: nil, device_offset: 0)
37
+ length = end_address - start_address + 1
38
+ ram = RAM.new(size || length, fill_byte:)
39
+ bus.map(ram, start_address:, end_address:, device_offset:)
40
+ end
41
+
42
+ # Maps an arbitrary device into the machine address space.
43
+ #
44
+ # @param device [Device] the device to map
45
+ # @param start_address [Integer] the first mapped address
46
+ # @param end_address [Integer] the last mapped address
47
+ # @param device_offset [Integer] the first exposed device-local byte
48
+ # @return [Device] the mapped device
49
+ def map_device(device, start_address:, end_address:, device_offset: 0)
50
+ bus.map(device, start_address:, end_address:, device_offset:)
51
+ end
52
+
53
+ # Maps a ROM image into the machine address space.
54
+ #
55
+ # @param bytes [String, #to_a] the ROM image
56
+ # @param start_address [Integer] the first mapped address
57
+ # @param end_address [Integer, nil] the last mapped address
58
+ # @param device_offset [Integer] the first exposed device-local byte
59
+ # @param fill_byte [Integer] the byte returned for padded reads
60
+ # @return [ROM] the mapped ROM device
61
+ def map_rom(bytes, start_address:, end_address: nil, device_offset: 0, fill_byte: 0xff)
62
+ rom = ROM.new(bytes, fill_byte:)
63
+ end_address ||= start_address + rom.size - 1
64
+ bus.map(rom, start_address:, end_address:, device_offset:)
65
+ end
66
+
67
+ # Resets the CPU without erasing machine memory.
68
+ #
69
+ # @param program_counter [Integer, nil] an optional explicit reset address
70
+ # @return [Machine] the machine
71
+ def reset(program_counter: nil)
72
+ cpu.reset(program_counter:)
73
+ self
74
+ end
75
+
76
+ # Performs a full power-on sequence for the machine.
77
+ #
78
+ # Unlike the standalone CPU default, this does not install a synthetic reset
79
+ # vector, which allows ROM-mapped systems to boot from their real vectors.
80
+ #
81
+ # @return [Machine] the machine
82
+ def power_on
83
+ cpu.power_on(install_default_reset_vector: false)
84
+ self
85
+ end
86
+
87
+ # Loads raw bytes into the machine through the CPU convenience API.
88
+ #
89
+ # @param program [String, #to_a] the bytes to load
90
+ # @param start_address [Integer] where to place the first byte
91
+ # @param set_reset_vector [Boolean] whether to update the reset vector
92
+ # @return [Integer] the start address used for the load
93
+ def load(program, start_address: CPU::DEFAULT_LOAD_ADDRESS, set_reset_vector: true)
94
+ cpu.load(program, start_address:, set_reset_vector:)
95
+ end
96
+
97
+ # Loads Intel HEX text into the machine.
98
+ #
99
+ # @param source [String] the Intel HEX text to parse
100
+ # @param set_reset_vector [Boolean] whether to update the reset vector
101
+ # @return [Integer, nil] the lowest loaded address
102
+ def load_intel_hex(source, set_reset_vector: true)
103
+ cpu.load_intel_hex(source, set_reset_vector:)
104
+ end
105
+
106
+ # Executes one CPU instruction.
107
+ #
108
+ # @return [Integer] the executed opcode byte
109
+ def step
110
+ opcode = cpu.step
111
+ bus.tick(cpu.last_cycles)
112
+ opcode
113
+ end
114
+
115
+ # Executes a fixed number of CPU instructions.
116
+ #
117
+ # @param max_instructions [Integer] the number of instructions to execute
118
+ # @return [Machine] the machine
119
+ def run(max_instructions:)
120
+ max_instructions.times { step }
121
+ self
122
+ end
123
+
124
+ # Services a maskable interrupt and ticks mapped devices when taken.
125
+ #
126
+ # @return [Boolean] true when the IRQ was taken
127
+ def irq
128
+ taken = cpu.irq
129
+ bus.tick(7) if taken
130
+ taken
131
+ end
132
+
133
+ # Services a non-maskable interrupt and ticks mapped devices.
134
+ #
135
+ # @return [Boolean] always true
136
+ def nmi
137
+ cpu.nmi.tap { bus.tick(7) }
138
+ end
139
+ end
140
+ end