using System; using System.Collections.Generic; using System.IO; using System.Net.Sockets; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; namespace X16Debug; /// /// TCP client for the x16emu DAP (Debug Adapter Protocol) debug server. /// Uses Content-Length framing: "Content-Length: N\r\n\r\n{json}" /// public class X16DebugClient : IDisposable { private TcpClient? _tcp; private Stream? _stream; private int _nextSeq = 1; private readonly object _lock = new(); private readonly Dictionary> _pending = new(); private readonly List> _eventWaiters = new(); private CancellationTokenSource? _cts; private Task? _listenerTask; public event Action? OnEvent; public bool IsConnected => _tcp?.Connected == true; public async Task ConnectAsync(string host, int port, int timeoutMs = 5000) { _tcp = new TcpClient(); using var cts = new CancellationTokenSource(timeoutMs); await _tcp.ConnectAsync(host, port, cts.Token); _stream = _tcp.GetStream(); _cts = new CancellationTokenSource(); _listenerTask = Task.Run(() => ListenerLoop(_cts.Token)); } /// /// Perform the DAP initialization handshake: initialize -> wait for initialized event -> configurationDone. /// Must be called after ConnectAsync before any other commands. /// public async Task InitializeAsync() { var initArgs = new JsonObject { ["clientID"] = "x16dbg", ["clientName"] = "X16 Debug Client", ["adapterID"] = "x16emu", ["supportsVariableType"] = true, ["supportsReadMemoryRequest"] = true, }; var initResponse = await SendRequestAsync("initialize", initArgs); // Wait for the "initialized" event from the server await WaitForEventAsync("initialized", 5000); // Complete configuration await SendRequestAsync("configurationDone"); return initResponse; } // ─── Core DAP transport ───────────────────────────────────────── /// /// Send a DAP request and wait for its matching response. /// public async Task SendRequestAsync(string command, JsonNode? arguments = null, int timeoutMs = 10000) { int seq; var tcs = new TaskCompletionSource(); lock (_lock) { seq = _nextSeq++; _pending[seq] = tcs; } var msg = new JsonObject { ["seq"] = seq, ["type"] = "request", ["command"] = command, }; if (arguments != null) msg["arguments"] = arguments; await SendDapMessageAsync(msg); using var cts = new CancellationTokenSource(timeoutMs); cts.Token.Register(() => tcs.TrySetCanceled()); return await tcs.Task; } /// /// Wait for a specific DAP event by name. /// public Task WaitForEventAsync(string eventName, int timeoutMs = 10000) { var tcs = new TaskCompletionSource(); Action? handler = null; handler = (evt) => { if (evt["event"]?.GetValue() == eventName) { OnEvent -= handler; tcs.TrySetResult(evt); } }; OnEvent += handler; var cts = new CancellationTokenSource(timeoutMs); cts.Token.Register(() => { OnEvent -= handler; tcs.TrySetCanceled(); }); return tcs.Task; } private async Task SendDapMessageAsync(JsonObject msg) { string json = msg.ToJsonString(); byte[] body = Encoding.UTF8.GetBytes(json); string header = $"Content-Length: {body.Length}\r\n\r\n"; byte[] headerBytes = Encoding.ASCII.GetBytes(header); byte[] packet = new byte[headerBytes.Length + body.Length]; Buffer.BlockCopy(headerBytes, 0, packet, 0, headerBytes.Length); Buffer.BlockCopy(body, 0, packet, headerBytes.Length, body.Length); await _stream!.WriteAsync(packet); await _stream.FlushAsync(); } private async Task ListenerLoop(CancellationToken ct) { try { while (!ct.IsCancellationRequested && _stream != null) { var node = await ReadDapMessageAsync(ct); if (node == null) break; string? type = node["type"]?.GetValue(); if (type == "response") { int reqSeq = node["request_seq"]!.GetValue(); lock (_lock) { if (_pending.TryGetValue(reqSeq, out var tcs)) { _pending.Remove(reqSeq); tcs.SetResult(node); } } } else if (type == "event") { OnEvent?.Invoke(node); } } } catch (OperationCanceledException) { } catch (IOException) { } catch (Exception ex) { Console.Error.WriteLine($"[listener] Error: {ex.Message}"); } } /// /// Read one DAP message: parse Content-Length header, then read that many bytes of JSON. /// private async Task ReadDapMessageAsync(CancellationToken ct) { // Read headers line by line until we hit the empty line (\r\n\r\n) int contentLength = -1; while (true) { string? headerLine = await ReadLineAsync(ct); if (headerLine == null) return null; // connection closed if (headerLine.Length == 0) break; // end of headers if (headerLine.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)) { string val = headerLine.Substring("Content-Length:".Length).Trim(); contentLength = int.Parse(val); } } if (contentLength < 0) return null; // Read exactly contentLength bytes byte[] buffer = new byte[contentLength]; int totalRead = 0; while (totalRead < contentLength) { int read = await _stream!.ReadAsync(buffer, totalRead, contentLength - totalRead, ct); if (read == 0) return null; // connection closed totalRead += read; } string json = Encoding.UTF8.GetString(buffer); return JsonNode.Parse(json); } /// /// Read a single \r\n-terminated line from the raw stream (for DAP headers). /// private async Task ReadLineAsync(CancellationToken ct) { var sb = new StringBuilder(); byte[] buf = new byte[1]; bool prevCR = false; while (true) { int read = await _stream!.ReadAsync(buf, 0, 1, ct); if (read == 0) return null; char c = (char)buf[0]; if (c == '\n' && prevCR) { // Remove the trailing CR we appended if (sb.Length > 0 && sb[sb.Length - 1] == '\r') sb.Length--; return sb.ToString(); } prevCR = c == '\r'; sb.Append(c); } } // ─── Execution commands ───────────────────────────────────────── public Task StepInAsync() => SendRequestAsync("stepIn", new JsonObject { ["threadId"] = 1 }); public Task StepOverAsync() => SendRequestAsync("next", new JsonObject { ["threadId"] = 1 }); public Task StepOutAsync() => SendRequestAsync("stepOut", new JsonObject { ["threadId"] = 1 }); public Task ContinueAsync() => SendRequestAsync("continue", new JsonObject { ["threadId"] = 1 }); public Task PauseAsync() => SendRequestAsync("pause", new JsonObject { ["threadId"] = 1 }); public Task DisconnectAsync(bool terminateDebuggee = false) => SendRequestAsync("disconnect", new JsonObject { ["terminateDebuggee"] = terminateDebuggee }); // ─── State inspection ─────────────────────────────────────────── public Task GetThreadsAsync() => SendRequestAsync("threads"); public Task GetStackTraceAsync(int threadId = 1) => SendRequestAsync("stackTrace", new JsonObject { ["threadId"] = threadId }); public Task GetScopesAsync(int frameId) => SendRequestAsync("scopes", new JsonObject { ["frameId"] = frameId }); public Task GetVariablesAsync(int variablesReference) => SendRequestAsync("variables", new JsonObject { ["variablesReference"] = variablesReference }); /// /// Helper: stackTrace -> scopes -> variables to get the "Registers" scope. /// The emulator uses variablesReference=1 for Registers. /// public async Task GetRegistersAsync() { var stack = await GetStackTraceAsync(1); var frames = stack["body"]?["stackFrames"]?.AsArray(); if (frames == null || frames.Count == 0) throw new InvalidOperationException("No stack frames available"); int frameId = frames[0]!["id"]!.GetValue(); var scopes = await GetScopesAsync(frameId); var scopeArray = scopes["body"]?["scopes"]?.AsArray(); if (scopeArray == null) throw new InvalidOperationException("No scopes available"); // Find the "Registers" scope (variablesReference=1 by convention) int registersRef = -1; foreach (var scope in scopeArray) { string? name = scope?["name"]?.GetValue(); if (name != null && name.Equals("Registers", StringComparison.OrdinalIgnoreCase)) { registersRef = scope!["variablesReference"]!.GetValue(); break; } } // Fallback: use variablesReference=1 directly if (registersRef < 0) registersRef = 1; return await GetVariablesAsync(registersRef); } public Task ReadMemoryAsync(int address, int count) => ReadMemoryAsync($"${address:X4}", count); /// /// Read memory by raw DAP reference. A bare address is CPU space; a "vram:" /// prefix reaches VERA's 17-bit video RAM, which is not in the CPU map at all. /// public Task ReadMemoryAsync(string memoryReference, int count) => SendRequestAsync("readMemory", new JsonObject { ["memoryReference"] = memoryReference, ["count"] = count }); /// /// Write memory by raw DAP reference, same address rules as ReadMemoryAsync. /// This is the only way to write VRAM: the CPU reaches it through VERA's data /// port one auto-incrementing byte at a time, so there is no CPU address for it. /// public Task WriteMemoryAsync(string memoryReference, byte[] data) => SendRequestAsync("writeMemory", new JsonObject { ["memoryReference"] = memoryReference, ["data"] = Convert.ToBase64String(data) }); // ─── Virtual joysticks ────────────────────────────────────────── /// /// Drive one of the four SNES controller ports. Needs no gamepad attached and /// no -joy1..-joy4 flag. /// /// buttons is the complete held set, so anything left out of it is released; /// passing null for every argument but index queries the port without /// changing it. Set enabled=false to hand the port back to a physical pad. /// public Task JoystickAsync(int index, IEnumerable? buttons = null, int? mask = null, bool? enabled = null) { var args = new JsonObject { ["index"] = index }; if (buttons != null) { var arr = new JsonArray(); foreach (var b in buttons) arr.Add(b); args["buttons"] = arr; } if (mask.HasValue) args["mask"] = mask.Value; if (enabled.HasValue) args["enabled"] = enabled.Value; return SendRequestAsync("x16/joystick", args); } public Task EvaluateAsync(string expression) => SendRequestAsync("evaluate", new JsonObject { ["expression"] = expression, ["frameId"] = 0 }); /// /// Set a variable in a scope. The emulator uses variablesReference=1 for the /// Registers scope. This is the request that actually writes a register -- /// the evaluate expression "A=FF" is parsed as a read and silently does /// nothing. /// public Task SetVariableAsync(string name, string value, int variablesReference = 1) => SendRequestAsync("setVariable", new JsonObject { ["variablesReference"] = variablesReference, ["name"] = name, ["value"] = value }); // ─── Breakpoints (via evaluate) ───────────────────────────────── public Task AddBreakpointAsync(int address) => EvaluateAsync($"bp_add {address:X4}"); public Task RemoveBreakpointAsync(int address) => EvaluateAsync($"bp_remove {address:X4}"); public Task ListBreakpointsAsync() => EvaluateAsync("bp_list"); public Task ClearBreakpointsAsync() => EvaluateAsync("bp_clear"); public void Dispose() { _cts?.Cancel(); _stream?.Dispose(); _tcp?.Dispose(); _listenerTask?.Wait(1000); } }