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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +189 -0
- data/bin/assemble +122 -0
- data/bin/disassemble +58 -0
- data/bin/run_assembled_program +80 -0
- data/bin/run_klaus_functional_test +19 -0
- data/examples/README.md +147 -0
- data/examples/branching.asm +16 -0
- data/examples/countdown.asm +17 -0
- data/examples/labels_and_data.asm +28 -0
- data/examples/primes.asm +61 -0
- data/examples/simple_machine.rb +25 -0
- data/examples/traced_machine.rb +55 -0
- data/lib/mos6502/workbench/assembler.rb +725 -0
- data/lib/mos6502/workbench/bus.rb +264 -0
- data/lib/mos6502/workbench/cpu.rb +1292 -0
- data/lib/mos6502/workbench/device.rb +64 -0
- data/lib/mos6502/workbench/disassembler.rb +234 -0
- data/lib/mos6502/workbench/flags.rb +74 -0
- data/lib/mos6502/workbench/intel_hex.rb +116 -0
- data/lib/mos6502/workbench/machine.rb +140 -0
- data/lib/mos6502/workbench/memory.rb +159 -0
- data/lib/mos6502/workbench/registers.rb +19 -0
- data/lib/mos6502/workbench/tui.rb +537 -0
- data/lib/mos6502/workbench/version.rb +9 -0
- data/lib/mos6502/workbench.rb +12 -0
- metadata +126 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'device'
|
|
4
|
+
|
|
5
|
+
module MOS6502
|
|
6
|
+
# Shared CPU memory helpers backed by an external bus.
|
|
7
|
+
#
|
|
8
|
+
# The CPU still exposes convenient `read_byte`, `write_byte`, `read_word`, and
|
|
9
|
+
# `write_word` helpers, but the actual storage and address decoding now live in
|
|
10
|
+
# {Bus} and mapped devices such as {RAM} and {ROM}.
|
|
11
|
+
module Memory
|
|
12
|
+
MEMORY_SIZE = 0x10000
|
|
13
|
+
|
|
14
|
+
# Resets the attached bus and any mapped devices.
|
|
15
|
+
#
|
|
16
|
+
# @return [Bus] the reset bus
|
|
17
|
+
def memory_reset
|
|
18
|
+
@bus.reset
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Reads a single byte from the current bus.
|
|
22
|
+
#
|
|
23
|
+
# @param addr [Integer] the address to read from
|
|
24
|
+
# @return [Integer] the byte stored at the wrapped address
|
|
25
|
+
def read_byte(addr)
|
|
26
|
+
@bus.read_byte(addr)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Writes a single byte to the current bus.
|
|
30
|
+
#
|
|
31
|
+
# @param addr [Integer] the address to write to
|
|
32
|
+
# @param value [Integer] the value to store
|
|
33
|
+
# @return [Integer] the stored byte value
|
|
34
|
+
def write_byte(addr, value)
|
|
35
|
+
@bus.write_byte(addr, value)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Writes a 16-bit little-endian word through the bus.
|
|
39
|
+
#
|
|
40
|
+
# @param addr [Integer] the starting address
|
|
41
|
+
# @param value [Integer] the 16-bit value to write
|
|
42
|
+
# @return [Integer] the written word value
|
|
43
|
+
def write_word(addr, value)
|
|
44
|
+
write_byte(addr, value)
|
|
45
|
+
write_byte(addr + 1, value >> 8)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Reads a 16-bit little-endian word through the bus.
|
|
49
|
+
#
|
|
50
|
+
# @param addr [Integer] the starting address
|
|
51
|
+
# @return [Integer] the reconstructed 16-bit value
|
|
52
|
+
def read_word(addr)
|
|
53
|
+
read_byte(addr) | (read_byte(addr + 1) << 8)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Simple read-write memory device.
|
|
58
|
+
#
|
|
59
|
+
# RAM stores a fixed-size byte array and wraps offsets to the configured device
|
|
60
|
+
# size, which makes it suitable for use either as full system RAM or as a
|
|
61
|
+
# smaller device window mapped into a subset of the address space.
|
|
62
|
+
class RAM < Device
|
|
63
|
+
# @return [Integer] the size of the device in bytes
|
|
64
|
+
attr_reader :size
|
|
65
|
+
|
|
66
|
+
# Creates a new RAM device.
|
|
67
|
+
#
|
|
68
|
+
# @param size [Integer] the size of the device in bytes
|
|
69
|
+
# @param fill_byte [Integer] the byte used to initialise memory on reset
|
|
70
|
+
# @raise [ArgumentError] if the size is not positive
|
|
71
|
+
def initialize(size = Memory::MEMORY_SIZE, fill_byte: 0x00)
|
|
72
|
+
super()
|
|
73
|
+
raise ArgumentError, 'RAM size must be positive' unless size.positive?
|
|
74
|
+
|
|
75
|
+
@size = size
|
|
76
|
+
@fill_byte = fill_byte & 0xff
|
|
77
|
+
reset
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Resets every byte in the RAM device to its fill byte.
|
|
81
|
+
#
|
|
82
|
+
# @return [RAM] the RAM device
|
|
83
|
+
def reset
|
|
84
|
+
@bytes = Array.new(size, @fill_byte)
|
|
85
|
+
self
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Reads a byte from the RAM device.
|
|
89
|
+
#
|
|
90
|
+
# @param address [Integer] the device-local byte offset
|
|
91
|
+
# @return [Integer] the stored byte value
|
|
92
|
+
def read_byte(address)
|
|
93
|
+
@bytes.fetch(normalize_offset(address))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Writes a byte into the RAM device.
|
|
97
|
+
#
|
|
98
|
+
# @param address [Integer] the device-local byte offset
|
|
99
|
+
# @param value [Integer] the value to store
|
|
100
|
+
# @return [Integer] the stored byte value
|
|
101
|
+
def write_byte(address, value)
|
|
102
|
+
@bytes[normalize_offset(address)] = value & 0xff
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# Wraps an address to the size of the RAM device.
|
|
108
|
+
#
|
|
109
|
+
# @param address [Integer] the device-local byte offset
|
|
110
|
+
# @return [Integer] the wrapped device-local offset
|
|
111
|
+
def normalize_offset(address)
|
|
112
|
+
address % size
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Read-only memory device.
|
|
117
|
+
#
|
|
118
|
+
# Writes are ignored, matching the common emulator convention for ROM-backed
|
|
119
|
+
# regions.
|
|
120
|
+
class ROM < Device
|
|
121
|
+
# @return [Integer] the size of the ROM image in bytes
|
|
122
|
+
attr_reader :size
|
|
123
|
+
|
|
124
|
+
# Creates a new ROM device from raw bytes.
|
|
125
|
+
#
|
|
126
|
+
# @param bytes [String, #to_a] the ROM image
|
|
127
|
+
# @param fill_byte [Integer] the byte returned for padded reads
|
|
128
|
+
def initialize(bytes, fill_byte: 0xff)
|
|
129
|
+
super()
|
|
130
|
+
@bytes = bytes.is_a?(String) ? bytes.bytes : bytes.to_a
|
|
131
|
+
@fill_byte = fill_byte & 0xff
|
|
132
|
+
@size = @bytes.length
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# ROM reset is a no-op because the contents are immutable.
|
|
136
|
+
#
|
|
137
|
+
# @return [ROM] the ROM device
|
|
138
|
+
def reset
|
|
139
|
+
self
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Reads a byte from the ROM image.
|
|
143
|
+
#
|
|
144
|
+
# @param address [Integer] the device-local byte offset
|
|
145
|
+
# @return [Integer] the stored byte, or the fill byte for padded reads
|
|
146
|
+
def read_byte(address)
|
|
147
|
+
@bytes.fetch(address, @fill_byte)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Ignores writes to the ROM image.
|
|
151
|
+
#
|
|
152
|
+
# @param _address [Integer] the device-local byte offset
|
|
153
|
+
# @param value [Integer] the attempted write value
|
|
154
|
+
# @return [Integer] the masked attempted write value
|
|
155
|
+
def write_byte(_address, value)
|
|
156
|
+
value & 0xff
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MOS6502
|
|
4
|
+
# Register helpers shared by the CPU implementation.
|
|
5
|
+
module Registers
|
|
6
|
+
# The 6502 general-purpose registers:
|
|
7
|
+
# accumulator (`A`), index register X, and index register Y.
|
|
8
|
+
attr_accessor :register_x, :register_y, :accumulator
|
|
9
|
+
|
|
10
|
+
# Restores the programmer-visible data registers to their reset state.
|
|
11
|
+
#
|
|
12
|
+
# @return [void]
|
|
13
|
+
def registers_reset
|
|
14
|
+
@register_x = 0
|
|
15
|
+
@register_y = 0
|
|
16
|
+
@accumulator = 0
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ratatui_ruby'
|
|
4
|
+
|
|
5
|
+
module MOS6502
|
|
6
|
+
# Interactive terminal visualizer for the 6502 CPU state.
|
|
7
|
+
#
|
|
8
|
+
# The TUI is intentionally optional. The rest of the toolchain still works in
|
|
9
|
+
# plain CLI mode, while this class provides a richer execution cockpit when
|
|
10
|
+
# launched explicitly.
|
|
11
|
+
class TUI
|
|
12
|
+
HistoryEntry = Data.define(:instruction_count, :address, :opcode, :text)
|
|
13
|
+
|
|
14
|
+
DEFAULT_STEP_DELAYS = [0.40, 0.20, 0.10, 0.05, 0.02].freeze
|
|
15
|
+
DEFAULT_WATCH_BASE = 0x0000
|
|
16
|
+
DEFAULT_THEME = :classic
|
|
17
|
+
DISASSEMBLY_WINDOW_SIZE = 18
|
|
18
|
+
HISTORY_LIMIT = 18
|
|
19
|
+
TRACE_LIMIT = 48
|
|
20
|
+
|
|
21
|
+
THEMES = {
|
|
22
|
+
classic: {
|
|
23
|
+
surface: 235,
|
|
24
|
+
panel_header: 236,
|
|
25
|
+
panel_program: 235,
|
|
26
|
+
panel_registers: 236,
|
|
27
|
+
panel_trace: 235,
|
|
28
|
+
panel_history: 235,
|
|
29
|
+
panel_status: 236,
|
|
30
|
+
panel_pc: 235,
|
|
31
|
+
panel_watch: 236,
|
|
32
|
+
panel_stack: 235,
|
|
33
|
+
panel_footer: 234,
|
|
34
|
+
text: 255,
|
|
35
|
+
muted: 245,
|
|
36
|
+
accent: 45,
|
|
37
|
+
accent_alt: 78,
|
|
38
|
+
warm: 221,
|
|
39
|
+
danger: 203,
|
|
40
|
+
highlight: 250
|
|
41
|
+
}.freeze,
|
|
42
|
+
vivid: {
|
|
43
|
+
surface: 17,
|
|
44
|
+
panel_header: 19,
|
|
45
|
+
panel_program: 23,
|
|
46
|
+
panel_registers: 54,
|
|
47
|
+
panel_trace: 22,
|
|
48
|
+
panel_history: 58,
|
|
49
|
+
panel_status: 28,
|
|
50
|
+
panel_pc: 53,
|
|
51
|
+
panel_watch: 52,
|
|
52
|
+
panel_stack: 18,
|
|
53
|
+
panel_footer: 17,
|
|
54
|
+
text: 255,
|
|
55
|
+
muted: 250,
|
|
56
|
+
accent: 51,
|
|
57
|
+
accent_alt: 83,
|
|
58
|
+
warm: 227,
|
|
59
|
+
danger: 204,
|
|
60
|
+
highlight: 230
|
|
61
|
+
}.freeze,
|
|
62
|
+
mono: {
|
|
63
|
+
surface: 235,
|
|
64
|
+
panel_header: 236,
|
|
65
|
+
panel_program: 235,
|
|
66
|
+
panel_registers: 236,
|
|
67
|
+
panel_trace: 235,
|
|
68
|
+
panel_history: 235,
|
|
69
|
+
panel_status: 236,
|
|
70
|
+
panel_pc: 235,
|
|
71
|
+
panel_watch: 236,
|
|
72
|
+
panel_stack: 235,
|
|
73
|
+
panel_footer: 234,
|
|
74
|
+
text: 255,
|
|
75
|
+
muted: 247,
|
|
76
|
+
accent: 252,
|
|
77
|
+
accent_alt: 250,
|
|
78
|
+
warm: 252,
|
|
79
|
+
danger: 255,
|
|
80
|
+
highlight: 248
|
|
81
|
+
}.freeze
|
|
82
|
+
}.freeze
|
|
83
|
+
|
|
84
|
+
attr_reader :cpu, :program, :title, :history, :error_message, :watch_base, :paused, :running, :step_delays, :speed_index, :theme_name
|
|
85
|
+
|
|
86
|
+
# @return [Array<Symbol>] the supported theme names
|
|
87
|
+
def self.theme_names
|
|
88
|
+
THEMES.keys
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @param cpu [CPU] the CPU instance being visualized
|
|
92
|
+
# @param program [Assembler::Program] the loaded program metadata
|
|
93
|
+
# @param title [String] the title shown in the header
|
|
94
|
+
# @param step_delays [Array<Float>] available autoplay speeds in seconds per instruction
|
|
95
|
+
# @param start_paused [Boolean] whether to begin in paused mode
|
|
96
|
+
# @param watch_base [Integer, nil] the start address of the adjustable memory panel
|
|
97
|
+
# @param theme [String, Symbol] the visual theme name
|
|
98
|
+
def initialize(cpu:, program:, title:, step_delays: DEFAULT_STEP_DELAYS, start_paused: true, watch_base: nil, theme: DEFAULT_THEME)
|
|
99
|
+
@cpu = cpu
|
|
100
|
+
@program = program
|
|
101
|
+
@title = title
|
|
102
|
+
@step_delays = step_delays
|
|
103
|
+
@paused = start_paused
|
|
104
|
+
@running = true
|
|
105
|
+
@watch_base = resolve_watch_base(watch_base)
|
|
106
|
+
@speed_index = [step_delays.length / 2, step_delays.length - 1].min
|
|
107
|
+
@theme_name, @palette = resolve_theme(theme)
|
|
108
|
+
@error_message = nil
|
|
109
|
+
@status_message = start_paused ? 'Paused at reset vector. Press n to step or space to run.' : 'Running.'
|
|
110
|
+
@history = []
|
|
111
|
+
@accumulator_trace = [cpu.accumulator]
|
|
112
|
+
@listing = build_listing
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Starts the interactive terminal session.
|
|
116
|
+
#
|
|
117
|
+
# @return [TUI] the TUI instance
|
|
118
|
+
def run
|
|
119
|
+
RatatuiRuby.run do
|
|
120
|
+
while running
|
|
121
|
+
RatatuiRuby.draw { |frame| render(frame) }
|
|
122
|
+
event = RatatuiRuby.poll_event(timeout: paused ? 0.10 : current_step_delay)
|
|
123
|
+
process_event_loop(event)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
self
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Renders a single frame.
|
|
131
|
+
#
|
|
132
|
+
# @param frame [RatatuiRuby::Frame] the current frame
|
|
133
|
+
# @return [void]
|
|
134
|
+
def render(frame)
|
|
135
|
+
header_area, body_area, footer_area = split(frame.area, :vertical, [
|
|
136
|
+
constraint(:length, 3),
|
|
137
|
+
constraint(:fill, 1),
|
|
138
|
+
constraint(:length, 3)
|
|
139
|
+
])
|
|
140
|
+
|
|
141
|
+
left_area, right_area = split(body_area, :horizontal, [
|
|
142
|
+
constraint(:fill, 1),
|
|
143
|
+
constraint(:length, 38)
|
|
144
|
+
])
|
|
145
|
+
|
|
146
|
+
disassembly_area, history_area = split(left_area, :vertical, [
|
|
147
|
+
constraint(:fill, 1),
|
|
148
|
+
constraint(:length, 10)
|
|
149
|
+
])
|
|
150
|
+
|
|
151
|
+
metrics_area, status_area, memory_area = split(right_area, :vertical, [
|
|
152
|
+
constraint(:length, 8),
|
|
153
|
+
constraint(:length, 5),
|
|
154
|
+
constraint(:fill, 1)
|
|
155
|
+
])
|
|
156
|
+
|
|
157
|
+
registers_area, trace_area = split(metrics_area, :vertical, [
|
|
158
|
+
constraint(:length, 4),
|
|
159
|
+
constraint(:fill, 1)
|
|
160
|
+
])
|
|
161
|
+
|
|
162
|
+
current_page_area, watch_area, stack_area = split(memory_area, :vertical, [
|
|
163
|
+
constraint(:fill, 1),
|
|
164
|
+
constraint(:length, 8),
|
|
165
|
+
constraint(:length, 8)
|
|
166
|
+
])
|
|
167
|
+
|
|
168
|
+
pc_rows = 6
|
|
169
|
+
pc_cols = memory_columns(current_page_area)
|
|
170
|
+
watch_rows = 4
|
|
171
|
+
watch_cols = memory_columns(watch_area)
|
|
172
|
+
|
|
173
|
+
frame.render_widget(header_widget, header_area)
|
|
174
|
+
frame.render_widget(disassembly_widget, disassembly_area)
|
|
175
|
+
frame.render_widget(history_widget, history_area)
|
|
176
|
+
frame.render_widget(registers_widget, registers_area)
|
|
177
|
+
frame.render_widget(trace_widget, trace_area)
|
|
178
|
+
frame.render_widget(status_widget, status_area)
|
|
179
|
+
frame.render_widget(
|
|
180
|
+
memory_widget('PC Window', pc_window_base(rows: pc_rows, cols: pc_cols), rows: pc_rows, cols: pc_cols,
|
|
181
|
+
highlight: cpu.program_counter), current_page_area
|
|
182
|
+
)
|
|
183
|
+
frame.render_widget(memory_widget("Watch $#{format('%04X', watch_base)}", watch_base, rows: watch_rows, cols: watch_cols), watch_area)
|
|
184
|
+
frame.render_widget(stack_widget, stack_area)
|
|
185
|
+
frame.render_widget(footer_widget, footer_area)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Handles a single event.
|
|
189
|
+
#
|
|
190
|
+
# @param event [RatatuiRuby::Event] the event to process
|
|
191
|
+
# @return [void]
|
|
192
|
+
def handle_event(event)
|
|
193
|
+
if event.resize?
|
|
194
|
+
@status_message = "Resized to #{event.width}x#{event.height}."
|
|
195
|
+
return
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
return unless event.key?
|
|
199
|
+
|
|
200
|
+
case event.code
|
|
201
|
+
when 'q', 'esc'
|
|
202
|
+
@running = false
|
|
203
|
+
when ' '
|
|
204
|
+
toggle_pause
|
|
205
|
+
when 'n'
|
|
206
|
+
step_once if paused
|
|
207
|
+
when 'r'
|
|
208
|
+
reset_cpu
|
|
209
|
+
when '['
|
|
210
|
+
adjust_speed(-1)
|
|
211
|
+
when ']'
|
|
212
|
+
adjust_speed(1)
|
|
213
|
+
when 'h'
|
|
214
|
+
move_watch(-0x20)
|
|
215
|
+
when 'l'
|
|
216
|
+
move_watch(0x20)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Executes a single instruction and records it for the UI.
|
|
221
|
+
#
|
|
222
|
+
# @return [Integer, nil] the opcode that was stepped, if any
|
|
223
|
+
def step_once
|
|
224
|
+
line = current_line
|
|
225
|
+
opcode = cpu.step
|
|
226
|
+
append_history(line, opcode)
|
|
227
|
+
@error_message = nil
|
|
228
|
+
@status_message = "Stepped #{line&.text || format('.byte $%02X', opcode)}."
|
|
229
|
+
opcode
|
|
230
|
+
rescue StandardError => e
|
|
231
|
+
@paused = true
|
|
232
|
+
@error_message = e.message
|
|
233
|
+
@status_message = 'Execution paused due to an error.'
|
|
234
|
+
nil
|
|
235
|
+
ensure
|
|
236
|
+
trim_accumulator_trace
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Resets the CPU and clears transient view state.
|
|
240
|
+
#
|
|
241
|
+
# @return [void]
|
|
242
|
+
def reset_cpu
|
|
243
|
+
cpu.reset
|
|
244
|
+
@history.clear
|
|
245
|
+
@accumulator_trace = [cpu.accumulator]
|
|
246
|
+
@error_message = nil
|
|
247
|
+
@paused = true
|
|
248
|
+
@status_message = 'CPU reset. Press n to step or space to run.'
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
private
|
|
252
|
+
|
|
253
|
+
attr_reader :palette
|
|
254
|
+
|
|
255
|
+
def resolve_theme(theme)
|
|
256
|
+
name = theme.to_sym
|
|
257
|
+
palette = THEMES[name]
|
|
258
|
+
raise ArgumentError, "Unknown TUI theme #{theme.inspect}. Available themes: #{THEMES.keys.join(', ')}" unless palette
|
|
259
|
+
|
|
260
|
+
[name, palette]
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def resolve_watch_base(watch_base)
|
|
264
|
+
return watch_base & 0xffff unless watch_base.nil?
|
|
265
|
+
|
|
266
|
+
default_watch_base
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def default_watch_base
|
|
270
|
+
(program.entry_point || program.start_address || DEFAULT_WATCH_BASE) & 0xffff
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def process_event_loop(event)
|
|
274
|
+
if event.none?
|
|
275
|
+
step_once unless paused
|
|
276
|
+
else
|
|
277
|
+
handle_event(event)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def build_listing
|
|
282
|
+
program.segments.sort_by(&:start_address).flat_map do |segment|
|
|
283
|
+
Disassembler.new.disassemble(segment.bytes, start_address: segment.start_address)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def current_line
|
|
288
|
+
@listing.find { |line| line.address == cpu.program_counter }
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def current_listing_index
|
|
292
|
+
@listing.index { |line| line.address == cpu.program_counter } || 0
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def current_step_delay
|
|
296
|
+
step_delays.fetch(speed_index)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def toggle_pause
|
|
300
|
+
@paused = !paused
|
|
301
|
+
@status_message = paused ? 'Paused.' : format('Running at %.2fs per instruction.', current_step_delay)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def adjust_speed(delta)
|
|
305
|
+
@speed_index = (speed_index + delta).clamp(0, step_delays.length - 1)
|
|
306
|
+
@status_message = format('Speed set to %.2fs per instruction.', current_step_delay)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def move_watch(delta)
|
|
310
|
+
@watch_base = (watch_base + delta) & 0xffff
|
|
311
|
+
@status_message = format('Watch window moved to $%04X.', watch_base)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def append_history(line, opcode)
|
|
315
|
+
entry = HistoryEntry.new(
|
|
316
|
+
instruction_count: cpu.instruction_count,
|
|
317
|
+
address: line&.address || cpu.program_counter,
|
|
318
|
+
opcode: opcode,
|
|
319
|
+
text: line&.text || format('.byte $%02X', opcode)
|
|
320
|
+
)
|
|
321
|
+
@history.unshift(entry)
|
|
322
|
+
@history.slice!(HISTORY_LIMIT, @history.length)
|
|
323
|
+
@accumulator_trace << cpu.accumulator
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def trim_accumulator_trace
|
|
327
|
+
@accumulator_trace = @accumulator_trace.last(TRACE_LIMIT)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def pc_window_base(rows:, cols:)
|
|
331
|
+
window_size = rows * cols
|
|
332
|
+
centered_base = (cpu.program_counter - (window_size / 2)) & 0xffff
|
|
333
|
+
(centered_base - (centered_base % cols)) & 0xffff
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def memory_columns(area, max_cols: 8)
|
|
337
|
+
inner_width = [area.width - 2, 1].max
|
|
338
|
+
usable_width = [inner_width - 7, 1].max
|
|
339
|
+
(usable_width / 4).clamp(1, max_cols)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def split(area, direction, constraints)
|
|
343
|
+
RatatuiRuby::Layout::Layout.split(area, direction:, constraints:)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def constraint(kind, value)
|
|
347
|
+
RatatuiRuby::Layout::Constraint.public_send(kind, value)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def panel_block(title, color:, background: palette[:surface])
|
|
351
|
+
RatatuiRuby::Widgets::Block.new(
|
|
352
|
+
title:,
|
|
353
|
+
borders: %i[top right bottom left],
|
|
354
|
+
border_style: style(fg: color, bg: background, modifiers: [:bold]),
|
|
355
|
+
style: style(bg: background)
|
|
356
|
+
)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def style(fg: nil, bg: nil, modifiers: [])
|
|
360
|
+
RatatuiRuby::Style::Style.new(fg:, bg:, modifiers:)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def header_widget
|
|
364
|
+
RatatuiRuby::Widgets::Paragraph.new(
|
|
365
|
+
text: [
|
|
366
|
+
"MOS 6502 Studio #{title}",
|
|
367
|
+
format('PC $%04X OP %s Mode %s', cpu.program_counter, last_opcode_text, paused ? 'PAUSED' : 'RUNNING')
|
|
368
|
+
].join("\n"),
|
|
369
|
+
block: panel_block('Live Session', color: palette[:accent], background: palette[:panel_header]),
|
|
370
|
+
style: style(fg: palette[:text], bg: palette[:panel_header], modifiers: %i[bold])
|
|
371
|
+
)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def disassembly_widget
|
|
375
|
+
current_index = current_listing_index
|
|
376
|
+
window_start = disassembly_window_start(current_index)
|
|
377
|
+
window_end = [window_start + DISASSEMBLY_WINDOW_SIZE, @listing.length].min
|
|
378
|
+
visible = @listing[window_start...window_end]
|
|
379
|
+
selected = visible.index { |line| line.address == cpu.program_counter } || 0
|
|
380
|
+
|
|
381
|
+
items = if visible.empty?
|
|
382
|
+
['No decoded instructions available.']
|
|
383
|
+
else
|
|
384
|
+
visible.map do |line|
|
|
385
|
+
bytes = line.bytes.map { |byte| format('%02X', byte) }.join(' ').ljust(8)
|
|
386
|
+
format('$%04X %-8s %s', line.address, bytes, line.text)
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
RatatuiRuby::Widgets::List.new(
|
|
391
|
+
items:,
|
|
392
|
+
selected_index: selected,
|
|
393
|
+
highlight_symbol: '>> ',
|
|
394
|
+
highlight_style: style(fg: 232, bg: palette[:highlight], modifiers: %i[bold]),
|
|
395
|
+
block: panel_block('Program', color: palette[:accent], background: palette[:panel_program]),
|
|
396
|
+
style: style(fg: palette[:text], bg: palette[:panel_program])
|
|
397
|
+
)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def disassembly_window_start(current_index)
|
|
401
|
+
max_start = [@listing.length - DISASSEMBLY_WINDOW_SIZE, 0].max
|
|
402
|
+
preferred_start = current_index - (DISASSEMBLY_WINDOW_SIZE / 2)
|
|
403
|
+
|
|
404
|
+
preferred_start.clamp(0, max_start)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def history_widget
|
|
408
|
+
items = if history.empty?
|
|
409
|
+
['No instructions executed yet.']
|
|
410
|
+
else
|
|
411
|
+
history.map do |entry|
|
|
412
|
+
format('#%04d $%04X %s', entry.instruction_count, entry.address, entry.text)
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
RatatuiRuby::Widgets::List.new(
|
|
417
|
+
items:,
|
|
418
|
+
block: panel_block('Recent Instructions', color: palette[:warm], background: palette[:panel_history]),
|
|
419
|
+
style: style(fg: palette[:text], bg: palette[:panel_history])
|
|
420
|
+
)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def registers_widget
|
|
424
|
+
RatatuiRuby::Widgets::Paragraph.new(
|
|
425
|
+
text: [
|
|
426
|
+
format('A $%02X X $%02X Y $%02X', cpu.accumulator, cpu.register_x, cpu.register_y),
|
|
427
|
+
format('PC $%04X SP $%02X OP %s', cpu.program_counter, cpu.stack_pointer, last_opcode_text)
|
|
428
|
+
].join("\n"),
|
|
429
|
+
block: panel_block('Registers', color: palette[:accent], background: palette[:panel_registers]),
|
|
430
|
+
style: style(fg: palette[:text], bg: palette[:panel_registers], modifiers: %i[bold])
|
|
431
|
+
)
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def trace_widget
|
|
435
|
+
RatatuiRuby::Widgets::Sparkline.new(
|
|
436
|
+
data: @accumulator_trace,
|
|
437
|
+
max: 255,
|
|
438
|
+
block: panel_block('Accumulator Trace', color: palette[:accent_alt], background: palette[:panel_trace]),
|
|
439
|
+
style: style(fg: palette[:accent_alt], bg: palette[:panel_trace])
|
|
440
|
+
)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def status_widget
|
|
444
|
+
RatatuiRuby::Widgets::Paragraph.new(
|
|
445
|
+
text: [
|
|
446
|
+
format('STATUS $%02X COUNT %d', cpu.flags_encode, cpu.instruction_count),
|
|
447
|
+
flag_summary,
|
|
448
|
+
format('Speed %.2fs Watch $%04X', current_step_delay, watch_base)
|
|
449
|
+
].join("\n"),
|
|
450
|
+
block: panel_block('CPU State', color: palette[:accent_alt], background: palette[:panel_status]),
|
|
451
|
+
style: style(fg: palette[:text], bg: palette[:panel_status])
|
|
452
|
+
)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def flag_summary
|
|
456
|
+
[
|
|
457
|
+
flag_chip('N', cpu.negative?),
|
|
458
|
+
flag_chip('V', cpu.overflow?),
|
|
459
|
+
flag_chip('B', cpu.break?),
|
|
460
|
+
flag_chip('D', cpu.decimal?),
|
|
461
|
+
flag_chip('I', cpu.interrupt?),
|
|
462
|
+
flag_chip('Z', cpu.zero?),
|
|
463
|
+
flag_chip('C', cpu.carry?)
|
|
464
|
+
].join(' ')
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def flag_chip(label, active)
|
|
468
|
+
active ? "[#{label}]" : " #{label} "
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def memory_widget(title, base, rows:, cols:, highlight: nil)
|
|
472
|
+
background = case title
|
|
473
|
+
when 'PC Window' then palette[:panel_pc]
|
|
474
|
+
when /\AWatch/ then palette[:panel_watch]
|
|
475
|
+
else palette[:surface]
|
|
476
|
+
end
|
|
477
|
+
RatatuiRuby::Widgets::Paragraph.new(
|
|
478
|
+
text: memory_dump(base, rows:, cols:, highlight:),
|
|
479
|
+
block: panel_block(title, color: palette[:warm], background:),
|
|
480
|
+
style: style(fg: palette[:text], bg: background)
|
|
481
|
+
)
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def stack_widget
|
|
485
|
+
RatatuiRuby::Widgets::Paragraph.new(
|
|
486
|
+
text: stack_dump,
|
|
487
|
+
block: panel_block('Stack', color: palette[:accent], background: palette[:panel_stack]),
|
|
488
|
+
style: style(fg: palette[:text], bg: palette[:panel_stack])
|
|
489
|
+
)
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def footer_widget
|
|
493
|
+
color = error_message ? palette[:danger] : palette[:muted]
|
|
494
|
+
message = error_message || @status_message
|
|
495
|
+
|
|
496
|
+
RatatuiRuby::Widgets::Paragraph.new(
|
|
497
|
+
text: [
|
|
498
|
+
'Controls: space run/pause n step r reset [ ] speed h l memory q quit',
|
|
499
|
+
message
|
|
500
|
+
].join("\n"),
|
|
501
|
+
block: panel_block('Command Deck', color:, background: palette[:panel_footer]),
|
|
502
|
+
style: style(fg: palette[:text], bg: palette[:panel_footer], modifiers: %i[bold])
|
|
503
|
+
)
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def memory_dump(base, rows:, cols:, highlight: nil)
|
|
507
|
+
rows.times.map do |row|
|
|
508
|
+
address = (base + (row * cols)) & 0xffff
|
|
509
|
+
bytes = cols.times.map do |offset|
|
|
510
|
+
cell_address = (address + offset) & 0xffff
|
|
511
|
+
token = format('%02X', cpu.read_byte(cell_address))
|
|
512
|
+
cell_address == highlight ? ">#{token}<" : " #{token} "
|
|
513
|
+
end.join
|
|
514
|
+
format('$%04X: %s', address, bytes)
|
|
515
|
+
end.join("\n")
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def stack_dump
|
|
519
|
+
stack_top = (cpu.stack_pointer + 1) & 0xff
|
|
520
|
+
lines = [
|
|
521
|
+
format('SP $%02X next pop $%04X', cpu.stack_pointer, 0x0100 | stack_top)
|
|
522
|
+
]
|
|
523
|
+
|
|
524
|
+
5.times do |index|
|
|
525
|
+
address = 0x0100 | ((stack_top + index) & 0xff)
|
|
526
|
+
marker = index.zero? ? ' <- top' : ''
|
|
527
|
+
lines << format('$%04X: %02X%s', address, cpu.read_byte(address), marker)
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
lines.join("\n")
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def last_opcode_text
|
|
534
|
+
cpu.last_opcode.nil? ? '--' : format('$%02X', cpu.last_opcode)
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
end
|