From 096c95e2c9777485ccc14d06e1e2eb7d51db890a Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 23 Aug 2026 21:58:44 +0200 Subject: [PATCH] feat(core): add interactive console debugger --- src/Chip8.Core/DebuggerConsole.cs | 288 ++++++++++++++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 src/Chip8.Core/DebuggerConsole.cs diff --git a/src/Chip8.Core/DebuggerConsole.cs b/src/Chip8.Core/DebuggerConsole.cs new file mode 100644 index 0000000..ce5a3fa --- /dev/null +++ b/src/Chip8.Core/DebuggerConsole.cs @@ -0,0 +1,288 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace Chip8.Core; + +public sealed class DebuggerConsole +{ + const int defaultRunSteps = 1_000_000; + + public DebuggerConsole(Debugger debugger) + { + this.debugger = debugger; + } + + public Debugger debugger { get; } + + public bool quitRequested { get; private set; } + + public string execute(string line) + { + string[] parts = line.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) + { + return string.Empty; + } + + string command = parts[0].ToLowerInvariant(); + return command switch + { + "help" or "h" or "?" => help(), + "s" or "step" => step(parts), + "so" => stepOver(), + "c" or "cont" or "continue" => run(parts), + "u" or "until" => runUntil(parts), + "b" or "break" => addBreakpoint(parts), + "rb" or "delete" => removeBreakpoint(parts), + "bl" => listBreakpoints(), + "wm" => watchMemory(parts), + "wr" => watchRegister(parts), + "wl" => listWatches(), + "wc" => clearWatches(), + "r" or "regs" => registers(), + "mem" or "x" => memory(parts), + "dis" or "d" => disassemble(parts), + "reset" => reset(), + "q" or "quit" or "exit" => quit(), + _ => "unknown command '" + command + "' (try 'help')", + }; + } + + static string help() => string.Join('\n', new[] + { + "commands:", + " s [n] step n instructions (default 1)", + " so step over a CALL", + " c [n] continue up to n steps (default until stop)", + " u run until pc reaches addr", + " b add breakpoint", + " rb remove breakpoint", + " bl list breakpoints", + " wm watch a memory byte", + " wr watch a register", + " wl list watchpoints", + " wc clear watchpoints", + " r show registers", + " mem [n] hex dump n bytes from a (default 16)", + " dis [n] disassemble n instructions from pc (default 8)", + " reset reset the machine", + " q quit", + }); + + string step(string[] parts) + { + int count = optionalInt(parts, 1, 1); + for (int taken = 0; taken < count && !debugger.machine.exited; taken++) + { + debugger.step(); + } + return registers(); + } + + string stepOver() + { + RunResult outcome = debugger.stepOver(defaultRunSteps); + return describe(outcome) + "\n" + registers(); + } + + string run(string[] parts) + { + int limit = optionalInt(parts, 1, defaultRunSteps); + RunResult outcome = debugger.run(limit); + return describe(outcome) + "\n" + registers(); + } + + string runUntil(string[] parts) + { + if (!tryAddress(parts, out ushort address)) + { + return "usage: u "; + } + RunResult outcome = debugger.runUntil(address, defaultRunSteps); + return describe(outcome) + "\n" + registers(); + } + + string addBreakpoint(string[] parts) + { + if (!tryAddress(parts, out ushort address)) + { + return "usage: b "; + } + debugger.addBreakpoint(address); + return "breakpoint set at 0x" + address.ToString("X4", CultureInfo.InvariantCulture); + } + + string removeBreakpoint(string[] parts) + { + if (!tryAddress(parts, out ushort address)) + { + return "usage: rb "; + } + debugger.removeBreakpoint(address); + return "breakpoint cleared at 0x" + address.ToString("X4", CultureInfo.InvariantCulture); + } + + string listBreakpoints() + { + var addresses = new List(debugger.breakpointAddresses); + if (addresses.Count == 0) + { + return "no breakpoints"; + } + addresses.Sort(); + var builder = new StringBuilder("breakpoints:"); + foreach (ushort address in addresses) + { + builder.Append(" 0x").Append(address.ToString("X4", CultureInfo.InvariantCulture)); + } + return builder.ToString(); + } + + string watchMemory(string[] parts) + { + if (!tryAddress(parts, out ushort address)) + { + return "usage: wm "; + } + debugger.watchMemory(address); + return "watching memory 0x" + address.ToString("X4", CultureInfo.InvariantCulture); + } + + string watchRegister(string[] parts) + { + if (parts.Length < 2 || !tryRegister(parts[1], out int register)) + { + return "usage: wr "; + } + debugger.watchRegister(register); + return "watching register V" + register.ToString("X1", CultureInfo.InvariantCulture); + } + + string listWatches() + { + var builder = new StringBuilder(); + foreach (ushort address in debugger.watchedMemory) + { + builder.Append("mem 0x").Append(address.ToString("X4", CultureInfo.InvariantCulture)).Append('\n'); + } + foreach (int register in debugger.watchedRegisters) + { + builder.Append("reg V").Append(register.ToString("X1", CultureInfo.InvariantCulture)).Append('\n'); + } + return builder.Length == 0 ? "no watchpoints" : builder.ToString().TrimEnd('\n'); + } + + string clearWatches() + { + debugger.clearWatches(); + return "watchpoints cleared"; + } + + string registers() => debugger.snapshot().format().TrimEnd('\n'); + + string memory(string[] parts) + { + if (!tryAddress(parts, out ushort address)) + { + return "usage: mem [len]"; + } + int length = optionalInt(parts, 2, 16); + byte[] window = debugger.readMemory(address, length); + + var builder = new StringBuilder(); + for (int offset = 0; offset < length; offset += 16) + { + builder.Append("0x").Append(((address + offset) & 0xFFFF).ToString("X4", CultureInfo.InvariantCulture)).Append(':'); + for (int column = 0; column < 16 && offset + column < length; column++) + { + builder.Append(' ').Append(window[offset + column].ToString("X2", CultureInfo.InvariantCulture)); + } + builder.Append('\n'); + } + return builder.ToString().TrimEnd('\n'); + } + + string disassemble(string[] parts) + { + int count = optionalInt(parts, 1, 8); + var builder = new StringBuilder(); + foreach (DecodedInstruction line in debugger.disassembleWindow(count)) + { + builder.Append(line.address == debugger.machine.pc ? "-> " : " "); + builder.Append(line).Append('\n'); + } + return builder.ToString().TrimEnd('\n'); + } + + string reset() + { + debugger.machine.reset(); + return "machine reset"; + } + + string quit() + { + quitRequested = true; + return "bye"; + } + + static string describe(RunResult outcome) => outcome.reason switch + { + StopReason.Breakpoint => "stopped at breakpoint 0x" + outcome.triggeredAddress.ToString("X4", CultureInfo.InvariantCulture) + " after " + outcome.steps + " steps", + StopReason.Watchpoint => "stopped on watchpoint after " + outcome.steps + " steps", + StopReason.Exited => "interpreter exited after " + outcome.steps + " steps", + _ => "ran " + outcome.steps + " steps", + }; + + static int optionalInt(string[] parts, int index, int fallback) + { + if (parts.Length <= index) + { + return fallback; + } + return int.TryParse(parts[index], NumberStyles.Integer, CultureInfo.InvariantCulture, out int value) ? value : fallback; + } + + static bool tryAddress(string[] parts, out ushort address) + { + address = 0; + if (parts.Length < 2) + { + return false; + } + if (!tryParseNumber(parts[1], out int value)) + { + return false; + } + address = (ushort)(value & 0xFFFF); + return true; + } + + static bool tryRegister(string token, out int register) + { + register = 0; + string trimmed = token.StartsWith("V", StringComparison.OrdinalIgnoreCase) ? token.Substring(1) : token; + if (!int.TryParse(trimmed, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out int value) || value is < 0 or > 0xF) + { + return false; + } + register = value; + return true; + } + + static bool tryParseNumber(string token, out int value) + { + string digits = token; + if (digits.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + digits = digits.Substring(2); + } + else if (digits.StartsWith("$", StringComparison.Ordinal)) + { + digits = digits.Substring(1); + } + return int.TryParse(digits, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out value); + } +}