OXYGENHUB
◆ rrdc-perf enhanced-x16-emulator / tools / x16dbg / X16DebugClient.cs
14.2 KiB · C# · last changed chore(): initial commit 25 days ago source blame history raw
9120e874 Andy McCall 1
using System;
2
using System.Collections.Generic;
3
using System.IO;
4
using System.Net.Sockets;
5
using System.Text;
6
using System.Text.Json;
7
using System.Text.Json.Nodes;
8
using System.Threading;
9
using System.Threading.Tasks;
10
11
namespace X16Debug;
12
13
/// <summary>
14
/// TCP client for the x16emu DAP (Debug Adapter Protocol) debug server.
15
/// Uses Content-Length framing: "Content-Length: N\r\n\r\n{json}"
16
/// </summary>
17
public class X16DebugClient : IDisposable
18
{
19
    private TcpClient? _tcp;
20
    private Stream? _stream;
21
    private int _nextSeq = 1;
22
    private readonly object _lock = new();
23
    private readonly Dictionary<int, TaskCompletionSource<JsonNode>> _pending = new();
24
    private readonly List<TaskCompletionSource<JsonNode>> _eventWaiters = new();
25
    private CancellationTokenSource? _cts;
26
    private Task? _listenerTask;
27
28
    public event Action<JsonNode>? OnEvent;
29
    public bool IsConnected => _tcp?.Connected == true;
30
31
    public async Task ConnectAsync(string host, int port, int timeoutMs = 5000)
32
    {
33
        _tcp = new TcpClient();
34
        using var cts = new CancellationTokenSource(timeoutMs);
35
        await _tcp.ConnectAsync(host, port, cts.Token);
36
        _stream = _tcp.GetStream();
37
38
        _cts = new CancellationTokenSource();
39
        _listenerTask = Task.Run(() => ListenerLoop(_cts.Token));
40
    }
41
42
    /// <summary>
43
    /// Perform the DAP initialization handshake: initialize -> wait for initialized event -> configurationDone.
44
    /// Must be called after ConnectAsync before any other commands.
45
    /// </summary>
46
    public async Task<JsonNode> InitializeAsync()
47
    {
48
        var initArgs = new JsonObject
49
        {
50
            ["clientID"] = "x16dbg",
51
            ["clientName"] = "X16 Debug Client",
52
            ["adapterID"] = "x16emu",
53
            ["supportsVariableType"] = true,
54
            ["supportsReadMemoryRequest"] = true,
55
        };
56
57
        var initResponse = await SendRequestAsync("initialize", initArgs);
58
59
        // Wait for the "initialized" event from the server
60
        await WaitForEventAsync("initialized", 5000);
61
62
        // Complete configuration
63
        await SendRequestAsync("configurationDone");
64
65
        return initResponse;
66
    }
67
68
    // ─── Core DAP transport ─────────────────────────────────────────
69
70
    /// <summary>
71
    /// Send a DAP request and wait for its matching response.
72
    /// </summary>
73
    public async Task<JsonNode> SendRequestAsync(string command, JsonNode? arguments = null, int timeoutMs = 10000)
74
    {
75
        int seq;
76
        var tcs = new TaskCompletionSource<JsonNode>();
77
78
        lock (_lock)
79
        {
80
            seq = _nextSeq++;
81
            _pending[seq] = tcs;
82
        }
83
84
        var msg = new JsonObject
85
        {
86
            ["seq"] = seq,
87
            ["type"] = "request",
88
            ["command"] = command,
89
        };
90
        if (arguments != null)
91
            msg["arguments"] = arguments;
92
93
        await SendDapMessageAsync(msg);
94
95
        using var cts = new CancellationTokenSource(timeoutMs);
96
        cts.Token.Register(() => tcs.TrySetCanceled());
97
98
        return await tcs.Task;
99
    }
100
101
    /// <summary>
102
    /// Wait for a specific DAP event by name.
103
    /// </summary>
104
    public Task<JsonNode> WaitForEventAsync(string eventName, int timeoutMs = 10000)
105
    {
106
        var tcs = new TaskCompletionSource<JsonNode>();
107
        Action<JsonNode>? handler = null;
108
        handler = (evt) =>
109
        {
110
            if (evt["event"]?.GetValue<string>() == eventName)
111
            {
112
                OnEvent -= handler;
113
                tcs.TrySetResult(evt);
114
            }
115
        };
116
        OnEvent += handler;
117
118
        var cts = new CancellationTokenSource(timeoutMs);
119
        cts.Token.Register(() =>
120
        {
121
            OnEvent -= handler;
122
            tcs.TrySetCanceled();
123
        });
124
125
        return tcs.Task;
126
    }
127
128
    private async Task SendDapMessageAsync(JsonObject msg)
129
    {
130
        string json = msg.ToJsonString();
131
        byte[] body = Encoding.UTF8.GetBytes(json);
132
        string header = $"Content-Length: {body.Length}\r\n\r\n";
133
        byte[] headerBytes = Encoding.ASCII.GetBytes(header);
134
135
        byte[] packet = new byte[headerBytes.Length + body.Length];
136
        Buffer.BlockCopy(headerBytes, 0, packet, 0, headerBytes.Length);
137
        Buffer.BlockCopy(body, 0, packet, headerBytes.Length, body.Length);
138
139
        await _stream!.WriteAsync(packet);
140
        await _stream.FlushAsync();
141
    }
142
143
    private async Task ListenerLoop(CancellationToken ct)
144
    {
145
        try
146
        {
147
            while (!ct.IsCancellationRequested && _stream != null)
148
            {
149
                var node = await ReadDapMessageAsync(ct);
150
                if (node == null) break;
151
152
                string? type = node["type"]?.GetValue<string>();
153
154
                if (type == "response")
155
                {
156
                    int reqSeq = node["request_seq"]!.GetValue<int>();
157
                    lock (_lock)
158
                    {
159
                        if (_pending.TryGetValue(reqSeq, out var tcs))
160
                        {
161
                            _pending.Remove(reqSeq);
162
                            tcs.SetResult(node);
163
                        }
164
                    }
165
                }
166
                else if (type == "event")
167
                {
168
                    OnEvent?.Invoke(node);
169
                }
170
            }
171
        }
172
        catch (OperationCanceledException) { }
173
        catch (IOException) { }
174
        catch (Exception ex)
175
        {
176
            Console.Error.WriteLine($"[listener] Error: {ex.Message}");
177
        }
178
    }
179
180
    /// <summary>
181
    /// Read one DAP message: parse Content-Length header, then read that many bytes of JSON.
182
    /// </summary>
183
    private async Task<JsonNode?> ReadDapMessageAsync(CancellationToken ct)
184
    {
185
        // Read headers line by line until we hit the empty line (\r\n\r\n)
186
        int contentLength = -1;
187
        while (true)
188
        {
189
            string? headerLine = await ReadLineAsync(ct);
190
            if (headerLine == null) return null; // connection closed
191
192
            if (headerLine.Length == 0)
193
                break; // end of headers
194
195
            if (headerLine.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase))
196
            {
197
                string val = headerLine.Substring("Content-Length:".Length).Trim();
198
                contentLength = int.Parse(val);
199
            }
200
        }
201
202
        if (contentLength < 0)
203
            return null;
204
205
        // Read exactly contentLength bytes
206
        byte[] buffer = new byte[contentLength];
207
        int totalRead = 0;
208
        while (totalRead < contentLength)
209
        {
210
            int read = await _stream!.ReadAsync(buffer, totalRead, contentLength - totalRead, ct);
211
            if (read == 0) return null; // connection closed
212
            totalRead += read;
213
        }
214
215
        string json = Encoding.UTF8.GetString(buffer);
216
        return JsonNode.Parse(json);
217
    }
