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,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