218
219
    /// <summary>
220
    /// Read a single \r\n-terminated line from the raw stream (for DAP headers).
221
    /// </summary>
222
    private async Task<string?> ReadLineAsync(CancellationToken ct)
223
    {
224
        var sb = new StringBuilder();
225
        byte[] buf = new byte[1];
226
        bool prevCR = false;
227
228
        while (true)
229
        {
230
            int read = await _stream!.ReadAsync(buf, 0, 1, ct);
231
            if (read == 0) return null;
232
233
            char c = (char)buf[0];
234
            if (c == '\n' && prevCR)
235
            {
236
                // Remove the trailing CR we appended
237
                if (sb.Length > 0 && sb[sb.Length - 1] == '\r')
238
                    sb.Length--;
239
                return sb.ToString();
240
            }
241
            prevCR = c == '\r';
242
            sb.Append(c);
243
        }
244
    }
245
246
    // ─── Execution commands ─────────────────────────────────────────
247
248
    public Task<JsonNode> StepInAsync() =>
249
        SendRequestAsync("stepIn", new JsonObject { ["threadId"] = 1 });
250
251
    public Task<JsonNode> StepOverAsync() =>
252
        SendRequestAsync("next", new JsonObject { ["threadId"] = 1 });
253
254
    public Task<JsonNode> StepOutAsync() =>
255
        SendRequestAsync("stepOut", new JsonObject { ["threadId"] = 1 });
256
257
    public Task<JsonNode> ContinueAsync() =>
258
        SendRequestAsync("continue", new JsonObject { ["threadId"] = 1 });
259
260
    public Task<JsonNode> PauseAsync() =>
261
        SendRequestAsync("pause", new JsonObject { ["threadId"] = 1 });
262
263
    public Task<JsonNode> DisconnectAsync(bool terminateDebuggee = false) =>
264
        SendRequestAsync("disconnect", new JsonObject { ["terminateDebuggee"] = terminateDebuggee });
265
266
    // ─── State inspection ───────────────────────────────────────────
267
268
    public Task<JsonNode> GetThreadsAsync() =>
269
        SendRequestAsync("threads");
270
271
    public Task<JsonNode> GetStackTraceAsync(int threadId = 1) =>
272
        SendRequestAsync("stackTrace", new JsonObject { ["threadId"] = threadId });
273
274
    public Task<JsonNode> GetScopesAsync(int frameId) =>
275
        SendRequestAsync("scopes", new JsonObject { ["frameId"] = frameId });
276
277
    public Task<JsonNode> GetVariablesAsync(int variablesReference) =>
278
        SendRequestAsync("variables", new JsonObject { ["variablesReference"] = variablesReference });
279
280
    /// <summary>
281
    /// Helper: stackTrace -> scopes -> variables to get the "Registers" scope.
282
    /// The emulator uses variablesReference=1 for Registers.
283
    /// </summary>
284
    public async Task<JsonNode> GetRegistersAsync()
285
    {
286
        var stack = await GetStackTraceAsync(1);
287
        var frames = stack["body"]?["stackFrames"]?.AsArray();
288
        if (frames == null || frames.Count == 0)
289
            throw new InvalidOperationException("No stack frames available");
290
291
        int frameId = frames[0]!["id"]!.GetValue<int>();
292
        var scopes = await GetScopesAsync(frameId);
293
        var scopeArray = scopes["body"]?["scopes"]?.AsArray();
294
        if (scopeArray == null)
295
            throw new InvalidOperationException("No scopes available");
296
297
        // Find the "Registers" scope (variablesReference=1 by convention)
298
        int registersRef = -1;
299
        foreach (var scope in scopeArray)
300
        {
301
            string? name = scope?["name"]?.GetValue<string>();
302
            if (name != null && name.Equals("Registers", StringComparison.OrdinalIgnoreCase))
303
            {
304
                registersRef = scope!["variablesReference"]!.GetValue<int>();
305
                break;
306
            }
307
        }
308
309
        // Fallback: use variablesReference=1 directly
310
        if (registersRef < 0)
311
            registersRef = 1;
312
313
        return await GetVariablesAsync(registersRef);
314
    }
315
316
    public Task<JsonNode> ReadMemoryAsync(int address, int count) =>
317
        ReadMemoryAsync($"${address:X4}", count);
318
319
    /// <summary>
320
    /// Read memory by raw DAP reference. A bare address is CPU space; a "vram:"
321
    /// prefix reaches VERA's 17-bit video RAM, which is not in the CPU map at all.
322
    /// </summary>
323
    public Task<JsonNode> ReadMemoryAsync(string memoryReference, int count) =>
324
        SendRequestAsync("readMemory", new JsonObject
325
        {
326
            ["memoryReference"] = memoryReference,
327
            ["count"] = count
328
        });
329
330
    /// <summary>
331
    /// Write memory by raw DAP reference, same address rules as ReadMemoryAsync.
332
    /// This is the only way to write VRAM: the CPU reaches it through VERA's data
333
    /// port one auto-incrementing byte at a time, so there is no CPU address for it.
334
    /// </summary>
335
    public Task<JsonNode> WriteMemoryAsync(string memoryReference, byte[] data) =>
336
        SendRequestAsync("writeMemory", new JsonObject
337
        {
338
            ["memoryReference"] = memoryReference,
339
            ["data"] = Convert.ToBase64String(data)
340
        });
341
342
    // ─── Virtual joysticks ──────────────────────────────────────────
343
344
    /// <summary>
345
    /// Drive one of the four SNES controller ports. Needs no gamepad attached and
346
    /// no -joy1..-joy4 flag.
347
    ///
348
    /// buttons is the complete held set, so anything left out of it is released;
349
    /// passing null for every argument but index queries the port without
350
    /// changing it. Set enabled=false to hand the port back to a physical pad.
351
    /// </summary>
352
    public Task<JsonNode> JoystickAsync(int index, IEnumerable<string>? buttons = null,
353
                                        int? mask = null, bool? enabled = null)
354
    {
355
        var args = new JsonObject { ["index"] = index };
356
        if (buttons != null)
357
        {
358
            var arr = new JsonArray();
359
            foreach (var b in buttons) arr.Add(b);
360
            args["buttons"] = arr;
361
        }
362
        if (mask.HasValue) args["mask"] = mask.Value;
363
        if (enabled.HasValue) args["enabled"] = enabled.Value;
364
        return SendRequestAsync("x16/joystick", args);
365
    }
366
367
    public Task<JsonNode> EvaluateAsync(string expression) =>
368
        SendRequestAsync("evaluate", new JsonObject
369
        {
370
            ["expression"] = expression,
371
            ["frameId"] = 0
372
        });
373
374
    /// <summary>
375
    /// Set a variable in a scope. The emulator uses variablesReference=1 for the
376
    /// Registers scope. This is the request that actually writes a register --
377
    /// the evaluate expression "A=FF" is parsed as a read and silently does
378
    /// nothing.
379
    /// </summary>
380
    public Task<JsonNode> SetVariableAsync(string name, string value, int variablesReference = 1) =>
381
        SendRequestAsync("setVariable", new JsonObject
382
        {
383
            ["variablesReference"] = variablesReference,
384
            ["name"] = name,
385
            ["value"] = value
386
        });
387
388
    // ─── Breakpoints (via evaluate) ─────────────────────────────────
389
390
    public Task<JsonNode> AddBreakpointAsync(int address) =>
391
        EvaluateAsync($"bp_add {address:X4}");
392
393
    public Task<JsonNode> RemoveBreakpointAsync(int address) =>
394
        EvaluateAsync($"bp_remove {address:X4}");
395
396
    public Task<JsonNode> ListBreakpointsAsync() =>
397
        EvaluateAsync("bp_list");
398
399
    public Task<JsonNode> ClearBreakpointsAsync() =>
400
        EvaluateAsync("bp_clear");
401
402
    public void Dispose()
403
    {
404
        _cts?.Cancel();
405
        _stream?.Dispose();
406
        _tcp?.Dispose();
407
        _listenerTask?.Wait(1000);
408
    }
409
}