From 13269797bf1724441fe3c37a3296220873b41644 Mon Sep 17 00:00:00 2001 From: Spooks <62370103+Spooks4576@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:41:07 -0600 Subject: [PATCH] Add live debugger frontend and mutex stall recovery (#383) --- .gitignore | 2 + SharpEmu.slnx | 2 + docs/debugger-server.md | 177 +++ src/SharpEmu.CLI/Program.cs | 80 +- src/SharpEmu.CLI/SharpEmu.CLI.csproj | 1 + src/SharpEmu.CLI/packages.lock.json | 32 +- src/SharpEmu.Core/Cpu/CpuDispatcher.cs | 20 + src/SharpEmu.Core/Cpu/CpuExecutionOptions.cs | 13 +- .../Cpu/Debugging/CpuContextDebugFrame.cs | 66 + .../Cpu/Debugging/CpuDebugFrameKind.cs | 18 + .../Cpu/Debugging/CpuStallInfo.cs | 70 + .../Cpu/Debugging/ICpuDebugFrame.cs | 66 + .../Cpu/Debugging/ICpuDebugHook.cs | 49 + .../Native/DirectExecutionBackend.Imports.cs | 13 +- .../Cpu/Native/DirectExecutionBackend.cs | 50 + src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs | 2 + .../Runtime/SharpEmuRuntimeOptions.cs | 8 + src/SharpEmu.DebugClient/ClientEndpoint.cs | 54 + src/SharpEmu.DebugClient/CommandTranslator.cs | 160 +++ src/SharpEmu.DebugClient/DEVELOPER_READ.md | 153 +++ .../DebugClientConnection.cs | 120 ++ src/SharpEmu.DebugClient/Program.cs | 193 +++ .../SharpEmu.DebugClient.csproj | 16 + .../Breakpoints/Breakpoint.cs | 48 + .../Breakpoints/BreakpointKind.cs | 26 + .../Breakpoints/BreakpointStore.cs | 90 ++ src/SharpEmu.Debugger/DebugRegisterFile.cs | 73 ++ src/SharpEmu.Debugger/DebugRegisterId.cs | 62 + src/SharpEmu.Debugger/DebuggerServerHost.cs | 59 + .../Protocol/DebugCommandDispatcher.cs | 340 +++++ .../Protocol/DebugRequest.cs | 152 +++ .../Protocol/DebugResponse.cs | 34 + .../Protocol/IDebugProtocol.cs | 33 + .../Protocol/JsonLineDebugProtocol.cs | 112 ++ .../Server/DebuggerClientConnection.cs | 158 +++ .../Server/DebuggerServer.cs | 136 ++ .../Server/DebuggerServerOptions.cs | 78 ++ .../Server/IDebuggerServer.cs | 24 + .../Session/DebugStopEvent.cs | 69 + .../Session/DebugStopReason.cs | 32 + .../Session/DebuggerRunState.cs | 23 + .../Session/DebuggerSession.cs | 425 ++++++ .../Session/DebuggerSessionOptions.cs | 31 + src/SharpEmu.Debugger/Session/IDebugTarget.cs | 51 + .../Session/IDebuggerSession.cs | 43 + .../SharpEmu.Debugger.csproj | 16 + .../Kernel/KernelPthreadCompatExports.cs | 13 +- .../Pthread/PthreadMutexSemanticsTests.cs | 99 ++ tools/SharpEmu.DebuggerFrontend/README.md | 89 ++ .../debugger_frontend.py | 1165 +++++++++++++++++ tools/SharpEmu.DebuggerFrontend/run.sh | 8 + .../tests/test_debugger_frontend.py | 309 +++++ tools/SharpEmu.DebuggerFrontend/web/app.js | 626 +++++++++ .../SharpEmu.DebuggerFrontend/web/index.html | 302 +++++ .../web/sharpemu-logo.webp | Bin 0 -> 13388 bytes .../web/sharpemu-logo.webp.license | 2 + .../SharpEmu.DebuggerFrontend/web/styles.css | 463 +++++++ 57 files changed, 6534 insertions(+), 22 deletions(-) create mode 100644 docs/debugger-server.md create mode 100644 src/SharpEmu.Core/Cpu/Debugging/CpuContextDebugFrame.cs create mode 100644 src/SharpEmu.Core/Cpu/Debugging/CpuDebugFrameKind.cs create mode 100644 src/SharpEmu.Core/Cpu/Debugging/CpuStallInfo.cs create mode 100644 src/SharpEmu.Core/Cpu/Debugging/ICpuDebugFrame.cs create mode 100644 src/SharpEmu.Core/Cpu/Debugging/ICpuDebugHook.cs create mode 100644 src/SharpEmu.DebugClient/ClientEndpoint.cs create mode 100644 src/SharpEmu.DebugClient/CommandTranslator.cs create mode 100644 src/SharpEmu.DebugClient/DEVELOPER_READ.md create mode 100644 src/SharpEmu.DebugClient/DebugClientConnection.cs create mode 100644 src/SharpEmu.DebugClient/Program.cs create mode 100644 src/SharpEmu.DebugClient/SharpEmu.DebugClient.csproj create mode 100644 src/SharpEmu.Debugger/Breakpoints/Breakpoint.cs create mode 100644 src/SharpEmu.Debugger/Breakpoints/BreakpointKind.cs create mode 100644 src/SharpEmu.Debugger/Breakpoints/BreakpointStore.cs create mode 100644 src/SharpEmu.Debugger/DebugRegisterFile.cs create mode 100644 src/SharpEmu.Debugger/DebugRegisterId.cs create mode 100644 src/SharpEmu.Debugger/DebuggerServerHost.cs create mode 100644 src/SharpEmu.Debugger/Protocol/DebugCommandDispatcher.cs create mode 100644 src/SharpEmu.Debugger/Protocol/DebugRequest.cs create mode 100644 src/SharpEmu.Debugger/Protocol/DebugResponse.cs create mode 100644 src/SharpEmu.Debugger/Protocol/IDebugProtocol.cs create mode 100644 src/SharpEmu.Debugger/Protocol/JsonLineDebugProtocol.cs create mode 100644 src/SharpEmu.Debugger/Server/DebuggerClientConnection.cs create mode 100644 src/SharpEmu.Debugger/Server/DebuggerServer.cs create mode 100644 src/SharpEmu.Debugger/Server/DebuggerServerOptions.cs create mode 100644 src/SharpEmu.Debugger/Server/IDebuggerServer.cs create mode 100644 src/SharpEmu.Debugger/Session/DebugStopEvent.cs create mode 100644 src/SharpEmu.Debugger/Session/DebugStopReason.cs create mode 100644 src/SharpEmu.Debugger/Session/DebuggerRunState.cs create mode 100644 src/SharpEmu.Debugger/Session/DebuggerSession.cs create mode 100644 src/SharpEmu.Debugger/Session/DebuggerSessionOptions.cs create mode 100644 src/SharpEmu.Debugger/Session/IDebugTarget.cs create mode 100644 src/SharpEmu.Debugger/Session/IDebuggerSession.cs create mode 100644 src/SharpEmu.Debugger/SharpEmu.Debugger.csproj create mode 100644 tests/SharpEmu.Libs.Tests/Pthread/PthreadMutexSemanticsTests.cs create mode 100644 tools/SharpEmu.DebuggerFrontend/README.md create mode 100755 tools/SharpEmu.DebuggerFrontend/debugger_frontend.py create mode 100755 tools/SharpEmu.DebuggerFrontend/run.sh create mode 100644 tools/SharpEmu.DebuggerFrontend/tests/test_debugger_frontend.py create mode 100644 tools/SharpEmu.DebuggerFrontend/web/app.js create mode 100644 tools/SharpEmu.DebuggerFrontend/web/index.html create mode 100644 tools/SharpEmu.DebuggerFrontend/web/sharpemu-logo.webp create mode 100644 tools/SharpEmu.DebuggerFrontend/web/sharpemu-logo.webp.license create mode 100644 tools/SharpEmu.DebuggerFrontend/web/styles.css diff --git a/.gitignore b/.gitignore index 6f78331..f25ceb1 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,8 @@ packages/ .nuget/ .dotnet-home/ .cache/ +__pycache__/ +*.py[cod] .DS_Store Thumbs.db diff --git a/SharpEmu.slnx b/SharpEmu.slnx index b39b35c..a867d51 100644 --- a/SharpEmu.slnx +++ b/SharpEmu.slnx @@ -7,6 +7,8 @@ SPDX-License-Identifier: GPL-2.0-or-later + + diff --git a/docs/debugger-server.md b/docs/debugger-server.md new file mode 100644 index 0000000..1e1f846 --- /dev/null +++ b/docs/debugger-server.md @@ -0,0 +1,177 @@ + + +# Live debug server + +SharpEmu can expose a **live debug server** so an external process can inspect +and control a running guest over TCP. The server lives in the emulator; the +companion `SharpEmu.DebugClient` executable is one client, and the wire protocol +is simple enough to script against directly. + +This document describes the moving parts and the wire protocol. For day-to-day +client usage, see +[`src/SharpEmu.DebugClient/DEVELOPER_READ.md`](../src/SharpEmu.DebugClient/DEVELOPER_READ.md). + +## Layering + +| Assembly | Role | +| -------- | ---- | +| `SharpEmu.Core` | Defines the dispatcher seam `ICpuDebugHook` / `ICpuDebugFrame` (namespace `SharpEmu.Core.Cpu.Debug`) and the `CpuExecutionOptions.DebugHook` slot. Core has **no** reference to the debugger. | +| `SharpEmu.Debugger` | The debugger: `DebuggerSession` (implements the hook), `BreakpointStore`, the TCP `DebuggerServer`, the pluggable `IDebugProtocol` with a JSON-lines implementation, and the `DebuggerServerHost` one-call wiring. | +| `SharpEmu.CLI` | Parses `--debug-server`, builds a `DebuggerServerHost`, hands its `Hook` to `SharpEmuRuntimeOptions.DebugHook`, and manages its lifetime. | +| `SharpEmu.DebugClient` | A standalone client executable. Depends only on the BCL. | + +The dependency direction is important: Core stays debugger-agnostic and only +publishes the seam. Anything that observes execution implements +`ICpuDebugHook` and is injected through the options, so the debugger can evolve +without touching the CPU core. + +## Execution model + +`CpuDispatcher` enters a fresh frame for the process entry point and for each +module initializer. When a `DebugHook` is attached it is notified at those +boundaries: + +- `OnFrameEnter(frame)` — before the native backend runs the frame. The + `DebuggerSession` decides whether to stop (pause request, breakpoint on the + entry address, single-step, or stop-at-entry). To stop, it **parks the + emulation thread** inside this call on a gate; the frame stays live, so a + client can read and write registers and memory while parked. `continue` / + `step` release the gate. +- `OnFrameExit(frame, result)` — after the frame completes. + +Because pausing parks the one thread that owns the guest context, register and +memory accessors are only served while the session reports `Paused`; otherwise +they return "not paused" so a client never observes torn state. + +### What is and isn't live yet + +- **Live:** attach/handshake, run-state tracking, register read/write, memory + read/write, breakpoint management, execution breakpoints at frame entry, + pause, frame-level step, continue, and stop/resume/terminate events. +- **Surface only (armed as the backend grows hooks):** per-instruction + stepping and data watchpoints (`readwatch` / `writewatch` / `accesswatch`). + The verbs and types exist so clients and tooling can be written now. + +## Enabling the server + +```bash +SharpEmu --debug-server "/path/to/eboot.bin" # 127.0.0.1:5714 +SharpEmu --debug-server=0.0.0.0:5714 "/path/to/eboot.bin" +``` + +The bind address defaults to loopback; a routable address must be given +explicitly. With stop-at-entry (the default `DebuggerSessionOptions.StopAtEntry`), +the guest parks at its first frame until a client connects and issues +`continue`, giving you a window to set breakpoints before any guest code runs. + +## Browser frontend + +The dependency-free Python frontend can choose and launch an `eboot.bin`, attach +to its debugger automatically, and provides execution controls, registers, +memory inspection, breakpoint management, process output, and a live protocol +activity stream: + +```bash +./tools/SharpEmu.DebuggerFrontend/run.sh +``` + +It connects to `127.0.0.1:5714` and opens `http://127.0.0.1:8765/` by default. +See [`tools/SharpEmu.DebuggerFrontend/README.md`](../tools/SharpEmu.DebuggerFrontend/README.md) +for configuration and testing options. + +## Wire protocol (json-lines/1) + +One JSON object per line, UTF-8, `\n`-terminated, in both directions. + +### Requests + +A `command` string plus command-specific fields. Numeric fields accept a JSON +number or a `0x`-prefixed hex string. + +| `command` | Fields | Reply `data` | +| --------- | ------ | ------------ | +| `ping` | — | — | +| `status` (`info`) | — | `state`, `breakpoints`, `lastStop?` | +| `state` | — | `state` | +| `registers` (`regs`) | — | `registers` (rax..r15, rip, rflags, fs_base, gs_base) | +| `set-register` | `register`, `value` | — | +| `read-memory` | `address`, `length` (≤ 65536) | `address`, `length`, `bytes` (hex) | +| `write-memory` | `address`, `bytes` (hex) | `written` | +| `list-breakpoints` (`breakpoints`) | — | `breakpoints[]` | +| `add-breakpoint` (`break`) | `address`, `kind?`, `length?` | `breakpoint` | +| `remove-breakpoint` (`delete-breakpoint`) | `id` | — | +| `enable-breakpoint` | `id`, `enabled?` (default true) | — | +| `continue` (`cont`, `c`) | — | — | +| `step` (`s`) | — | — | +| `pause` | — | — | + +### Replies + +```json +{"ok":true,"command":"registers","data":{ "registers": { "rax":"0x…", … } }} +{"ok":false,"command":"read-memory","error":"Target is not paused."} +``` + +### Events (unsolicited) + +```json +{"event":"hello","protocol":"json-lines/1","state":"Paused"} +{"event":"stopped","reason":"Breakpoint","address":"0x…","frameKind":"ProcessEntry","frameLabel":"eboot.bin","registers":{…},"breakpoint":{…}} +{"event":"resumed"} +{"event":"terminated"} +``` + +`reason` is one of `EntryPoint`, `Breakpoint`, `Watchpoint`, `Step`, `Pause`, +`Fault`, or `Stall`. + +Stall stops include structured evidence in addition to the human-readable +detail. Import-loop evidence identifies the NID, resolved HLE export, repeating +guest return site, dispatch count, and first two ABI arguments: + +```json +{ + "event": "stopped", + "reason": "Stall", + "stall": { + "kind": "ImportLoop", + "nid": "9UK1vLZQft4", + "instructionPointer": "0x0000000801CE2418", + "dispatchIndex": 40667904, + "argument0": "0x0000000812345000", + "argument1": "0x0000000000000000", + "resolved": true, + "library": "libKernel", + "function": "scePthreadMutexLock" + } +} +``` + +The Python frontend uses this evidence to explain the likely failure class and +rank concrete checks/fixes. Its diagnosis is intentionally labelled heuristic: +it helps locate the responsible HLE/scheduler path but does not replace tracing. + +## Swapping the protocol + +`DebuggerServer` takes an `IDebugProtocol` factory. The default is +`JsonLineDebugProtocol`; a GDB remote serial stub (or any other framing) can be +dropped in without changing the session or command semantics, which live in +`DebugCommandDispatcher`. + +## Embedding the server + +```csharp +using SharpEmu.Debugger; +using SharpEmu.Core.Runtime; + +await using var host = new DebuggerServerHost(); +host.Start(); + +var options = new SharpEmuRuntimeOptions { DebugHook = host.Hook }; +using var runtime = SharpEmuRuntime.CreateDefault(options); +var result = runtime.Run(ebootPath); + +host.NotifyRunCompleted(); +``` diff --git a/src/SharpEmu.CLI/Program.cs b/src/SharpEmu.CLI/Program.cs index 478ff37..3dc2fe5 100644 --- a/src/SharpEmu.CLI/Program.cs +++ b/src/SharpEmu.CLI/Program.cs @@ -262,6 +262,32 @@ internal static partial class Program return 2; } + if (!TryGetDebugServerOptions(args, out var debugServerEnabled, out var debugServerOptions, out var debugServerError)) + { + Log.Error($"Invalid --debug-server endpoint: {debugServerError}"); + return 1; + } + + SharpEmu.Debugger.DebuggerServerHost? debugHost = null; + if (debugServerEnabled) + { + debugHost = new SharpEmu.Debugger.DebuggerServerHost(debugServerOptions); + try + { + debugHost.Start(); + Log.Info($"Live debug server listening on {debugHost.Endpoint}. Attach with SharpEmu.DebugClient."); + // With StopAtEntry, the guest parks at its first frame until a + // client connects and continues. + runtimeOptions = runtimeOptions with { DebugHook = debugHost.Hook }; + } + catch (Exception ex) + { + Log.Error("Failed to start the debug server.", ex); + debugHost.DisposeAsync().AsTask().GetAwaiter().GetResult(); + return 6; + } + } + Console.Error.WriteLine("[DEBUG] Creating runtime..."); try @@ -335,6 +361,12 @@ internal static partial class Program } finally { + if (debugHost is not null) + { + debugHost.NotifyRunCompleted(); + debugHost.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + HostSessionControl.SetEmbeddedHostSurface(0); if (hostSurface is not null) { @@ -998,8 +1030,45 @@ internal static partial class Program private static void PrintUsage() { - Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=] [--log-level=] [--log-file[=]] "); + Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=] [--log-level=] [--log-file[=]] [--debug-server[=host:port]] "); Log.Info(@"Example: SharpEmu.CLI --cpu-engine=native --trace-imports=64 --log-level=debug --log-file ""E:\Games\...\eboot.bin"""); + Log.Info("Debug server: --debug-server starts a live debug listener (default 127.0.0.1:5714); connect with SharpEmu.DebugClient."); + } + + /// + /// Detects the --debug-server flag and parses its optional + /// host:port endpoint. Returns false only when the flag is present but + /// its endpoint is malformed, so the caller can abort with a clear error. + /// + private static bool TryGetDebugServerOptions( + string[] args, + out bool enabled, + out SharpEmu.Debugger.Server.DebuggerServerOptions options, + out string error) + { + enabled = false; + options = new SharpEmu.Debugger.Server.DebuggerServerOptions(); + error = string.Empty; + foreach (var argument in args) + { + if (string.Equals(argument, "--debug-server", StringComparison.OrdinalIgnoreCase)) + { + enabled = true; + continue; + } + + const string prefix = "--debug-server="; + if (argument.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + enabled = true; + if (!SharpEmu.Debugger.Server.DebuggerServerOptions.TryParseEndpoint(argument[prefix.Length..], out options, out error)) + { + return false; + } + } + } + + return true; } private static bool TryParseArguments( @@ -1033,6 +1102,15 @@ internal static partial class Program continue; } + // The debug-server endpoint is parsed separately (see + // TryGetDebugServerOptions); accept the flag here so it is not + // rejected as an unknown option or mistaken for the eboot path. + if (string.Equals(argument, "--debug-server", StringComparison.OrdinalIgnoreCase) || + argument.StartsWith("--debug-server=", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + if (string.Equals(argument, "--trace-imports", StringComparison.OrdinalIgnoreCase)) { importTraceLimit = DefaultImportTraceLimit; diff --git a/src/SharpEmu.CLI/SharpEmu.CLI.csproj b/src/SharpEmu.CLI/SharpEmu.CLI.csproj index 09cce94..f8e9f95 100644 --- a/src/SharpEmu.CLI/SharpEmu.CLI.csproj +++ b/src/SharpEmu.CLI/SharpEmu.CLI.csproj @@ -7,6 +7,7 @@ SPDX-License-Identifier: GPL-2.0-or-later + diff --git a/src/SharpEmu.CLI/packages.lock.json b/src/SharpEmu.CLI/packages.lock.json index 503035f..f23527a 100644 --- a/src/SharpEmu.CLI/packages.lock.json +++ b/src/SharpEmu.CLI/packages.lock.json @@ -216,9 +216,17 @@ "type": "Project", "dependencies": { "Iced": "[1.21.0, )", - "SharpEmu.HLE": "[0.0.2-beta.2, )", - "SharpEmu.Libs": "[0.0.2-beta.2, )", - "SharpEmu.Logging": "[0.0.2-beta.2, )" + "SharpEmu.HLE": "[0.0.2-beta.3, )", + "SharpEmu.Libs": "[0.0.2-beta.3, )", + "SharpEmu.Logging": "[0.0.2-beta.3, )" + } + }, + "sharpemu.debugger": { + "type": "Project", + "dependencies": { + "SharpEmu.Core": "[0.0.2-beta.3, )", + "SharpEmu.HLE": "[0.0.2-beta.3, )", + "SharpEmu.Logging": "[0.0.2-beta.3, )" } }, "sharpemu.gui": { @@ -228,24 +236,24 @@ "Avalonia.Desktop": "[11.3.18, )", "Avalonia.Fonts.Inter": "[11.3.18, )", "Avalonia.Themes.Fluent": "[11.3.18, )", - "SharpEmu.Core": "[0.0.2-beta.2, )", - "SharpEmu.Libs": "[0.0.2-beta.2, )", - "SharpEmu.Logging": "[0.0.2-beta.2, )", + "SharpEmu.Core": "[0.0.2-beta.3, )", + "SharpEmu.Libs": "[0.0.2-beta.3, )", + "SharpEmu.Logging": "[0.0.2-beta.3, )", "Tmds.DBus.Protocol": "[0.21.3, )" } }, "sharpemu.hle": { "type": "Project", "dependencies": { - "SharpEmu.Logging": "[0.0.2-beta.2, )" + "SharpEmu.Logging": "[0.0.2-beta.3, )" } }, "sharpemu.libs": { "type": "Project", "dependencies": { - "SharpEmu.HLE": "[0.0.2-beta.2, )", - "SharpEmu.ShaderCompiler": "[0.0.2-beta.2, )", - "SharpEmu.ShaderCompiler.Vulkan": "[0.0.2-beta.2, )", + "SharpEmu.HLE": "[0.0.2-beta.3, )", + "SharpEmu.ShaderCompiler": "[0.0.2-beta.3, )", + "SharpEmu.ShaderCompiler.Vulkan": "[0.0.2-beta.3, )", "Silk.NET.Input": "[2.23.0, )", "Silk.NET.Vulkan": "[2.23.0, )", "Silk.NET.Vulkan.Extensions.EXT": "[2.23.0, )", @@ -259,13 +267,13 @@ "sharpemu.shadercompiler": { "type": "Project", "dependencies": { - "SharpEmu.HLE": "[0.0.2-beta.2, )" + "SharpEmu.HLE": "[0.0.2-beta.3, )" } }, "sharpemu.shadercompiler.vulkan": { "type": "Project", "dependencies": { - "SharpEmu.ShaderCompiler": "[0.0.2-beta.2, )" + "SharpEmu.ShaderCompiler": "[0.0.2-beta.3, )" } }, "Avalonia": { diff --git a/src/SharpEmu.Core/Cpu/CpuDispatcher.cs b/src/SharpEmu.Core/Cpu/CpuDispatcher.cs index c763599..78267bb 100644 --- a/src/SharpEmu.Core/Cpu/CpuDispatcher.cs +++ b/src/SharpEmu.Core/Cpu/CpuDispatcher.cs @@ -3,6 +3,7 @@ using System.Buffers.Binary; using System.Text; +using SharpEmu.Core.Cpu.Debugging; using SharpEmu.Core.Cpu.Native; using SharpEmu.Core.Loader; using SharpEmu.Core.Memory; @@ -272,7 +273,23 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable entryFrameDiagnostic, Environment.NewLine, "CpuEngine: native-only"); + // Frame boundaries an attached debugger observes; null hook = a branch. + var debugHook = executionOptions.DebugHook; + var debugFrame = debugHook is null + ? null + : new CpuContextDebugFrame( + frameKind == EntryFrameKind.ProcessEntry + ? CpuDebugFrameKind.ProcessEntry + : CpuDebugFrameKind.ModuleInitializer, + entryPoint, + processImageName, + context, + effectiveImportStubs); + debugHook?.OnFrameEnter(debugFrame!); + _nativeCpuBackend ??= new DirectExecutionBackend(_moduleManager); + // Let backend stall reports reference the same frame as entry. + (_nativeCpuBackend as DirectExecutionBackend)?.SetActiveDebugFrame(debugFrame); if (_nativeCpuBackend.TryExecute( context, entryPoint, @@ -282,6 +299,7 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable executionOptions, out var nativeResult)) { + debugHook?.OnFrameExit(debugFrame!, nativeResult); LastSessionSummary = new CpuSessionSummary( nativeResult, nativeResult == OrbisGen2Result.ORBIS_GEN2_OK @@ -296,6 +314,8 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable return nativeResult; } + debugHook?.OnFrameExit(debugFrame!, OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_IMPLEMENTED); + var backendName = string.IsNullOrWhiteSpace(_nativeCpuBackend.BackendName) ? "native-backend" : _nativeCpuBackend.BackendName; diff --git a/src/SharpEmu.Core/Cpu/CpuExecutionOptions.cs b/src/SharpEmu.Core/Cpu/CpuExecutionOptions.cs index cc96fa8..02209a9 100644 --- a/src/SharpEmu.Core/Cpu/CpuExecutionOptions.cs +++ b/src/SharpEmu.Core/Cpu/CpuExecutionOptions.cs @@ -1,15 +1,26 @@ // Copyright (C) 2026 SharpEmu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +using SharpEmu.Core.Cpu.Debugging; + namespace SharpEmu.Core.Cpu; public readonly struct CpuExecutionOptions { public bool EnableDisasmDiagnostics { get; init; } - + public CpuExecutionEngine CpuEngine { get; init; } public bool StrictDynlibResolution { get; init; } public int ImportTraceLimit { get; init; } + + /// + /// An optional debugger attached to this execution session. When set, the + /// dispatcher notifies it at each frame boundary via + /// / . + /// Null when no debugger is attached, which is the default and imposes no + /// runtime cost. + /// + public ICpuDebugHook? DebugHook { get; init; } } diff --git a/src/SharpEmu.Core/Cpu/Debugging/CpuContextDebugFrame.cs b/src/SharpEmu.Core/Cpu/Debugging/CpuContextDebugFrame.cs new file mode 100644 index 0000000..5de7a5f --- /dev/null +++ b/src/SharpEmu.Core/Cpu/Debugging/CpuContextDebugFrame.cs @@ -0,0 +1,66 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.HLE; + +namespace SharpEmu.Core.Cpu.Debugging; + +/// +/// Adapts a live to . The +/// dispatcher creates one of these around the guest context it is about to run +/// and passes it to the attached ; every accessor +/// forwards directly to the underlying context. +/// +internal sealed class CpuContextDebugFrame : ICpuDebugFrame +{ + private readonly CpuContext _context; + + internal CpuContextDebugFrame( + CpuDebugFrameKind kind, + ulong entryPoint, + string label, + CpuContext context, + IReadOnlyDictionary importStubs) + { + Kind = kind; + EntryPoint = entryPoint; + Label = label ?? string.Empty; + _context = context ?? throw new ArgumentNullException(nameof(context)); + ImportStubs = importStubs ?? new Dictionary(); + } + + public CpuDebugFrameKind Kind { get; } + + public Generation Generation => _context.TargetGeneration; + + public ulong EntryPoint { get; } + + public string Label { get; } + + public ICpuMemory Memory => _context.Memory; + + public ulong GetRegister(CpuRegister register) => _context[register]; + + public void SetRegister(CpuRegister register, ulong value) => _context[register] = value; + + public ulong Rip + { + get => _context.Rip; + set => _context.Rip = value; + } + + public ulong Rflags + { + get => _context.Rflags; + set => _context.Rflags = value; + } + + public ulong FsBase => _context.FsBase; + + public ulong GsBase => _context.GsBase; + + public void GetXmm(int registerIndex, out ulong low, out ulong high) + => _context.GetXmmRegister(registerIndex, out low, out high); + + public IReadOnlyDictionary ImportStubs { get; } +} diff --git a/src/SharpEmu.Core/Cpu/Debugging/CpuDebugFrameKind.cs b/src/SharpEmu.Core/Cpu/Debugging/CpuDebugFrameKind.cs new file mode 100644 index 0000000..0abec92 --- /dev/null +++ b/src/SharpEmu.Core/Cpu/Debugging/CpuDebugFrameKind.cs @@ -0,0 +1,18 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.Core.Cpu.Debugging; + +/// +/// Identifies the kind of guest entry frame a debugger is observing. The +/// dispatcher enters a fresh frame for the process entry point and for every +/// module initializer, so the debug layer can label stops accordingly. +/// +public enum CpuDebugFrameKind +{ + /// The guest process entry point (eboot.bin start). + ProcessEntry, + + /// A module DT_INIT / initializer routine. + ModuleInitializer, +} diff --git a/src/SharpEmu.Core/Cpu/Debugging/CpuStallInfo.cs b/src/SharpEmu.Core/Cpu/Debugging/CpuStallInfo.cs new file mode 100644 index 0000000..159407d --- /dev/null +++ b/src/SharpEmu.Core/Cpu/Debugging/CpuStallInfo.cs @@ -0,0 +1,70 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.Core.Cpu.Debugging; + +/// The kind of execution stall the backend detected. +public enum CpuStallKind +{ + /// + /// The guest is repeatedly re-dispatching the same import with no forward + /// progress — most commonly a spin on a mutex lock/unlock pair. + /// + ImportLoop, +} + +/// +/// Details of a detected stall handed to . +/// Reported from the emulation thread at the point the backend recognises the +/// livelock, before it forces the guest out of the loop. +/// +public readonly struct CpuStallInfo +{ + public CpuStallInfo( + CpuStallKind kind, + string? nid, + ulong instructionPointer, + long dispatchIndex, + ulong argument0, + ulong argument1, + string detail, + string? libraryName = null, + string? functionName = null) + { + Kind = kind; + Nid = nid; + InstructionPointer = instructionPointer; + DispatchIndex = dispatchIndex; + Argument0 = argument0; + Argument1 = argument1; + Detail = detail ?? string.Empty; + LibraryName = libraryName; + FunctionName = functionName; + } + + public CpuStallKind Kind { get; } + + /// The NID of the import being spun on, when known. + public string? Nid { get; } + + /// The guest return address of the looping import dispatch. + public ulong InstructionPointer { get; } + + /// The import dispatch counter at detection time. + public long DispatchIndex { get; } + + /// The first two guest ABI arguments at stall detection. + public ulong Argument0 { get; } + + public ulong Argument1 { get; } + + /// The resolved HLE export, when the NID is registered. + public string? LibraryName { get; } + + public string? FunctionName { get; } + + public bool IsResolved => !string.IsNullOrWhiteSpace(FunctionName); + + /// A human-readable one-line summary of the stall. + public string Detail { get; } +} diff --git a/src/SharpEmu.Core/Cpu/Debugging/ICpuDebugFrame.cs b/src/SharpEmu.Core/Cpu/Debugging/ICpuDebugFrame.cs new file mode 100644 index 0000000..622fa2e --- /dev/null +++ b/src/SharpEmu.Core/Cpu/Debugging/ICpuDebugFrame.cs @@ -0,0 +1,66 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.HLE; + +namespace SharpEmu.Core.Cpu.Debugging; + +/// +/// A live view of the guest CPU state at a dispatch boundary, handed to an +/// so a debugger can read and mutate registers and +/// guest memory without taking a dependency on the concrete +/// CpuContext/CpuDispatcher types. +/// +/// +/// The frame instance is only valid for the duration of the hook call that +/// receives it (between and the +/// matching ). Reads and writes are +/// forwarded straight to the underlying guest context, so mutations made from +/// a hook are observed by the CPU backend when it resumes the frame. +/// +public interface ICpuDebugFrame +{ + /// The kind of frame being executed. + CpuDebugFrameKind Kind { get; } + + /// The guest ABI generation this frame targets. + Generation Generation { get; } + + /// The guest virtual address the frame begins executing at. + ulong EntryPoint { get; } + + /// + /// A human-readable label for the frame (process image name or module name). + /// + string Label { get; } + + /// Guest-addressable memory for this frame. + ICpuMemory Memory { get; } + + /// Reads a general-purpose register. + ulong GetRegister(CpuRegister register); + + /// Overwrites a general-purpose register. + void SetRegister(CpuRegister register, ulong value); + + /// The instruction pointer. + ulong Rip { get; set; } + + /// The flags register. + ulong Rflags { get; set; } + + /// The FS segment base (guest TLS pointer). + ulong FsBase { get; } + + /// The GS segment base. + ulong GsBase { get; } + + /// Reads the 128-bit value of an XMM register. + void GetXmm(int registerIndex, out ulong low, out ulong high); + + /// + /// The import stubs (guest address to NID) resolved for this frame, so a + /// debugger can annotate calls into HLE exports. + /// + IReadOnlyDictionary ImportStubs { get; } +} diff --git a/src/SharpEmu.Core/Cpu/Debugging/ICpuDebugHook.cs b/src/SharpEmu.Core/Cpu/Debugging/ICpuDebugHook.cs new file mode 100644 index 0000000..f945821 --- /dev/null +++ b/src/SharpEmu.Core/Cpu/Debugging/ICpuDebugHook.cs @@ -0,0 +1,49 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.HLE; + +namespace SharpEmu.Core.Cpu.Debugging; + +/// +/// The seam the CPU dispatcher uses to notify an attached debugger when guest +/// execution crosses a frame boundary. Implemented outside of Core (for +/// example by SharpEmu.Debugger) and supplied through +/// . +/// +/// +/// This is intentionally coarse-grained: it exposes the entry and exit of each +/// dispatched frame rather than per-instruction stepping. Per-instruction +/// control requires cooperation from the native execution backend and is layered +/// on top of this seam as the backend gains support; keeping the dispatcher-level +/// contract stable lets the debugger infrastructure exist independently of that +/// work. Implementations must be thread-safe: frames may be dispatched from the +/// dedicated emulation thread while a debug server services clients on its own +/// threads. +/// +public interface ICpuDebugHook +{ + /// + /// Invoked immediately before the native backend begins executing a frame. + /// The debugger may inspect or mutate and may block + /// the calling thread (for example, to honour a pause request) before + /// returning to allow execution to proceed. + /// + void OnFrameEnter(ICpuDebugFrame frame); + + /// + /// Invoked after a frame completes, whether it returned to the host or + /// terminated with an error. reflects the final + /// guest state. + /// + void OnFrameExit(ICpuDebugFrame frame, OrbisGen2Result result); + + /// + /// Invoked from the emulation thread when the backend detects an execution + /// stall (for example a mutex spin loop) in the running frame, before it + /// forces the guest out of the loop. As with , the + /// implementation may inspect and block to honour a + /// break before returning to let the backend proceed. + /// + void OnStall(ICpuDebugFrame frame, CpuStallInfo info); +} diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs index f1b738e..db18bcf 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs @@ -10,6 +10,7 @@ using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Threading; +using SharpEmu.Core.Cpu.Debugging; using SharpEmu.Core.Cpu; using SharpEmu.HLE; using SharpEmu.Libs.Kernel; @@ -316,11 +317,15 @@ public sealed partial class DirectExecutionBackend } if (!isGuestWorker && !ActiveForcedGuestExit && - ShouldForceGuestExitOnImportLoop(in importStubEntry, num7, num, value, value2) && - TryForceGuestExitToHostStub(argPackPtr, num, num7, importStubEntry.Nid)) + ShouldForceGuestExitOnImportLoop(in importStubEntry, num7, num, value, value2)) { - cpuContext[CpuRegister.Rax] = 1uL; - return 1uL; + // Break before the forced exit so the loop state is still live. + NotifyDebuggerStall(CpuStallKind.ImportLoop, in importStubEntry, num7, num, value, value2); + if (TryForceGuestExitToHostStub(argPackPtr, num, num7, importStubEntry.Nid)) + { + cpuContext[CpuRegister.Rax] = 1uL; + return 1uL; + } } bool flag0 = importStubEntry.SuppressStrlenTrace; bool flag = num7 >= 2156221920u && num7 <= 2156225024u; diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs index 1448912..7fbad04 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs @@ -9,6 +9,7 @@ using System.Linq; using System.Runtime.InteropServices; using System.Threading; using SharpEmu.Core.Cpu; +using SharpEmu.Core.Cpu.Debugging; using SharpEmu.Core.Loader; using SharpEmu.Core.Memory; using SharpEmu.HLE; @@ -235,6 +236,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I private CpuContext? _cpuContext; + // Debugger seam; both null when no debugger is attached. + private ICpuDebugHook? _debugHook; + + private ICpuDebugFrame? _activeDebugFrame; + [ThreadStatic] private static DirectExecutionBackend? _activeExecutionBackend; @@ -890,6 +896,49 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I private bool HasActiveExecutionThread => ReferenceEquals(_activeExecutionBackend, this); + /// + /// Binds the debug frame view the dispatcher created for the frame about to + /// run, so stall notifications reference the same frame the debugger saw at + /// entry. Set to null when no debugger is attached. + /// + internal void SetActiveDebugFrame(ICpuDebugFrame? frame) => _activeDebugFrame = frame; + + /// + /// Notifies an attached debugger of a detected execution stall. No-op when no + /// debugger is attached or no frame is bound. The debugger may block here to + /// present a break before the backend forces the guest out of the loop. + /// + private void NotifyDebuggerStall( + CpuStallKind kind, + in ImportStubEntry import, + ulong instructionPointer, + long dispatchIndex, + ulong argument0, + ulong argument1) + { + var hook = _debugHook; + var frame = _activeDebugFrame; + if (hook is null || frame is null) + { + return; + } + + var export = import.Export; + var exportDescription = export is null ? "unresolved" : $"{export.LibraryName}:{export.Name}"; + var detail = $"kind={kind}, nid={import.Nid}, export={exportDescription}, dispatch#{dispatchIndex}, " + + $"rip=0x{instructionPointer:X16}, arg0=0x{argument0:X16}, arg1=0x{argument1:X16}"; + hook.OnStall(frame, new CpuStallInfo( + kind, + import.Nid, + instructionPointer, + dispatchIndex, + argument0, + argument1, + detail, + export?.LibraryName, + export?.Name)); + } + private CpuContext? ActiveCpuContext => HasActiveExecutionThread ? _activeCpuContext : _cpuContext; private ulong ActiveEntryReturnSentinelRip @@ -1051,6 +1100,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I Console.Error.WriteLine(_moduleManager.TryGetExport("L-Q3LEjIbgA", out ExportedFunction export2) ? ("[LOADER][INFO] ExportCheck map_direct: " + export2.LibraryName + ":" + export2.Name) : "[LOADER][INFO] ExportCheck map_direct: MISSING"); _entryPoint = entryPoint; _cpuContext = context; + _debugHook = executionOptions.DebugHook; _returnFallbackTarget = context[CpuRegister.Rsi]; Volatile.Write(ref _globalFallbackTarget, _returnFallbackTarget); Volatile.Write(ref _globalUnresolvedReturnStub, (ulong)_unresolvedReturnStub); diff --git a/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs b/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs index 6af1f74..e1c853e 100644 --- a/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs +++ b/src/SharpEmu.Core/Runtime/SharpEmuRuntime.cs @@ -68,6 +68,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime CpuEngine = cpuExecutionOptions.CpuEngine, StrictDynlibResolution = cpuExecutionOptions.StrictDynlibResolution, ImportTraceLimit = Math.Max(0, cpuExecutionOptions.ImportTraceLimit), + DebugHook = cpuExecutionOptions.DebugHook, }; _fileSystem = fileSystem ?? new PhysicalFileSystem(); } @@ -79,6 +80,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime CpuEngine = options.CpuEngine, StrictDynlibResolution = options.StrictDynlibResolution, ImportTraceLimit = Math.Max(0, options.ImportTraceLimit), + DebugHook = options.DebugHook, }; var moduleManager = new ModuleManager(); // The compile-time generated registry (SharpEmu.SourceGenerators) is the sole diff --git a/src/SharpEmu.Core/Runtime/SharpEmuRuntimeOptions.cs b/src/SharpEmu.Core/Runtime/SharpEmuRuntimeOptions.cs index 7fc167b..de9adf8 100644 --- a/src/SharpEmu.Core/Runtime/SharpEmuRuntimeOptions.cs +++ b/src/SharpEmu.Core/Runtime/SharpEmuRuntimeOptions.cs @@ -4,6 +4,7 @@ namespace SharpEmu.Core.Runtime; using SharpEmu.Core.Cpu; +using SharpEmu.Core.Cpu.Debugging; public readonly struct SharpEmuRuntimeOptions { @@ -12,4 +13,11 @@ public readonly struct SharpEmuRuntimeOptions public bool StrictDynlibResolution { get; init; } public int ImportTraceLimit { get; init; } + + /// + /// An optional debugger to attach to guest execution. Flows through to + /// . Null (the default) runs with + /// no debugger attached. + /// + public ICpuDebugHook? DebugHook { get; init; } } diff --git a/src/SharpEmu.DebugClient/ClientEndpoint.cs b/src/SharpEmu.DebugClient/ClientEndpoint.cs new file mode 100644 index 0000000..b70976a --- /dev/null +++ b/src/SharpEmu.DebugClient/ClientEndpoint.cs @@ -0,0 +1,54 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Net; + +namespace SharpEmu.DebugClient; + +/// +/// Parses the host:port the client connects to. Mirrors the server's +/// defaults (loopback, port 5714) so a bare invocation attaches to a local +/// emulator with no arguments. +/// +internal static class ClientEndpoint +{ + public const int DefaultPort = 5714; + + public static bool TryParse(string? text, out string host, out int port, out string error) + { + host = "127.0.0.1"; + port = DefaultPort; + error = string.Empty; + if (string.IsNullOrWhiteSpace(text)) + { + return true; + } + + var value = text.Trim(); + var separator = value.LastIndexOf(':'); + if (separator >= 0) + { + var portText = value[(separator + 1)..]; + if (portText.Length > 0 && (!int.TryParse(portText, out port) || port is <= 0 or > 65535)) + { + error = $"Invalid port '{portText}'."; + return false; + } + + value = value[..separator]; + } + + if (!string.IsNullOrWhiteSpace(value)) + { + host = string.Equals(value, "localhost", StringComparison.OrdinalIgnoreCase) ? "127.0.0.1" : value; + } + + if (!IPAddress.TryParse(host, out _) && !Uri.CheckHostName(host).Equals(UriHostNameType.Dns)) + { + error = $"Invalid host '{host}'."; + return false; + } + + return true; + } +} diff --git a/src/SharpEmu.DebugClient/CommandTranslator.cs b/src/SharpEmu.DebugClient/CommandTranslator.cs new file mode 100644 index 0000000..847199a --- /dev/null +++ b/src/SharpEmu.DebugClient/CommandTranslator.cs @@ -0,0 +1,160 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Text.Json; + +namespace SharpEmu.DebugClient; + +/// +/// Turns a friendly REPL line (mem 0x1000 64) into the JSON request the +/// server understands. Local-only verbs (help, quit) are reported back to the +/// caller instead of producing a request. +/// +internal static class CommandTranslator +{ + public enum ActionKind + { + SendRequest, + ShowHelp, + Quit, + Ignore, + Error, + } + + public readonly record struct Result(ActionKind Kind, string? Payload = null, string? Error = null); + + public static Result Translate(string line) + { + var trimmed = line.Trim(); + if (trimmed.Length == 0) + { + return new Result(ActionKind.Ignore); + } + + var parts = trimmed.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + var verb = parts[0].ToLowerInvariant(); + switch (verb) + { + case "help" or "?": + return new Result(ActionKind.ShowHelp); + case "quit" or "exit" or "q": + return new Result(ActionKind.Quit); + case "raw": + var json = trimmed[verb.Length..].Trim(); + return json.Length == 0 + ? Error("raw requires a JSON object argument.") + : Send(json); + + case "ping": + return Request("ping"); + case "status" or "info": + return Request("status"); + case "state": + return Request("state"); + case "regs" or "registers": + return Request("registers"); + case "continue" or "cont" or "c": + return Request("continue"); + case "step" or "s": + return Request("step"); + case "pause" or "p": + return Request("pause"); + case "bp" or "breakpoints" or "bl": + return Request("list-breakpoints"); + + case "setreg": + return parts.Length >= 3 + ? Request("set-register", ("register", parts[1]), ("value", parts[2])) + : Error("Usage: setreg "); + case "mem" or "read": + return parts.Length >= 3 + ? Request("read-memory", ("address", parts[1]), ("length", parts[2])) + : Error("Usage: mem
"); + case "write": + return parts.Length >= 3 + ? Request("write-memory", ("address", parts[1]), ("bytes", parts[2])) + : Error("Usage: write
"); + case "break" or "b": + if (parts.Length < 2) + { + return Error("Usage: break
[kind] [length]"); + } + + var breakArgs = new List<(string, string)> { ("address", parts[1]) }; + if (parts.Length >= 3) + { + breakArgs.Add(("kind", parts[2])); + } + + if (parts.Length >= 4) + { + breakArgs.Add(("length", parts[3])); + } + + return Request("add-breakpoint", breakArgs.ToArray()); + case "del" or "rm" or "delete": + return parts.Length >= 2 + ? Request("remove-breakpoint", ("id", parts[1])) + : Error("Usage: del "); + case "enable": + return parts.Length >= 2 + ? Request("enable-breakpoint", ("id", parts[1]), ("enabled", "true")) + : Error("Usage: enable "); + case "disable": + return parts.Length >= 2 + ? RequestWithBool("enable-breakpoint", ("id", parts[1]), enabledName: "enabled", enabled: false) + : Error("Usage: disable "); + + default: + return Error($"Unknown command '{verb}'. Type 'help' for the command list."); + } + } + + private static Result Request(string command, params (string Name, string Value)[] args) + { + var payload = new Dictionary { ["command"] = command }; + foreach (var (name, value) in args) + { + payload[name] = value; + } + + return Send(JsonSerializer.Serialize(payload)); + } + + private static Result RequestWithBool(string command, (string Name, string Value) idArg, string enabledName, bool enabled) + { + var payload = new Dictionary + { + ["command"] = command, + [idArg.Name] = idArg.Value, + [enabledName] = enabled, + }; + return Send(JsonSerializer.Serialize(payload)); + } + + private static Result Send(string json) => new(ActionKind.SendRequest, json); + + private static Result Error(string message) => new(ActionKind.Error, Error: message); + + public const string HelpText = """ + SharpEmu debug client commands: + status | info Show target state and last stop + state Show run state only + regs | registers Dump integer registers (paused only) + setreg Set a register (rip/rflags/gp, paused only) + mem Read guest memory as hex (paused only) + write Write guest memory from hex (paused only) + break [kind] [len] Add a breakpoint (kind: execute/readwatch/writewatch/accesswatch) + bp | breakpoints List breakpoints + del Remove a breakpoint + enable / disable Toggle a breakpoint + continue | c Resume the target + step | s Resume and stop at the next frame + pause Ask a running target to stop + ping Round-trip check + raw Send a literal JSON request + help | ? Show this help + quit | exit Disconnect and exit + Addresses and values accept decimal or 0x-prefixed hex. + """; +} diff --git a/src/SharpEmu.DebugClient/DEVELOPER_READ.md b/src/SharpEmu.DebugClient/DEVELOPER_READ.md new file mode 100644 index 0000000..7bbefb1 --- /dev/null +++ b/src/SharpEmu.DebugClient/DEVELOPER_READ.md @@ -0,0 +1,153 @@ + + +# SharpEmu.DebugClient + +A small, standalone command-line client that connects to the SharpEmu +emulator's **live debug server** and drives it interactively. It ships as its +own executable (`SharpEmu.DebugClient`) and takes no dependency on the emulator +assemblies — it speaks the server's line-delimited JSON protocol directly over +TCP, so you can also drive the server from `nc`, a script, or your own tool. + +> **Status:** infrastructure. The transport, protocol, session model, and +> breakpoint store are in place. Stops are delivered at **frame boundaries** +> (process entry and each module initializer); per-instruction stepping and data +> watchpoints are part of the surface and become live as the CPU backend grows +> the corresponding hooks. See [`docs/debugger-server.md`](../../docs/debugger-server.md) +> for the architecture and protocol reference. + +## How it fits together + +``` ++-------------------------+ TCP (JSON lines) +----------------------+ +| SharpEmu (emulator) | <------------------------------> | SharpEmu.DebugClient | +| --debug-server | | (this executable) | +| | | | +| DebuggerServerHost | | REPL / --exec | +| +- DebuggerServer | frame boundaries via ICpuDebugHook| | +| +- DebuggerSession <-+------ CPU dispatcher ------------ | | ++-------------------------+ +----------------------+ +``` + +The emulator is the **server**; this client is a separate process that connects +to it and issues commands. The two never share memory — everything crosses the +socket as JSON. + +## Building + +```bash +dotnet build src/SharpEmu.DebugClient/SharpEmu.DebugClient.csproj +``` + +## Quick start + +1. Launch the emulator with the debug server enabled. It listens on + `127.0.0.1:5714` by default and, with stop-at-entry on, parks the guest at + its first frame until you continue: + + ```bash + SharpEmu --debug-server "/path/to/game/eboot.bin" + # or choose an endpoint: + SharpEmu --debug-server=127.0.0.1:5714 "/path/to/game/eboot.bin" + ``` + +2. In another terminal, attach the client: + + ```bash + SharpEmu.DebugClient # defaults to 127.0.0.1:5714 + SharpEmu.DebugClient 127.0.0.1:5714 # explicit endpoint + ``` + +3. Drive the target: + + ``` + status + regs + break 0x00000008801234a0 + continue + mem 0x00000008802000000 64 + ``` + +## Invocation + +``` +SharpEmu.DebugClient [host:port] [--exec ""]... [--quiet] +``` + +| Option | Meaning | +| ------------- | ------------------------------------------------------------- | +| `host:port` | Server endpoint. Default `127.0.0.1:5714`. `localhost` is fine. | +| `--exec, -e` | Run one command non-interactively, then exit. Repeatable. | +| `--quiet` | Suppress the connection banner. | +| `--help, -h` | Show usage and the command list. | + +Non-interactive example (scriptable): + +```bash +SharpEmu.DebugClient --exec "break 0x8801234a0" --exec "continue" +``` + +## Commands + +Addresses and values accept decimal or `0x`-prefixed hex. Register and memory +commands only succeed while the target is **paused**. + +| Command | Server verb | Description | +| ------- | ----------- | ----------- | +| `status` \| `info` | `status` | Target state plus the last stop. | +| `state` | `state` | Run state only (`Running`/`Paused`/…). | +| `regs` \| `registers` | `registers` | Dump the integer registers. | +| `setreg ` | `set-register` | Set `rip`, `rflags`, or a GP register. | +| `mem ` \| `read ` | `read-memory` | Read guest memory as hex. | +| `write ` | `write-memory` | Write guest memory from a hex string. | +| `break [kind] [len]` \| `b …` | `add-breakpoint` | Add a breakpoint. `kind`: `execute` (default), `readwatch`, `writewatch`, `accesswatch`. | +| `bp` \| `breakpoints` | `list-breakpoints` | List breakpoints. | +| `del ` \| `rm ` | `remove-breakpoint` | Remove a breakpoint. | +| `enable ` / `disable ` | `enable-breakpoint` | Toggle a breakpoint. | +| `continue` \| `c` | `continue` | Resume a paused target. | +| `step` \| `s` | `step` | Resume and stop at the next frame boundary. | +| `pause` | `pause` | Ask a running target to stop at the next boundary. | +| `ping` | `ping` | Round-trip liveness check. | +| `raw ` | *(passthrough)* | Send a literal JSON request. | +| `help` \| `?` | — | Show the command list (local). | +| `quit` \| `exit` | — | Disconnect and exit (local). | + +## Output + +The client prints two kinds of lines as they arrive: + +- `reply>` — the response to a command you sent (`ok`, plus `data` or `error`). +- `event>` — an unsolicited notification: `hello` on connect, `stopped` when the + target hits a breakpoint / entry / step / pause, `resumed` on continue, and + `terminated` when the run ends. + +Because replies and events share one stream, the client prints everything it +receives rather than pairing replies to requests — a `stopped` event may arrive +between your command and its reply. + +## Protocol (for building your own client) + +One JSON object per line, UTF-8, `\n`-terminated, in both directions. + +Request: + +```json +{"command":"read-memory","address":"0x8802000000","length":64} +``` + +Reply: + +```json +{"ok":true,"command":"read-memory","data":{"address":"0x0000000880200000","length":64,"bytes":"48894C24.."}} +``` + +Event: + +```json +{"event":"stopped","reason":"Breakpoint","address":"0x00000008801234A0","frameKind":"ProcessEntry","frameLabel":"eboot.bin","registers":{ ... }} +``` + +The full verb list and payload fields live in +[`docs/debugger-server.md`](../../docs/debugger-server.md). diff --git a/src/SharpEmu.DebugClient/DebugClientConnection.cs b/src/SharpEmu.DebugClient/DebugClientConnection.cs new file mode 100644 index 0000000..84db03c --- /dev/null +++ b/src/SharpEmu.DebugClient/DebugClientConnection.cs @@ -0,0 +1,120 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Net.Sockets; +using System.Text; +using System.Text.Json; + +namespace SharpEmu.DebugClient; + +/// +/// A thin TCP wrapper around the server's line-delimited JSON protocol: it +/// writes request lines and runs a background loop that prints incoming +/// responses and events as they arrive. Because the stream interleaves replies +/// with asynchronous stop/resume events, a single reader printing everything is +/// simpler and more robust than correlating request/response pairs. +/// +internal sealed class DebugClientConnection : IAsyncDisposable +{ + private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); + private static readonly JsonSerializerOptions PrettyOptions = new() { WriteIndented = true }; + + private readonly TcpClient _client; + private readonly StreamReader _reader; + private readonly StreamWriter _writer; + private readonly SemaphoreSlim _writeLock = new(1, 1); + + private DebugClientConnection(TcpClient client, NetworkStream stream) + { + _client = client; + _reader = new StreamReader(stream, Utf8NoBom); + _writer = new StreamWriter(stream, Utf8NoBom) { AutoFlush = false }; + } + + public static async Task ConnectAsync(string host, int port, CancellationToken cancellationToken) + { + var client = new TcpClient(); + await client.ConnectAsync(host, port, cancellationToken).ConfigureAwait(false); + return new DebugClientConnection(client, client.GetStream()); + } + + /// Continuously prints incoming lines until the stream closes. + public async Task ReceiveLoopAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + var line = await _reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); + if (line is null) + { + Console.WriteLine(); + Console.WriteLine("[connection closed by server]"); + return; + } + + Print(line); + } + } + catch (OperationCanceledException) + { + } + catch (IOException) + { + Console.WriteLine(); + Console.WriteLine("[connection lost]"); + } + } + + public async Task SendAsync(string json, CancellationToken cancellationToken) + { + await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await _writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false); + await _writer.FlushAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _writeLock.Release(); + } + } + + private static void Print(string line) + { + try + { + using var document = JsonDocument.Parse(line); + var root = document.RootElement; + var isEvent = root.TryGetProperty("event", out _); + var prefix = isEvent ? "event>" : "reply>"; + var pretty = JsonSerializer.Serialize(root, PrettyOptions); + Console.WriteLine(); + Console.WriteLine($"{prefix}\n{pretty}"); + } + catch (JsonException) + { + Console.WriteLine(); + Console.WriteLine(line); + } + } + + public async ValueTask DisposeAsync() + { + try + { + await _writer.FlushAsync().ConfigureAwait(false); + } + catch (IOException) + { + } + catch (ObjectDisposedException) + { + } + + _writeLock.Dispose(); + _reader.Dispose(); + await _writer.DisposeAsync().ConfigureAwait(false); + _client.Dispose(); + } +} diff --git a/src/SharpEmu.DebugClient/Program.cs b/src/SharpEmu.DebugClient/Program.cs new file mode 100644 index 0000000..9ae04c5 --- /dev/null +++ b/src/SharpEmu.DebugClient/Program.cs @@ -0,0 +1,193 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Net.Sockets; +using SharpEmu.DebugClient; + +return await ClientProgram.RunAsync(args).ConfigureAwait(false); + +internal static class ClientProgram +{ + public static async Task RunAsync(string[] args) + { + if (args.Any(a => a is "--help" or "-h")) + { + PrintUsage(); + return 0; + } + + string? endpointArg = null; + var execCommands = new List(); + var quiet = false; + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (string.Equals(arg, "--exec", StringComparison.OrdinalIgnoreCase) || string.Equals(arg, "-e", StringComparison.OrdinalIgnoreCase)) + { + if (i + 1 >= args.Length) + { + Console.Error.WriteLine("--exec requires a command argument."); + return 2; + } + + execCommands.Add(args[++i]); + continue; + } + + if (string.Equals(arg, "--quiet", StringComparison.OrdinalIgnoreCase)) + { + quiet = true; + continue; + } + + if (arg.StartsWith('-')) + { + Console.Error.WriteLine($"Unknown option '{arg}'."); + PrintUsage(); + return 2; + } + + endpointArg ??= arg; + } + + if (!ClientEndpoint.TryParse(endpointArg, out var host, out var port, out var endpointError)) + { + Console.Error.WriteLine(endpointError); + return 2; + } + + using var shutdown = new CancellationTokenSource(); + Console.CancelKeyPress += (_, eventArgs) => + { + eventArgs.Cancel = true; + shutdown.Cancel(); + }; + + DebugClientConnection connection; + try + { + connection = await DebugClientConnection.ConnectAsync(host, port, shutdown.Token).ConfigureAwait(false); + } + catch (Exception ex) when (ex is SocketException or OperationCanceledException) + { + Console.Error.WriteLine($"Could not connect to {host}:{port}: {ex.Message}"); + Console.Error.WriteLine("Start the emulator with --debug-server first."); + return 3; + } + + await using (connection) + { + var receiveTask = connection.ReceiveLoopAsync(shutdown.Token); + + if (execCommands.Count > 0) + { + await RunOneShotAsync(connection, execCommands, shutdown.Token).ConfigureAwait(false); + } + else + { + if (!quiet) + { + Console.WriteLine($"Connected to SharpEmu debug server at {host}:{port}."); + Console.WriteLine("Type 'help' for commands, 'quit' to exit."); + } + + await RunReplAsync(connection, shutdown).ConfigureAwait(false); + } + + shutdown.Cancel(); + try + { + await receiveTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + } + + return 0; + } + + private static async Task RunOneShotAsync( + DebugClientConnection connection, + IReadOnlyList commands, + CancellationToken cancellationToken) + { + foreach (var command in commands) + { + var result = CommandTranslator.Translate(command); + switch (result.Kind) + { + case CommandTranslator.ActionKind.SendRequest: + await connection.SendAsync(result.Payload!, cancellationToken).ConfigureAwait(false); + break; + case CommandTranslator.ActionKind.Error: + Console.Error.WriteLine(result.Error); + break; + case CommandTranslator.ActionKind.ShowHelp: + Console.WriteLine(CommandTranslator.HelpText); + break; + } + } + + // Give the server a moment to answer before the client exits. + try + { + await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + } + + private static async Task RunReplAsync(DebugClientConnection connection, CancellationTokenSource shutdown) + { + while (!shutdown.IsCancellationRequested) + { + var line = await Console.In.ReadLineAsync(shutdown.Token).ConfigureAwait(false); + if (line is null) + { + break; + } + + var result = CommandTranslator.Translate(line); + switch (result.Kind) + { + case CommandTranslator.ActionKind.Quit: + return; + case CommandTranslator.ActionKind.ShowHelp: + Console.WriteLine(CommandTranslator.HelpText); + break; + case CommandTranslator.ActionKind.Error: + Console.Error.WriteLine(result.Error); + break; + case CommandTranslator.ActionKind.Ignore: + break; + case CommandTranslator.ActionKind.SendRequest: + try + { + await connection.SendAsync(result.Payload!, shutdown.Token).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException) + { + Console.Error.WriteLine("Send failed; the connection is closed."); + return; + } + + break; + } + } + } + + private static void PrintUsage() + { + Console.WriteLine("SharpEmu.DebugClient — live debugger client for the SharpEmu debug server."); + Console.WriteLine(); + Console.WriteLine("Usage: SharpEmu.DebugClient [host:port] [--exec \"\"]... [--quiet]"); + Console.WriteLine(" host:port Server endpoint (default 127.0.0.1:5714)."); + Console.WriteLine(" --exec, -e Run a command non-interactively (repeatable), then exit."); + Console.WriteLine(" --quiet Suppress the connection banner."); + Console.WriteLine(" --help, -h Show this help."); + Console.WriteLine(); + Console.WriteLine(CommandTranslator.HelpText); + } +} diff --git a/src/SharpEmu.DebugClient/SharpEmu.DebugClient.csproj b/src/SharpEmu.DebugClient/SharpEmu.DebugClient.csproj new file mode 100644 index 0000000..1642b97 --- /dev/null +++ b/src/SharpEmu.DebugClient/SharpEmu.DebugClient.csproj @@ -0,0 +1,16 @@ + + + + + + Exe + SharpEmu.DebugClient + SharpEmu.DebugClient + $(NoWarn);1591 + + diff --git a/src/SharpEmu.Debugger/Breakpoints/Breakpoint.cs b/src/SharpEmu.Debugger/Breakpoints/Breakpoint.cs new file mode 100644 index 0000000..3020789 --- /dev/null +++ b/src/SharpEmu.Debugger/Breakpoints/Breakpoint.cs @@ -0,0 +1,48 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.Debugger.Breakpoints; + +/// +/// A single breakpoint or watchpoint. Instances are immutable; the owning +/// replaces an entry to change its enabled state. +/// +public sealed class Breakpoint +{ + public Breakpoint(int id, BreakpointKind kind, ulong address, ulong length = 1, bool enabled = true) + { + if (length == 0) + { + throw new ArgumentOutOfRangeException(nameof(length), "Breakpoint length must be at least one byte."); + } + + Id = id; + Kind = kind; + Address = address; + Length = length; + Enabled = enabled; + } + + /// The store-assigned identifier used by clients to reference it. + public int Id { get; } + + public BreakpointKind Kind { get; } + + /// The first guest address the breakpoint covers. + public ulong Address { get; } + + /// + /// The number of bytes the breakpoint covers. Always one for + /// ; the watch kinds may span a range. + /// + public ulong Length { get; } + + public bool Enabled { get; } + + /// True when falls within this breakpoint. + public bool Covers(ulong address) => address >= Address && address < Address + Length; + + /// Returns a copy with a different enabled state. + public Breakpoint WithEnabled(bool enabled) + => enabled == Enabled ? this : new Breakpoint(Id, Kind, Address, Length, enabled); +} diff --git a/src/SharpEmu.Debugger/Breakpoints/BreakpointKind.cs b/src/SharpEmu.Debugger/Breakpoints/BreakpointKind.cs new file mode 100644 index 0000000..e766821 --- /dev/null +++ b/src/SharpEmu.Debugger/Breakpoints/BreakpointKind.cs @@ -0,0 +1,26 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.Debugger.Breakpoints; + +/// +/// The kind of stop a breakpoint requests. Execution breakpoints are honoured +/// at the frame-boundary seam that exists today; the data-watch kinds are part +/// of the surface so client protocols and tooling can be built against them, +/// and are armed once the execution backend can report the corresponding +/// accesses. +/// +public enum BreakpointKind +{ + /// Stop when the instruction pointer reaches the address. + Execute, + + /// Stop when the guest reads from the address range. + ReadWatch, + + /// Stop when the guest writes to the address range. + WriteWatch, + + /// Stop when the guest reads from or writes to the address range. + AccessWatch, +} diff --git a/src/SharpEmu.Debugger/Breakpoints/BreakpointStore.cs b/src/SharpEmu.Debugger/Breakpoints/BreakpointStore.cs new file mode 100644 index 0000000..6dac4ba --- /dev/null +++ b/src/SharpEmu.Debugger/Breakpoints/BreakpointStore.cs @@ -0,0 +1,90 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.Debugger.Breakpoints; + +/// +/// A thread-safe registry of breakpoints. The debug server mutates it from +/// client-servicing threads while the emulation thread queries it at frame +/// boundaries, so every operation takes the same lock. +/// +public sealed class BreakpointStore +{ + private readonly object _sync = new(); + private readonly Dictionary _breakpoints = new(); + private int _nextId = 1; + + /// Adds a breakpoint and returns the created entry with its id. + public Breakpoint Add(BreakpointKind kind, ulong address, ulong length = 1) + { + lock (_sync) + { + var effectiveLength = kind == BreakpointKind.Execute ? 1UL : Math.Max(1UL, length); + var breakpoint = new Breakpoint(_nextId++, kind, address, effectiveLength); + _breakpoints[breakpoint.Id] = breakpoint; + return breakpoint; + } + } + + /// Removes a breakpoint by id. Returns false when it did not exist. + public bool Remove(int id) + { + lock (_sync) + { + return _breakpoints.Remove(id); + } + } + + /// Enables or disables a breakpoint by id. + public bool SetEnabled(int id, bool enabled) + { + lock (_sync) + { + if (!_breakpoints.TryGetValue(id, out var breakpoint)) + { + return false; + } + + _breakpoints[id] = breakpoint.WithEnabled(enabled); + return true; + } + } + + /// Removes every breakpoint. + public void Clear() + { + lock (_sync) + { + _breakpoints.Clear(); + } + } + + /// Returns a point-in-time copy of all breakpoints. + public IReadOnlyList Snapshot() + { + lock (_sync) + { + return _breakpoints.Values.ToArray(); + } + } + + /// + /// Finds the first enabled execution breakpoint covering , + /// or null when none applies. + /// + public Breakpoint? FindExecuteHit(ulong address) + { + lock (_sync) + { + foreach (var breakpoint in _breakpoints.Values) + { + if (breakpoint.Enabled && breakpoint.Kind == BreakpointKind.Execute && breakpoint.Covers(address)) + { + return breakpoint; + } + } + + return null; + } + } +} diff --git a/src/SharpEmu.Debugger/DebugRegisterFile.cs b/src/SharpEmu.Debugger/DebugRegisterFile.cs new file mode 100644 index 0000000..22679cf --- /dev/null +++ b/src/SharpEmu.Debugger/DebugRegisterFile.cs @@ -0,0 +1,73 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Core.Cpu.Debugging; +using SharpEmu.HLE; + +namespace SharpEmu.Debugger; + +/// +/// An immutable snapshot of the guest integer register state at a stop. XMM/YMM +/// state is intentionally omitted here and read on demand through the target to +/// keep the common register-dump path cheap. +/// +public readonly struct DebugRegisterFile +{ + private readonly ulong[] _generalPurpose; + + public DebugRegisterFile( + ulong[] generalPurpose, + ulong rip, + ulong rflags, + ulong fsBase, + ulong gsBase) + { + ArgumentNullException.ThrowIfNull(generalPurpose); + if (generalPurpose.Length != 16) + { + throw new ArgumentException("Expected 16 general-purpose registers.", nameof(generalPurpose)); + } + + _generalPurpose = generalPurpose; + Rip = rip; + Rflags = rflags; + FsBase = fsBase; + GsBase = gsBase; + } + + public ulong Rip { get; } + + public ulong Rflags { get; } + + public ulong FsBase { get; } + + public ulong GsBase { get; } + + /// Reads a register by identifier. + public ulong this[DebugRegisterId id] => id switch + { + DebugRegisterId.Rip => Rip, + DebugRegisterId.Rflags => Rflags, + DebugRegisterId.FsBase => FsBase, + DebugRegisterId.GsBase => GsBase, + _ when id.IsGeneralPurpose() => _generalPurpose[(int)id], + _ => throw new ArgumentOutOfRangeException(nameof(id), id, null), + }; + + /// Reads a general-purpose register. + public ulong this[CpuRegister register] => _generalPurpose[(int)register]; + + /// Captures the integer register state of a live debug frame. + public static DebugRegisterFile Capture(ICpuDebugFrame frame) + { + ArgumentNullException.ThrowIfNull(frame); + + var gpr = new ulong[16]; + for (var i = 0; i < gpr.Length; i++) + { + gpr[i] = frame.GetRegister((CpuRegister)i); + } + + return new DebugRegisterFile(gpr, frame.Rip, frame.Rflags, frame.FsBase, frame.GsBase); + } +} diff --git a/src/SharpEmu.Debugger/DebugRegisterId.cs b/src/SharpEmu.Debugger/DebugRegisterId.cs new file mode 100644 index 0000000..3860161 --- /dev/null +++ b/src/SharpEmu.Debugger/DebugRegisterId.cs @@ -0,0 +1,62 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.HLE; + +namespace SharpEmu.Debugger; + +/// +/// The registers a debugger can name. The first sixteen values line up with +/// so a general-purpose register can be converted +/// between the two enums by casting; the remaining values cover the special +/// registers a debug frame exposes. +/// +public enum DebugRegisterId +{ + Rax = 0, + Rcx = 1, + Rdx = 2, + Rbx = 3, + Rsp = 4, + Rbp = 5, + Rsi = 6, + Rdi = 7, + R8 = 8, + R9 = 9, + R10 = 10, + R11 = 11, + R12 = 12, + R13 = 13, + R14 = 14, + R15 = 15, + + Rip = 16, + Rflags = 17, + FsBase = 18, + GsBase = 19, +} + +/// Helpers for mapping between debug and CPU register identifiers. +public static class DebugRegisterIdExtensions +{ + /// + /// True when the identifier names one of the sixteen general-purpose + /// registers and can be cast to . + /// + public static bool IsGeneralPurpose(this DebugRegisterId id) + => id is >= DebugRegisterId.Rax and <= DebugRegisterId.R15; + + /// + /// Converts a general-purpose identifier to its . + /// Throws when is a special register. + /// + public static CpuRegister ToCpuRegister(this DebugRegisterId id) + { + if (!id.IsGeneralPurpose()) + { + throw new ArgumentOutOfRangeException(nameof(id), id, "Not a general-purpose register."); + } + + return (CpuRegister)(int)id; + } +} diff --git a/src/SharpEmu.Debugger/DebuggerServerHost.cs b/src/SharpEmu.Debugger/DebuggerServerHost.cs new file mode 100644 index 0000000..37da61c --- /dev/null +++ b/src/SharpEmu.Debugger/DebuggerServerHost.cs @@ -0,0 +1,59 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Net; +using SharpEmu.Core.Cpu.Debugging; +using SharpEmu.Debugger.Server; +using SharpEmu.Debugger.Session; + +namespace SharpEmu.Debugger; + +/// +/// One-call wiring of the live debugger: it owns a +/// and a , exposes the to attach +/// to SharpEmuRuntimeOptions.DebugHook, and starts/stops the network +/// front-end. A host constructs one, hands to the runtime, +/// calls , and calls once the +/// runtime returns. +/// +public sealed class DebuggerServerHost : IAsyncDisposable +{ + private readonly DebuggerSession _session; + private readonly DebuggerServer _server; + + public DebuggerServerHost( + DebuggerServerOptions? serverOptions = null, + DebuggerSessionOptions? sessionOptions = null) + { + _session = new DebuggerSession(sessionOptions); + _server = new DebuggerServer(_session, serverOptions); + } + + /// The session driving the target. + public IDebuggerSession Session => _session; + + /// + /// The dispatcher hook to hand to the runtime so guest frames route through + /// the debugger. + /// + public ICpuDebugHook Hook => _session.Hook; + + /// The endpoint the server bound to, or null before . + public IPEndPoint? Endpoint => _server.Endpoint; + + /// Begins accepting debugger clients. + public void Start() => _server.Start(); + + /// + /// Releases a parked emulation thread and marks the target terminated. Call + /// after the runtime's run returns so any attached client is notified and the + /// guest thread is never left blocked in the debugger. + /// + public void NotifyRunCompleted() => _session.NotifyTerminated(); + + public async ValueTask DisposeAsync() + { + _session.NotifyTerminated(); + await _server.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/src/SharpEmu.Debugger/Protocol/DebugCommandDispatcher.cs b/src/SharpEmu.Debugger/Protocol/DebugCommandDispatcher.cs new file mode 100644 index 0000000..d2e7b10 --- /dev/null +++ b/src/SharpEmu.Debugger/Protocol/DebugCommandDispatcher.cs @@ -0,0 +1,340 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Debugger.Breakpoints; +using SharpEmu.Debugger.Session; +using SharpEmu.HLE; + +namespace SharpEmu.Debugger.Protocol; + +/// +/// Translates parsed verbs into operations on an +/// and packages the outcome as a +/// . This is the single place command semantics live, +/// so it is shared by every connection and independent of the wire format. +/// +public sealed class DebugCommandDispatcher +{ + private readonly IDebuggerSession _session; + + public DebugCommandDispatcher(IDebuggerSession session) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + } + + public DebugResponse Dispatch(DebugRequest request) + { + return request.Command switch + { + JsonLineDebugProtocol.ParseErrorCommand => ParseError(request), + "ping" => DebugResponse.Success(request.Command), + "status" or "info" => Status(request), + "state" => DebugResponse.Success(request.Command, new Dictionary + { + ["state"] = _session.State.ToString(), + }), + "registers" or "regs" => Registers(request), + "set-register" or "set-reg" => SetRegister(request), + "read-memory" or "read-mem" => ReadMemory(request), + "write-memory" or "write-mem" => WriteMemory(request), + "list-breakpoints" or "breakpoints" => ListBreakpoints(request), + "add-breakpoint" or "break" => AddBreakpoint(request), + "remove-breakpoint" or "delete-breakpoint" => RemoveBreakpoint(request), + "enable-breakpoint" => EnableBreakpoint(request), + "continue" or "cont" or "c" => Simple(request, _session.Continue(), "Target is not paused."), + "step" or "s" => Simple(request, _session.StepFrame(), "Target is not paused."), + "pause" => Pause(request), + _ => DebugResponse.Failure(request.Command, $"Unknown command '{request.Command}'."), + }; + } + + private static DebugResponse ParseError(DebugRequest request) + { + var message = request.TryGetString("message", out var text) ? text : "Malformed request."; + return DebugResponse.Failure(request.Command, message); + } + + private DebugResponse Status(DebugRequest request) + { + var data = new Dictionary + { + ["state"] = _session.State.ToString(), + ["breakpoints"] = _session.Breakpoints.Snapshot().Count, + }; + + if (_session.LastStop is { } stop) + { + data["lastStop"] = DescribeStop(stop); + } + + return DebugResponse.Success(request.Command, data); + } + + private DebugResponse Registers(DebugRequest request) + { + if (!_session.TryGetRegisters(out var registers)) + { + return NotPaused(request); + } + + return DebugResponse.Success(request.Command, new Dictionary + { + ["registers"] = DescribeRegisters(registers), + }); + } + + private DebugResponse SetRegister(DebugRequest request) + { + if (!request.TryGetString("register", out var name) || !TryParseRegister(name, out var id)) + { + return DebugResponse.Failure(request.Command, "Expected a valid 'register' name."); + } + + if (!request.TryGetUInt64("value", out var value)) + { + return DebugResponse.Failure(request.Command, "Expected a 'value'."); + } + + return _session.TrySetRegister(id, value) + ? DebugResponse.Success(request.Command) + : DebugResponse.Failure(request.Command, "Register is not writable or target is not paused."); + } + + private DebugResponse ReadMemory(DebugRequest request) + { + if (!request.TryGetUInt64("address", out var address)) + { + return DebugResponse.Failure(request.Command, "Expected an 'address'."); + } + + if (!request.TryGetInt32("length", out var length) || length <= 0 || length > MaxMemoryChunk) + { + return DebugResponse.Failure(request.Command, $"Expected a 'length' between 1 and {MaxMemoryChunk}."); + } + + var buffer = new byte[length]; + if (!_session.TryReadMemory(address, buffer)) + { + return DebugResponse.Failure(request.Command, "Memory is unreadable or target is not paused."); + } + + return DebugResponse.Success(request.Command, new Dictionary + { + ["address"] = FormatAddress(address), + ["length"] = length, + ["bytes"] = Convert.ToHexString(buffer), + }); + } + + private DebugResponse WriteMemory(DebugRequest request) + { + if (!request.TryGetUInt64("address", out var address)) + { + return DebugResponse.Failure(request.Command, "Expected an 'address'."); + } + + if (!request.TryGetString("bytes", out var hex) || hex.Length == 0 || (hex.Length & 1) != 0) + { + return DebugResponse.Failure(request.Command, "Expected 'bytes' as an even-length hex string."); + } + + byte[] data; + try + { + data = Convert.FromHexString(hex); + } + catch (FormatException) + { + return DebugResponse.Failure(request.Command, "'bytes' is not valid hex."); + } + + if (data.Length > MaxMemoryChunk) + { + return DebugResponse.Failure(request.Command, $"Cannot write more than {MaxMemoryChunk} bytes at once."); + } + + return _session.TryWriteMemory(address, data) + ? DebugResponse.Success(request.Command, new Dictionary { ["written"] = data.Length }) + : DebugResponse.Failure(request.Command, "Memory is unwritable or target is not paused."); + } + + private DebugResponse ListBreakpoints(DebugRequest request) + { + var breakpoints = _session.Breakpoints.Snapshot() + .OrderBy(breakpoint => breakpoint.Id) + .Select(DescribeBreakpoint) + .ToArray(); + + return DebugResponse.Success(request.Command, new Dictionary + { + ["breakpoints"] = breakpoints, + }); + } + + private DebugResponse AddBreakpoint(DebugRequest request) + { + if (!request.TryGetUInt64("address", out var address)) + { + return DebugResponse.Failure(request.Command, "Expected an 'address'."); + } + + var kind = BreakpointKind.Execute; + if (request.TryGetString("kind", out var kindText) && !TryParseBreakpointKind(kindText, out kind)) + { + return DebugResponse.Failure(request.Command, $"Unknown breakpoint kind '{kindText}'."); + } + + var length = 1UL; + if (request.TryGetUInt64("length", out var requestedLength) && requestedLength > 0) + { + length = requestedLength; + } + + var breakpoint = _session.Breakpoints.Add(kind, address, length); + return DebugResponse.Success(request.Command, new Dictionary + { + ["breakpoint"] = DescribeBreakpoint(breakpoint), + }); + } + + private DebugResponse RemoveBreakpoint(DebugRequest request) + { + if (!request.TryGetInt32("id", out var id)) + { + return DebugResponse.Failure(request.Command, "Expected an 'id'."); + } + + return _session.Breakpoints.Remove(id) + ? DebugResponse.Success(request.Command) + : DebugResponse.Failure(request.Command, $"No breakpoint with id {id}."); + } + + private DebugResponse EnableBreakpoint(DebugRequest request) + { + if (!request.TryGetInt32("id", out var id)) + { + return DebugResponse.Failure(request.Command, "Expected an 'id'."); + } + + var enabled = !request.TryGetBool("enabled", out var requested) || requested; + return _session.Breakpoints.SetEnabled(id, enabled) + ? DebugResponse.Success(request.Command) + : DebugResponse.Failure(request.Command, $"No breakpoint with id {id}."); + } + + private DebugResponse Pause(DebugRequest request) + { + _session.RequestPause(); + return DebugResponse.Success(request.Command); + } + + private static DebugResponse Simple(DebugRequest request, bool succeeded, string failureMessage) + => succeeded ? DebugResponse.Success(request.Command) : DebugResponse.Failure(request.Command, failureMessage); + + private static DebugResponse NotPaused(DebugRequest request) + => DebugResponse.Failure(request.Command, "Target is not paused."); + + internal static IReadOnlyDictionary DescribeStop(DebugStopEvent stop) + { + var data = new Dictionary + { + ["reason"] = stop.Reason.ToString(), + ["address"] = FormatAddress(stop.Address), + ["frameKind"] = stop.FrameKind.ToString(), + ["frameLabel"] = stop.FrameLabel, + ["registers"] = DescribeRegisters(stop.Registers), + }; + + if (stop.Breakpoint is { } breakpoint) + { + data["breakpoint"] = DescribeBreakpoint(breakpoint); + } + + if (stop.Result is { } result) + { + data["result"] = result.ToString(); + } + + if (stop.Detail is { } detail) + { + data["detail"] = detail; + } + + if (stop.OpcodeBytes is { } opcodeBytes) + { + data["opcodeBytes"] = opcodeBytes; + } + + if (stop.StallInfo is { } stall) + { + data["stall"] = new Dictionary + { + ["kind"] = stall.Kind.ToString(), + ["nid"] = stall.Nid, + ["instructionPointer"] = FormatAddress(stall.InstructionPointer), + ["dispatchIndex"] = stall.DispatchIndex, + ["argument0"] = FormatAddress(stall.Argument0), + ["argument1"] = FormatAddress(stall.Argument1), + ["resolved"] = stall.IsResolved, + ["library"] = stall.LibraryName, + ["function"] = stall.FunctionName, + }; + } + + return data; + } + + private static IReadOnlyDictionary DescribeRegisters(DebugRegisterFile registers) + { + var result = new Dictionary(20); + for (var i = 0; i < 16; i++) + { + result[((CpuRegister)i).ToString().ToLowerInvariant()] = FormatAddress(registers[(CpuRegister)i]); + } + + result["rip"] = FormatAddress(registers.Rip); + result["rflags"] = FormatAddress(registers.Rflags); + result["fs_base"] = FormatAddress(registers.FsBase); + result["gs_base"] = FormatAddress(registers.GsBase); + return result; + } + + private static IReadOnlyDictionary DescribeBreakpoint(Breakpoint breakpoint) + => new Dictionary + { + ["id"] = breakpoint.Id, + ["kind"] = breakpoint.Kind.ToString(), + ["address"] = FormatAddress(breakpoint.Address), + ["length"] = breakpoint.Length, + ["enabled"] = breakpoint.Enabled, + }; + + private static string FormatAddress(ulong value) => $"0x{value:X16}"; + + private static bool TryParseRegister(string name, out DebugRegisterId id) + { + var normalized = name.Trim().ToLowerInvariant(); + switch (normalized) + { + case "rip": + id = DebugRegisterId.Rip; + return true; + case "rflags": + id = DebugRegisterId.Rflags; + return true; + case "fs_base" or "fsbase": + id = DebugRegisterId.FsBase; + return true; + case "gs_base" or "gsbase": + id = DebugRegisterId.GsBase; + return true; + } + + return Enum.TryParse(normalized, ignoreCase: true, out id) && Enum.IsDefined(id); + } + + private static bool TryParseBreakpointKind(string text, out BreakpointKind kind) + => Enum.TryParse(text.Trim(), ignoreCase: true, out kind) && Enum.IsDefined(kind); + + private const int MaxMemoryChunk = 64 * 1024; +} diff --git a/src/SharpEmu.Debugger/Protocol/DebugRequest.cs b/src/SharpEmu.Debugger/Protocol/DebugRequest.cs new file mode 100644 index 0000000..3fc4177 --- /dev/null +++ b/src/SharpEmu.Debugger/Protocol/DebugRequest.cs @@ -0,0 +1,152 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Globalization; +using System.Text.Json; + +namespace SharpEmu.Debugger.Protocol; + +/// +/// A parsed client request: a verb plus a bag of named +/// arguments backed by the original JSON. Numeric arguments accept either JSON +/// numbers or "0x"-prefixed hex strings so addresses read naturally on +/// the wire. +/// +public sealed class DebugRequest +{ + private readonly JsonElement _root; + + private DebugRequest(string command, JsonElement root) + { + Command = command; + _root = root; + } + + /// The lower-cased command verb. + public string Command { get; } + + /// + /// Parses a single JSON object into a request. Returns false when the text is + /// not a JSON object or is missing a string command field. + /// + public static bool TryParse(string json, out DebugRequest request, out string error) + { + request = null!; + error = string.Empty; + try + { + using var document = JsonDocument.Parse(json); + var root = document.RootElement.Clone(); + if (root.ValueKind != JsonValueKind.Object) + { + error = "Request must be a JSON object."; + return false; + } + + if (!root.TryGetProperty("command", out var commandElement) || + commandElement.ValueKind != JsonValueKind.String) + { + error = "Request is missing a string 'command'."; + return false; + } + + var command = commandElement.GetString() ?? string.Empty; + request = new DebugRequest(command.Trim().ToLowerInvariant(), root); + return true; + } + catch (JsonException ex) + { + error = $"Malformed JSON: {ex.Message}"; + return false; + } + } + + public bool TryGetString(string name, out string value) + { + if (_root.TryGetProperty(name, out var element) && element.ValueKind == JsonValueKind.String) + { + value = element.GetString() ?? string.Empty; + return true; + } + + value = string.Empty; + return false; + } + + public bool TryGetUInt64(string name, out ulong value) + { + value = 0; + if (!_root.TryGetProperty(name, out var element)) + { + return false; + } + + switch (element.ValueKind) + { + case JsonValueKind.Number: + return element.TryGetUInt64(out value); + case JsonValueKind.String: + return TryParseNumber(element.GetString(), out value); + default: + return false; + } + } + + public bool TryGetInt32(string name, out int value) + { + value = 0; + if (!_root.TryGetProperty(name, out var element)) + { + return false; + } + + switch (element.ValueKind) + { + case JsonValueKind.Number: + return element.TryGetInt32(out value); + case JsonValueKind.String when TryParseNumber(element.GetString(), out var parsed) && parsed <= int.MaxValue: + value = (int)parsed; + return true; + default: + return false; + } + } + + public bool TryGetBool(string name, out bool value) + { + value = false; + if (!_root.TryGetProperty(name, out var element)) + { + return false; + } + + switch (element.ValueKind) + { + case JsonValueKind.True: + value = true; + return true; + case JsonValueKind.False: + value = false; + return true; + default: + return false; + } + } + + private static bool TryParseNumber(string? text, out ulong value) + { + value = 0; + if (string.IsNullOrWhiteSpace(text)) + { + return false; + } + + text = text.Trim(); + if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + return ulong.TryParse(text.AsSpan(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out value); + } + + return ulong.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out value); + } +} diff --git a/src/SharpEmu.Debugger/Protocol/DebugResponse.cs b/src/SharpEmu.Debugger/Protocol/DebugResponse.cs new file mode 100644 index 0000000..68c3993 --- /dev/null +++ b/src/SharpEmu.Debugger/Protocol/DebugResponse.cs @@ -0,0 +1,34 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.Debugger.Protocol; + +/// +/// The reply to a : either success with an optional +/// data payload, or a failure with a human-readable message. +/// +public sealed class DebugResponse +{ + private DebugResponse(bool ok, string? command, IReadOnlyDictionary? data, string? error) + { + Ok = ok; + Command = command; + Data = data; + Error = error; + } + + public bool Ok { get; } + + /// Echoes the command the reply answers, when known. + public string? Command { get; } + + public IReadOnlyDictionary? Data { get; } + + public string? Error { get; } + + public static DebugResponse Success(string command, IReadOnlyDictionary? data = null) + => new(ok: true, command, data, error: null); + + public static DebugResponse Failure(string command, string error) + => new(ok: false, command, data: null, error); +} diff --git a/src/SharpEmu.Debugger/Protocol/IDebugProtocol.cs b/src/SharpEmu.Debugger/Protocol/IDebugProtocol.cs new file mode 100644 index 0000000..3422107 --- /dev/null +++ b/src/SharpEmu.Debugger/Protocol/IDebugProtocol.cs @@ -0,0 +1,33 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.Debugger.Protocol; + +/// +/// Frames debugger traffic on a connection. A protocol turns bytes into +/// objects and serialises +/// replies plus asynchronous events (stops, resumes, termination) back to the +/// client. Swapping the implementation (line-delimited JSON today, a GDB remote +/// serial stub later) leaves the session and server untouched. +/// +public interface IDebugProtocol +{ + /// A short protocol name reported in the handshake. + string Name { get; } + + /// + /// Reads the next request, or null at end of stream. Parse failures are + /// surfaced as a request with a reserved error command rather than throwing. + /// + Task ReadRequestAsync(TextReader reader, CancellationToken cancellationToken); + + /// Writes a reply to a request. + Task WriteResponseAsync(TextWriter writer, DebugResponse response, CancellationToken cancellationToken); + + /// Writes an unsolicited event (for example a stop notification). + Task WriteEventAsync( + TextWriter writer, + string eventName, + IReadOnlyDictionary data, + CancellationToken cancellationToken); +} diff --git a/src/SharpEmu.Debugger/Protocol/JsonLineDebugProtocol.cs b/src/SharpEmu.Debugger/Protocol/JsonLineDebugProtocol.cs new file mode 100644 index 0000000..60af7d4 --- /dev/null +++ b/src/SharpEmu.Debugger/Protocol/JsonLineDebugProtocol.cs @@ -0,0 +1,112 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Text.Json; + +namespace SharpEmu.Debugger.Protocol; + +/// +/// A newline-delimited JSON protocol: one JSON object per line in each +/// direction. Requests carry a command; replies carry ok plus +/// data/error; events carry an event name. It is trivial to +/// drive from a socket, nc, or a small script, which suits bring-up and +/// tooling while a richer protocol is layered on later. +/// +public sealed class JsonLineDebugProtocol : IDebugProtocol +{ + /// The command assigned to a request that failed to parse. + public const string ParseErrorCommand = "$parse-error"; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = false, + }; + + public string Name => "json-lines/1"; + + public async Task ReadRequestAsync(TextReader reader, CancellationToken cancellationToken) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); + if (line is null) + { + return null; + } + + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + if (DebugRequest.TryParse(line, out var request, out var error)) + { + return request; + } + + // Surface the parse failure as a synthetic request so the connection + // loop can reply with an error rather than dropping the client. + var envelope = $"{{\"command\":\"{ParseErrorCommand}\",\"message\":{JsonSerializer.Serialize(error)}}}"; + if (DebugRequest.TryParse(envelope, out var errorRequest, out _)) + { + return errorRequest; + } + } + } + + public async Task WriteResponseAsync(TextWriter writer, DebugResponse response, CancellationToken cancellationToken) + { + var payload = new Dictionary + { + ["ok"] = response.Ok, + }; + + if (response.Command is not null) + { + payload["command"] = response.Command; + } + + if (response.Data is not null) + { + payload["data"] = response.Data; + } + + if (response.Error is not null) + { + payload["error"] = response.Error; + } + + await WriteLineAsync(writer, payload, cancellationToken).ConfigureAwait(false); + } + + public async Task WriteEventAsync( + TextWriter writer, + string eventName, + IReadOnlyDictionary data, + CancellationToken cancellationToken) + { + var payload = new Dictionary(data.Count + 1) + { + ["event"] = eventName, + }; + + foreach (var (key, value) in data) + { + payload[key] = value; + } + + await WriteLineAsync(writer, payload, cancellationToken).ConfigureAwait(false); + } + + private static async Task WriteLineAsync( + TextWriter writer, + IReadOnlyDictionary payload, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var json = JsonSerializer.Serialize(payload, SerializerOptions); + await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false); + await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/SharpEmu.Debugger/Server/DebuggerClientConnection.cs b/src/SharpEmu.Debugger/Server/DebuggerClientConnection.cs new file mode 100644 index 0000000..a772515 --- /dev/null +++ b/src/SharpEmu.Debugger/Server/DebuggerClientConnection.cs @@ -0,0 +1,158 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Net.Sockets; +using System.Text; +using SharpEmu.Debugger.Protocol; +using SharpEmu.Debugger.Session; +using SharpEmu.Logging; + +namespace SharpEmu.Debugger.Server; + +/// +/// Services a single connected client: reads requests, dispatches them against +/// the shared session, and pushes session lifecycle events. Writes from the +/// request loop and from event callbacks are serialised through one lock so the +/// two never interleave a half-written line. +/// +internal sealed class DebuggerClientConnection : IAsyncDisposable +{ + private static readonly SharpEmuLogger Log = SharpEmuLog.For("SharpEmu.Debugger"); + private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); + + private readonly TcpClient _client; + private readonly IDebuggerSession _session; + private readonly IDebugProtocol _protocol; + private readonly DebugCommandDispatcher _dispatcher; + private readonly SemaphoreSlim _writeLock = new(1, 1); + + private TextWriter? _writer; + private CancellationToken _cancellationToken; + + public DebuggerClientConnection(TcpClient client, IDebuggerSession session, IDebugProtocol protocol) + { + _client = client; + _session = session; + _protocol = protocol; + _dispatcher = new DebugCommandDispatcher(session); + } + + public async Task RunAsync(CancellationToken cancellationToken) + { + _cancellationToken = cancellationToken; + var endpoint = _client.Client.RemoteEndPoint?.ToString() ?? "unknown"; + Log.Info($"Debugger client connected: {endpoint}"); + + using var stream = _client.GetStream(); + using var reader = new StreamReader(stream, Utf8NoBom); + await using var writer = new StreamWriter(stream, Utf8NoBom) { AutoFlush = false }; + _writer = writer; + + _session.Stopped += OnStopped; + _session.Resumed += OnResumed; + _session.Terminated += OnTerminated; + try + { + await SendEventAsync("hello", new Dictionary + { + ["protocol"] = _protocol.Name, + ["state"] = _session.State.ToString(), + }).ConfigureAwait(false); + + while (!cancellationToken.IsCancellationRequested) + { + var request = await _protocol.ReadRequestAsync(reader, cancellationToken).ConfigureAwait(false); + if (request is null) + { + break; + } + + var response = _dispatcher.Dispatch(request); + await WriteResponseAsync(response).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Server shutting down. + } + catch (IOException) + { + // Client dropped the connection. + } + catch (Exception ex) + { + Log.Warn($"Debugger client error ({endpoint}): {ex.Message}"); + } + finally + { + _session.Stopped -= OnStopped; + _session.Resumed -= OnResumed; + _session.Terminated -= OnTerminated; + _writer = null; + Log.Info($"Debugger client disconnected: {endpoint}"); + } + } + + private void OnStopped(object? sender, DebugStopEvent stop) + => _ = SendEventAsync("stopped", DebugCommandDispatcher.DescribeStop(stop)); + + private void OnResumed(object? sender, EventArgs e) + => _ = SendEventAsync("resumed", EmptyData); + + private void OnTerminated(object? sender, EventArgs e) + => _ = SendEventAsync("terminated", EmptyData); + + private async Task WriteResponseAsync(DebugResponse response) + { + var writer = _writer; + if (writer is null) + { + return; + } + + await _writeLock.WaitAsync(_cancellationToken).ConfigureAwait(false); + try + { + await _protocol.WriteResponseAsync(writer, response, _cancellationToken).ConfigureAwait(false); + } + finally + { + _writeLock.Release(); + } + } + + private async Task SendEventAsync(string name, IReadOnlyDictionary data) + { + var writer = _writer; + if (writer is null) + { + return; + } + + try + { + await _writeLock.WaitAsync(_cancellationToken).ConfigureAwait(false); + try + { + await _protocol.WriteEventAsync(writer, name, data, _cancellationToken).ConfigureAwait(false); + } + finally + { + _writeLock.Release(); + } + } + catch (Exception ex) when (ex is IOException or OperationCanceledException or ObjectDisposedException) + { + // The client went away between the event firing and the write. + } + } + + public ValueTask DisposeAsync() + { + _writeLock.Dispose(); + _client.Dispose(); + return ValueTask.CompletedTask; + } + + private static readonly IReadOnlyDictionary EmptyData = new Dictionary(); +} diff --git a/src/SharpEmu.Debugger/Server/DebuggerServer.cs b/src/SharpEmu.Debugger/Server/DebuggerServer.cs new file mode 100644 index 0000000..08b5f73 --- /dev/null +++ b/src/SharpEmu.Debugger/Server/DebuggerServer.cs @@ -0,0 +1,136 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using SharpEmu.Debugger.Protocol; +using SharpEmu.Debugger.Session; +using SharpEmu.Logging; + +namespace SharpEmu.Debugger.Server; + +/// +/// A TCP server that exposes an to remote +/// clients over a pluggable . Every connection sees +/// the same session, so multiple clients (for example a UI and a scripted +/// probe) observe a consistent view of the target. +/// +public sealed class DebuggerServer : IDebuggerServer +{ + private static readonly SharpEmuLogger Log = SharpEmuLog.For("SharpEmu.Debugger"); + + private readonly IDebuggerSession _session; + private readonly DebuggerServerOptions _options; + private readonly Func _protocolFactory; + private readonly ConcurrentDictionary _connections = new(); + private readonly CancellationTokenSource _shutdown = new(); + + private TcpListener? _listener; + private Task? _acceptLoop; + + public DebuggerServer( + IDebuggerSession session, + DebuggerServerOptions? options = null, + Func? protocolFactory = null) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _options = options ?? new DebuggerServerOptions(); + _protocolFactory = protocolFactory ?? (static () => new JsonLineDebugProtocol()); + } + + public bool IsListening => _listener is not null; + + public IPEndPoint? Endpoint { get; private set; } + + public void Start() + { + if (_listener is not null) + { + return; + } + + var listener = new TcpListener(_options.BindAddress, _options.Port); + listener.Start(_options.MaxClients); + _listener = listener; + Endpoint = (IPEndPoint?)listener.LocalEndpoint; + Log.Info($"Debug server listening on {Endpoint} (protocol {_protocolFactory().Name})"); + _acceptLoop = Task.Run(() => AcceptLoopAsync(listener, _shutdown.Token)); + } + + private async Task AcceptLoopAsync(TcpListener listener, CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + TcpClient client; + try + { + client = await listener.AcceptTcpClientAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + catch (SocketException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (ObjectDisposedException) + { + break; + } + + var connection = new DebuggerClientConnection(client, _session, _protocolFactory()); + var task = Task.Run(() => ServeAsync(connection, cancellationToken), cancellationToken); + _connections[connection] = task; + } + } + + private async Task ServeAsync(DebuggerClientConnection connection, CancellationToken cancellationToken) + { + try + { + await connection.RunAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _connections.TryRemove(connection, out _); + await connection.DisposeAsync().ConfigureAwait(false); + } + } + + public async Task StopAsync() + { + if (_listener is null) + { + return; + } + + await _shutdown.CancelAsync().ConfigureAwait(false); + _listener.Stop(); + _listener = null; + + try + { + if (_acceptLoop is not null) + { + await _acceptLoop.ConfigureAwait(false); + } + + await Task.WhenAll(_connections.Values).ConfigureAwait(false); + } + catch (Exception ex) when (ex is OperationCanceledException or SocketException or ObjectDisposedException) + { + // Expected while tearing connections down. + } + + _connections.Clear(); + Log.Info("Debug server stopped."); + } + + public async ValueTask DisposeAsync() + { + await StopAsync().ConfigureAwait(false); + _shutdown.Dispose(); + } +} diff --git a/src/SharpEmu.Debugger/Server/DebuggerServerOptions.cs b/src/SharpEmu.Debugger/Server/DebuggerServerOptions.cs new file mode 100644 index 0000000..af21466 --- /dev/null +++ b/src/SharpEmu.Debugger/Server/DebuggerServerOptions.cs @@ -0,0 +1,78 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Net; + +namespace SharpEmu.Debugger.Server; + +/// Network configuration for a . +public sealed class DebuggerServerOptions +{ + /// The default TCP port the debug server listens on. + public const int DefaultPort = 5714; + + /// + /// The address to bind. Defaults to loopback so the debug surface is not + /// exposed off-box; a caller must opt in to a routable address explicitly. + /// + public IPAddress BindAddress { get; init; } = IPAddress.Loopback; + + /// The TCP port to listen on. + public int Port { get; init; } = DefaultPort; + + /// + /// The maximum number of simultaneous client connections. Additional + /// connections wait in the accept backlog. + /// + public int MaxClients { get; init; } = 4; + + /// + /// Parses a host:port, bare port, or bare host into options. + /// Returns false when the text cannot be interpreted. + /// + public static bool TryParseEndpoint(string? text, out DebuggerServerOptions options, out string error) + { + options = new DebuggerServerOptions(); + error = string.Empty; + if (string.IsNullOrWhiteSpace(text)) + { + return true; + } + + var value = text.Trim(); + var host = value; + var port = DefaultPort; + + var separator = value.LastIndexOf(':'); + if (separator >= 0) + { + var portText = value[(separator + 1)..]; + if (portText.Length > 0) + { + if (!int.TryParse(portText, out port) || port is <= 0 or > 65535) + { + error = $"Invalid port '{portText}'."; + return false; + } + } + + host = value[..separator]; + } + + var address = IPAddress.Loopback; + if (!string.IsNullOrWhiteSpace(host) && + !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) && + !IPAddress.TryParse(host, out address!)) + { + error = $"Invalid bind address '{host}'."; + return false; + } + + options = new DebuggerServerOptions + { + BindAddress = address ?? IPAddress.Loopback, + Port = port, + }; + return true; + } +} diff --git a/src/SharpEmu.Debugger/Server/IDebuggerServer.cs b/src/SharpEmu.Debugger/Server/IDebuggerServer.cs new file mode 100644 index 0000000..47a0b25 --- /dev/null +++ b/src/SharpEmu.Debugger/Server/IDebuggerServer.cs @@ -0,0 +1,24 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Net; + +namespace SharpEmu.Debugger.Server; + +/// +/// A network front-end that exposes a debugger session to remote clients. +/// +public interface IDebuggerServer : IAsyncDisposable +{ + /// True once the listener is accepting connections. + bool IsListening { get; } + + /// The endpoint the server is bound to, or null before start. + IPEndPoint? Endpoint { get; } + + /// Binds and begins accepting client connections. + void Start(); + + /// Stops accepting connections and closes active clients. + Task StopAsync(); +} diff --git a/src/SharpEmu.Debugger/Session/DebugStopEvent.cs b/src/SharpEmu.Debugger/Session/DebugStopEvent.cs new file mode 100644 index 0000000..6cd759f --- /dev/null +++ b/src/SharpEmu.Debugger/Session/DebugStopEvent.cs @@ -0,0 +1,69 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Core.Cpu.Debugging; +using SharpEmu.Debugger.Breakpoints; +using SharpEmu.HLE; + +namespace SharpEmu.Debugger.Session; + +/// +/// Describes a stop delivered to debugger clients: why the target stopped, +/// where, and the register snapshot at that point. +/// +public sealed class DebugStopEvent +{ + public DebugStopEvent( + DebugStopReason reason, + DebugRegisterFile registers, + CpuDebugFrameKind frameKind, + string frameLabel, + Breakpoint? breakpoint = null, + OrbisGen2Result? result = null, + string? detail = null, + string? opcodeBytes = null, + CpuStallInfo? stallInfo = null) + { + Reason = reason; + Registers = registers; + FrameKind = frameKind; + FrameLabel = frameLabel ?? string.Empty; + Breakpoint = breakpoint; + Result = result; + Detail = detail; + OpcodeBytes = opcodeBytes; + StallInfo = stallInfo; + } + + public DebugStopReason Reason { get; } + + /// The instruction pointer where the target stopped. + public ulong Address => Registers.Rip; + + public DebugRegisterFile Registers { get; } + + public CpuDebugFrameKind FrameKind { get; } + + public string FrameLabel { get; } + + /// The breakpoint responsible for the stop, when applicable. + public Breakpoint? Breakpoint { get; } + + /// + /// The frame result for a stop; null for + /// non-fault stops. + /// + public OrbisGen2Result? Result { get; } + + /// A human-readable summary of a fault, when applicable. + public string? Detail { get; } + + /// + /// A hex preview of the bytes at (the faulting + /// instruction), when the stop is a fault and the bytes were readable. + /// + public string? OpcodeBytes { get; } + + /// Structured backend evidence for a stall stop. + public CpuStallInfo? StallInfo { get; } +} diff --git a/src/SharpEmu.Debugger/Session/DebugStopReason.cs b/src/SharpEmu.Debugger/Session/DebugStopReason.cs new file mode 100644 index 0000000..8b5055e --- /dev/null +++ b/src/SharpEmu.Debugger/Session/DebugStopReason.cs @@ -0,0 +1,32 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.Debugger.Session; + +/// Why the target stopped and handed control to the debugger. +public enum DebugStopReason +{ + /// Stopped at the configured entry point before running any frame. + EntryPoint, + + /// An execution breakpoint was hit. + Breakpoint, + + /// A data watchpoint was hit. + Watchpoint, + + /// A single-step (frame step) request completed. + Step, + + /// A client-requested pause took effect. + Pause, + + /// The guest raised a fault or trap. + Fault, + + /// + /// The backend detected an execution stall (for example a mutex spin loop / + /// livelock) with no forward progress. + /// + Stall, +} diff --git a/src/SharpEmu.Debugger/Session/DebuggerRunState.cs b/src/SharpEmu.Debugger/Session/DebuggerRunState.cs new file mode 100644 index 0000000..4dcdbf5 --- /dev/null +++ b/src/SharpEmu.Debugger/Session/DebuggerRunState.cs @@ -0,0 +1,23 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.Debugger.Session; + +/// The execution state of a debugged target as seen by the debugger. +public enum DebuggerRunState +{ + /// No guest frame has entered the debugger yet. + Detached, + + /// The guest is executing and cannot be inspected safely. + Running, + + /// + /// The guest is parked at a frame boundary. Registers and memory can be + /// read and written, and breakpoints can be edited. + /// + Paused, + + /// The guest has finished; no further frames will run. + Terminated, +} diff --git a/src/SharpEmu.Debugger/Session/DebuggerSession.cs b/src/SharpEmu.Debugger/Session/DebuggerSession.cs new file mode 100644 index 0000000..a8efc0b --- /dev/null +++ b/src/SharpEmu.Debugger/Session/DebuggerSession.cs @@ -0,0 +1,425 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Core.Cpu.Debugging; +using SharpEmu.Debugger.Breakpoints; +using SharpEmu.HLE; +using SharpEmu.Logging; + +namespace SharpEmu.Debugger.Session; + +/// +/// The default . It plugs into the CPU dispatcher +/// as an : when a frame boundary warrants a stop it +/// parks the emulation thread inside +/// while a debug client inspects and edits state, then releases it on +/// continue/step. +/// +/// +/// Pausing works by blocking the emulation thread on +/// from within the hook call. Because that thread is the one that owns the guest +/// context, register and memory accessors are safe to serve from other threads +/// only while it is parked — which is exactly the +/// window the accessors gate on. +/// +public sealed class DebuggerSession : IDebuggerSession, ICpuDebugHook +{ + private static readonly SharpEmuLogger Log = SharpEmuLog.For("SharpEmu.Debugger"); + + private readonly object _sync = new(); + private readonly ManualResetEventSlim _resumeGate = new(initialState: false); + private readonly DebuggerSessionOptions _options; + + private ICpuDebugFrame? _currentFrame; + private DebuggerRunState _state = DebuggerRunState.Detached; + private DebugStopEvent? _lastStop; + private bool _seenFirstFrame; + private bool _pausePending; + private bool _stepPending; + + public DebuggerSession(DebuggerSessionOptions? options = null) + { + _options = options ?? new DebuggerSessionOptions(); + Breakpoints = new BreakpointStore(); + } + + public BreakpointStore Breakpoints { get; } + + public ICpuDebugHook Hook => this; + + public event EventHandler? Stopped; + + public event EventHandler? Resumed; + + public event EventHandler? Terminated; + + public DebuggerRunState State + { + get + { + lock (_sync) + { + return _state; + } + } + } + + public DebugStopEvent? LastStop + { + get + { + lock (_sync) + { + return _lastStop; + } + } + } + + void ICpuDebugHook.OnFrameEnter(ICpuDebugFrame frame) + { + DebugStopEvent? stop; + lock (_sync) + { + _currentFrame = frame; + var firstFrame = !_seenFirstFrame; + _seenFirstFrame = true; + + var reason = ResolveStopReason(frame, firstFrame, out var breakpoint); + if (reason is null) + { + _state = DebuggerRunState.Running; + return; + } + + _state = DebuggerRunState.Paused; + _lastStop = new DebugStopEvent( + reason.Value, + DebugRegisterFile.Capture(frame), + frame.Kind, + frame.Label, + breakpoint); + stop = _lastStop; + _resumeGate.Reset(); + } + + Log.Debug($"Debugger stop: {stop!.Reason} at 0x{stop.Address:X16} ({stop.FrameLabel})"); + Stopped?.Invoke(this, stop); + + // Park the emulation thread until a client resumes the target. The frame + // stays live and inspectable for the whole wait. + _resumeGate.Wait(); + + lock (_sync) + { + if (_state != DebuggerRunState.Terminated) + { + _state = DebuggerRunState.Running; + } + } + + Resumed?.Invoke(this, EventArgs.Empty); + } + + void ICpuDebugHook.OnFrameExit(ICpuDebugFrame frame, OrbisGen2Result result) + { + DebugStopEvent? stop = null; + lock (_sync) + { + if (_options.BreakOnFault && + result != OrbisGen2Result.ORBIS_GEN2_OK && + _state != DebuggerRunState.Terminated) + { + // Parking here keeps the post-fault frame inspectable. + _currentFrame = frame; + _state = DebuggerRunState.Paused; + _lastStop = BuildFaultStop(frame, result); + stop = _lastStop; + _resumeGate.Reset(); + } + } + + if (stop is not null) + { + Log.Debug($"Debugger fault stop: {stop.Result} at 0x{stop.Address:X16} ({stop.FrameLabel})"); + Stopped?.Invoke(this, stop); + _resumeGate.Wait(); + Resumed?.Invoke(this, EventArgs.Empty); + } + + lock (_sync) + { + if (ReferenceEquals(_currentFrame, frame)) + { + _currentFrame = null; + } + + if (_state != DebuggerRunState.Terminated) + { + _state = DebuggerRunState.Running; + } + } + } + + void ICpuDebugHook.OnStall(ICpuDebugFrame frame, CpuStallInfo info) + { + if (!_options.BreakOnStall) + { + return; + } + + DebugStopEvent? stop = null; + lock (_sync) + { + if (_state == DebuggerRunState.Terminated) + { + return; + } + + _currentFrame = frame; + _state = DebuggerRunState.Paused; + _lastStop = new DebugStopEvent( + DebugStopReason.Stall, + DebugRegisterFile.Capture(frame), + frame.Kind, + frame.Label, + breakpoint: null, + result: null, + detail: info.Detail, + opcodeBytes: ReadOpcodePreview(frame, info.InstructionPointer, 16), + stallInfo: info); + stop = _lastStop; + _resumeGate.Reset(); + } + + Log.Debug($"Debugger stall stop: {info.Kind} nid={info.Nid} at 0x{info.InstructionPointer:X16}"); + Stopped?.Invoke(this, stop); + _resumeGate.Wait(); + + lock (_sync) + { + if (ReferenceEquals(_currentFrame, frame)) + { + _currentFrame = null; + } + + if (_state != DebuggerRunState.Terminated) + { + _state = DebuggerRunState.Running; + } + } + + Resumed?.Invoke(this, EventArgs.Empty); + } + + private static DebugStopEvent BuildFaultStop(ICpuDebugFrame frame, OrbisGen2Result result) + { + var opcodeBytes = ReadOpcodePreview(frame, frame.Rip, 16); + var detail = $"result={result}"; + if (opcodeBytes is not null) + { + detail += $", bytes={opcodeBytes}"; + } + + return new DebugStopEvent( + DebugStopReason.Fault, + DebugRegisterFile.Capture(frame), + frame.Kind, + frame.Label, + breakpoint: null, + result: result, + detail: detail, + opcodeBytes: opcodeBytes); + } + + private static string? ReadOpcodePreview(ICpuDebugFrame frame, ulong address, int maxBytes) + { + Span buffer = stackalloc byte[maxBytes]; + var count = 0; + for (; count < maxBytes; count++) + { + if (!frame.Memory.TryRead(address + (ulong)count, buffer.Slice(count, 1))) + { + break; + } + } + + return count == 0 ? null : Convert.ToHexString(buffer[..count]); + } + + /// + /// Signals that the whole guest run has finished. Releases any parked + /// emulation thread and moves the session to + /// . + /// + public void NotifyTerminated() + { + lock (_sync) + { + _state = DebuggerRunState.Terminated; + _currentFrame = null; + } + + _resumeGate.Set(); + Terminated?.Invoke(this, EventArgs.Empty); + } + + private DebugStopReason? ResolveStopReason(ICpuDebugFrame frame, bool firstFrame, out Breakpoint? breakpoint) + { + breakpoint = null; + if (_pausePending) + { + _pausePending = false; + return DebugStopReason.Pause; + } + + if (_stepPending) + { + _stepPending = false; + return DebugStopReason.Step; + } + + var hit = Breakpoints.FindExecuteHit(frame.EntryPoint); + if (hit is not null) + { + breakpoint = hit; + return DebugStopReason.Breakpoint; + } + + if (_options.StopAtEntry && firstFrame) + { + return DebugStopReason.EntryPoint; + } + + return null; + } + + public bool TryGetRegisters(out DebugRegisterFile registers) + { + lock (_sync) + { + if (!IsPausedWithFrame(out var frame)) + { + registers = default; + return false; + } + + registers = DebugRegisterFile.Capture(frame); + return true; + } + } + + public bool TrySetRegister(DebugRegisterId id, ulong value) + { + lock (_sync) + { + if (!IsPausedWithFrame(out var frame)) + { + return false; + } + + if (id.IsGeneralPurpose()) + { + frame.SetRegister(id.ToCpuRegister(), value); + return true; + } + + switch (id) + { + case DebugRegisterId.Rip: + frame.Rip = value; + return true; + case DebugRegisterId.Rflags: + frame.Rflags = value; + return true; + default: + // FS/GS bases are owned by the TLS setup and are read-only here. + return false; + } + } + } + + public bool TryReadMemory(ulong address, Span destination) + { + lock (_sync) + { + return IsPausedWithFrame(out var frame) && frame.Memory.TryRead(address, destination); + } + } + + public bool TryWriteMemory(ulong address, ReadOnlySpan source) + { + lock (_sync) + { + return IsPausedWithFrame(out var frame) && frame.Memory.TryWrite(address, source); + } + } + + public bool TryReadXmm(int registerIndex, out ulong low, out ulong high) + { + lock (_sync) + { + if (!IsPausedWithFrame(out var frame) || (uint)registerIndex >= 16) + { + low = 0; + high = 0; + return false; + } + + frame.GetXmm(registerIndex, out low, out high); + return true; + } + } + + public bool Continue() + { + lock (_sync) + { + if (_state != DebuggerRunState.Paused) + { + return false; + } + + _resumeGate.Set(); + return true; + } + } + + public bool StepFrame() + { + lock (_sync) + { + if (_state != DebuggerRunState.Paused) + { + return false; + } + + _stepPending = true; + _resumeGate.Set(); + return true; + } + } + + public void RequestPause() + { + lock (_sync) + { + if (_state == DebuggerRunState.Running) + { + _pausePending = true; + } + } + } + + private bool IsPausedWithFrame(out ICpuDebugFrame frame) + { + // Callers must hold _sync. + if (_state == DebuggerRunState.Paused && _currentFrame is not null) + { + frame = _currentFrame; + return true; + } + + frame = null!; + return false; + } +} diff --git a/src/SharpEmu.Debugger/Session/DebuggerSessionOptions.cs b/src/SharpEmu.Debugger/Session/DebuggerSessionOptions.cs new file mode 100644 index 0000000..4b1ba0c --- /dev/null +++ b/src/SharpEmu.Debugger/Session/DebuggerSessionOptions.cs @@ -0,0 +1,31 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.Debugger.Session; + +/// Configuration for a . +public sealed class DebuggerSessionOptions +{ + /// + /// When true, the session pauses at the first frame it observes so a client + /// can attach breakpoints before the guest runs. Defaults to true, matching + /// the "stop at entry" behaviour most debuggers expose. + /// + public bool StopAtEntry { get; init; } = true; + + /// + /// When true, the session pauses when a frame ends with a non-OK result (a + /// CPU trap, memory fault, or unimplemented path) so a client can inspect the + /// post-fault register/memory state before the frame is torn down. Defaults + /// to true. The stop reports . + /// + public bool BreakOnFault { get; init; } = true; + + /// + /// When true, the session pauses when the backend detects an execution stall + /// (a mutex spin loop / livelock) before the guest is forced out of the loop, + /// so a client can inspect the stalled state. Defaults to true. The stop + /// reports . + /// + public bool BreakOnStall { get; init; } = true; +} diff --git a/src/SharpEmu.Debugger/Session/IDebugTarget.cs b/src/SharpEmu.Debugger/Session/IDebugTarget.cs new file mode 100644 index 0000000..b0907d3 --- /dev/null +++ b/src/SharpEmu.Debugger/Session/IDebugTarget.cs @@ -0,0 +1,51 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.Debugger.Session; + +/// +/// The inspection and control surface a debugger front-end (for example a +/// network server) drives. Register and memory accessors succeed only while the +/// target is ; they return false +/// otherwise so callers never read torn state from a running guest. +/// +public interface IDebugTarget +{ + /// The current execution state. + DebuggerRunState State { get; } + + /// The most recent stop, or null if the target has not stopped yet. + DebugStopEvent? LastStop { get; } + + /// Reads the integer register file. Fails unless paused. + bool TryGetRegisters(out DebugRegisterFile registers); + + /// Writes a single register. Fails unless paused. + bool TrySetRegister(DebugRegisterId id, ulong value); + + /// Reads guest memory into . Fails unless paused. + bool TryReadMemory(ulong address, Span destination); + + /// Writes guest memory from . Fails unless paused. + bool TryWriteMemory(ulong address, ReadOnlySpan source); + + /// Reads a 128-bit XMM register. Fails unless paused. + bool TryReadXmm(int registerIndex, out ulong low, out ulong high); + + /// + /// Resumes a paused target. Returns false when the target was not paused. + /// + bool Continue(); + + /// + /// Resumes a paused target and stops again at the next frame boundary. + /// Returns false when the target was not paused. + /// + bool StepFrame(); + + /// + /// Requests that a running target stop at the next frame boundary. Has no + /// effect if the target is already paused or terminated. + /// + void RequestPause(); +} diff --git a/src/SharpEmu.Debugger/Session/IDebuggerSession.cs b/src/SharpEmu.Debugger/Session/IDebuggerSession.cs new file mode 100644 index 0000000..bc95075 --- /dev/null +++ b/src/SharpEmu.Debugger/Session/IDebuggerSession.cs @@ -0,0 +1,43 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Core.Cpu.Debugging; +using SharpEmu.Debugger.Breakpoints; + +namespace SharpEmu.Debugger.Session; + +/// +/// The debugger's coordination point. It bridges the CPU dispatcher seam +/// () to the inspection surface (), +/// owns breakpoint state, and raises lifecycle events that a server relays to +/// connected clients. +/// +public interface IDebuggerSession : IDebugTarget +{ + /// The breakpoints armed for this session. + BreakpointStore Breakpoints { get; } + + /// + /// The dispatcher-facing hook. Assign this to + /// SharpEmuRuntimeOptions.DebugHook so guest frames are routed through + /// the session. + /// + ICpuDebugHook Hook { get; } + + /// Raised on the emulation thread each time the target stops. + event EventHandler? Stopped; + + /// Raised when a paused target resumes. + event EventHandler? Resumed; + + /// Raised once the target has terminated. + event EventHandler? Terminated; + + /// + /// Signals that the guest run has finished. Releases any parked emulation + /// thread and transitions the session to + /// . Hosts call this after the + /// runtime returns. + /// + void NotifyTerminated(); +} diff --git a/src/SharpEmu.Debugger/SharpEmu.Debugger.csproj b/src/SharpEmu.Debugger/SharpEmu.Debugger.csproj new file mode 100644 index 0000000..daca6c2 --- /dev/null +++ b/src/SharpEmu.Debugger/SharpEmu.Debugger.csproj @@ -0,0 +1,16 @@ + + + + + + + + + + + $(NoWarn);1591 + + diff --git a/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs index 1d8fc45..083f068 100644 --- a/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs @@ -676,10 +676,15 @@ public static class KernelPthreadCompatExports return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY; } - // FreeBSD maps NORMAL to checked non-recursive behavior: - // self-lock is an error, never implicit recursion. - TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK); - return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK; + // Several Gen5 runtimes layer their own owner/count bookkeeping + // over a NORMAL or ADAPTIVE kernel mutex. Returning EDEADLK here + // leaves that guest bookkeeping out of sync with the HLE owner and + // turns the wrapper into a permanent lock/unlock retry loop. Keep + // the compatibility recursion used by the original implementation; + // ERRORCHECK mutexes still take the strict EDEADLK path below. + state.RecursionCount++; + TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK); + return (int)OrbisGen2Result.ORBIS_GEN2_OK; } else { diff --git a/tests/SharpEmu.Libs.Tests/Pthread/PthreadMutexSemanticsTests.cs b/tests/SharpEmu.Libs.Tests/Pthread/PthreadMutexSemanticsTests.cs new file mode 100644 index 0000000..31f873a --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Pthread/PthreadMutexSemanticsTests.cs @@ -0,0 +1,99 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.HLE; +using SharpEmu.Libs.Kernel; +using Xunit; + +namespace SharpEmu.Libs.Tests.Pthread; + +public sealed class PthreadMutexSemanticsTests +{ + [Fact] + public void AdaptiveMutex_SelfLockUsesCompatibilityRecursion() + { + const ulong memoryBase = 0x1_0000_0000; + const ulong mutexAddress = memoryBase + 0x100; + var memory = new AllocatingCpuMemory(memoryBase, 0x4000); + var context = new CpuContext(memory, Generation.Gen5); + Assert.True(context.TryWriteUInt64(mutexAddress, 1)); // Static adaptive initializer. + context[CpuRegister.Rdi] = mutexAddress; + + Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(context)); + Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(context)); + Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context)); + Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context)); + } + + private sealed class AllocatingCpuMemory : ICpuMemory, IGuestMemoryAllocator + { + private readonly ulong _baseAddress; + private readonly byte[] _storage; + private ulong _nextAllocation; + + public AllocatingCpuMemory(ulong baseAddress, int size) + { + _baseAddress = baseAddress; + _storage = new byte[size]; + _nextAllocation = baseAddress + 0x1000; + } + + public bool TryRead(ulong virtualAddress, Span destination) + { + if (!TryResolve(virtualAddress, destination.Length, out var offset)) + { + return false; + } + + _storage.AsSpan(offset, destination.Length).CopyTo(destination); + return true; + } + + public bool TryWrite(ulong virtualAddress, ReadOnlySpan source) + { + if (!TryResolve(virtualAddress, source.Length, out var offset)) + { + return false; + } + + source.CopyTo(_storage.AsSpan(offset, source.Length)); + return true; + } + + public bool TryAllocateGuestMemory(ulong size, ulong alignment, out ulong address) + { + var mask = alignment - 1; + var aligned = (_nextAllocation + mask) & ~mask; + if (!TryResolve(aligned, checked((int)size), out _)) + { + address = 0; + return false; + } + + address = aligned; + _nextAllocation = aligned + size; + return true; + } + + public bool TryFreeGuestMemory(ulong address) => + address >= _baseAddress && address < _baseAddress + (ulong)_storage.Length; + + private bool TryResolve(ulong virtualAddress, int length, out int offset) + { + offset = 0; + if (virtualAddress < _baseAddress) + { + return false; + } + + var relative = virtualAddress - _baseAddress; + if (relative + (ulong)length > (ulong)_storage.Length) + { + return false; + } + + offset = (int)relative; + return true; + } + } +} diff --git a/tools/SharpEmu.DebuggerFrontend/README.md b/tools/SharpEmu.DebuggerFrontend/README.md new file mode 100644 index 0000000..f0af4fa --- /dev/null +++ b/tools/SharpEmu.DebuggerFrontend/README.md @@ -0,0 +1,89 @@ + + +# SharpEmu Debugger Frontend + +A polished browser UI for SharpEmu's live debugger. A small Python bridge talks +to the emulator over its JSON-lines TCP protocol and serves the frontend on +loopback. It uses only the Python standard library; no package installation or +JavaScript build step is required. + +The favicon and header mark use the official SharpEmu logo served by +`sharpemu.app`. + +## Start + +Start the frontend from the repository root: + +```bash +./tools/SharpEmu.DebuggerFrontend/run.sh +``` + +Use **Browse…** to choose the game's `eboot.bin`, then select **Launch & +attach**. The frontend starts the local Release build with its debug server, +waits for it to become ready, and connects automatically. Emulator output is +shown in the Activity panel. The Stop button only controls the process launched +by this frontend. + +You can still start SharpEmu manually and use the connection bar to attach: + +```bash +./artifacts/bin/Release/net10.0/linux-x64/SharpEmu \ + --debug-server=127.0.0.1:5714 "/path/to/game/eboot.bin" +``` + +The frontend opens `http://127.0.0.1:8765/`. If SharpEmu is already running, it +connects to the default debug endpoint automatically. If it is not running yet, +the UI remains available for launching or attaching later. + +Running the launcher again on the same UI port gracefully closes and replaces +the previous verified SharpEmu frontend instance. It will not stop an unrelated +application that happens to own the requested port; in that case, select a +different port with `--ui-port`. + +Useful options: + +```text +--debug-host HOST Debug server host (default 127.0.0.1) +--debug-port PORT Debug server port (default 5714) +--listen ADDRESS Web UI bind address (default 127.0.0.1) +--ui-port PORT Web UI port; 0 chooses a free port (default 8765) +--no-connect Do not connect to SharpEmu automatically +--no-browser Do not open a browser automatically +--verbose Print HTTP request logs +``` + +## Features + +- Live connection and target-state display +- Native file picker, local emulator launch, automatic debugger attach, and process stop +- Live output from the frontend-launched SharpEmu process +- Continue, pause, and frame-step controls with keyboard shortcuts +- Register inspection and editing +- Hex/ASCII memory reads and validated memory writes +- Breakpoint and watchpoint creation, toggling, and deletion +- Stop reason, frame, result, opcode, and fault details +- Evidence-based stall diagnosis with likely causes, ranked fixes, and targeted checks +- Raw JSON command console for new protocol operations +- Searchable activity stream containing requests, replies, and async events + +The debugger currently stops and steps at guest frame boundaries. Data +watchpoints and per-instruction stepping are exposed in the protocol but depend +on future CPU backend hooks, as documented in `docs/debugger-server.md`. + +## Test + +```bash +python3 -m unittest discover -s tools/SharpEmu.DebuggerFrontend/tests -v +node --check tools/SharpEmu.DebuggerFrontend/web/app.js +``` + +The HTTP service binds to loopback by default and has no authentication. Only +use a non-loopback `--listen` address on a trusted network. + +On Linux, the Browse button uses `zenity` or `kdialog`. A full path can always +be entered manually. Closing the frontend also stops the emulator process it +launched so its captured output pipe cannot be orphaned; manually launched +emulators are never stopped by the frontend. diff --git a/tools/SharpEmu.DebuggerFrontend/debugger_frontend.py b/tools/SharpEmu.DebuggerFrontend/debugger_frontend.py new file mode 100755 index 0000000..3eea751 --- /dev/null +++ b/tools/SharpEmu.DebuggerFrontend/debugger_frontend.py @@ -0,0 +1,1165 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 SharpEmu Emulator Project +# SPDX-License-Identifier: GPL-2.0-or-later + +"""Local web frontend and TCP bridge for the SharpEmu live debugger.""" + +from __future__ import annotations + +import argparse +import copy +from collections import deque +import errno +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import os +import platform +import queue +import re +from pathlib import Path +import shutil +import signal +import socket +import subprocess +import threading +import time +from typing import Any +from urllib.parse import parse_qs, urlparse +from urllib.error import URLError +from urllib.request import Request, urlopen +import webbrowser + + +APP_ROOT = Path(__file__).resolve().parent +REPO_ROOT = APP_ROOT.parents[1] +WEB_ROOT = APP_ROOT / "web" +MAX_REQUEST_BYTES = 1024 * 1024 +MAX_JOURNAL_MESSAGES = 1000 +APPLICATION_ID = "sharpemu-debugger-frontend" + +KNOWN_WAIT_IMPORTS: dict[str, tuple[str, str, str]] = { + "9UK1vLZQft4": ("libKernel", "scePthreadMutexLock", "mutex"), + "7H0iTOciTLo": ("libKernel", "pthread_mutex_lock", "mutex"), + "WKAXJ4XBPQ4": ("libKernel", "scePthreadCondWait", "condition"), + "BmMjYxmew1w": ("libKernel", "scePthreadCondTimedwait", "condition"), + "Op8TBGY5KHg": ("libKernel", "pthread_cond_wait", "condition"), + "27bAgiJmOh0": ("libKernel", "pthread_cond_timedwait", "condition"), + "Zxa0VhQVTsk": ("libKernel", "sceKernelWaitSema", "wait"), + "JTvBflhYazQ": ("libKernel", "sceKernelWaitEventFlag", "wait"), + "fzyMKs9kim0": ("libKernel", "sceKernelWaitEqueue", "wait"), + "j6RaAUlaLv0": ("libSceVideoOut", "sceVideoOutWaitVblank", "timeline"), + "1jfXLRVzisc": ("libKernel", "sceKernelUsleep", "sleep"), +} + + +def analyze_debug_stop(stop: dict[str, Any]) -> dict[str, Any] | None: + """Turns structured or legacy stall evidence into actionable guidance.""" + + if str(stop.get("reason", "")).lower() != "stall": + return None + stall = copy.deepcopy(stop.get("stall")) if isinstance(stop.get("stall"), dict) else {} + detail = str(stop.get("detail", "")) + legacy = _parse_stall_detail(detail) + for key, value in legacy.items(): + stall.setdefault(key, value) + + kind = str(stall.get("kind", "Unknown")) + nid = str(stall.get("nid", "")).strip() or None + known = KNOWN_WAIT_IMPORTS.get(nid or "") + library = str(stall.get("library") or (known[0] if known else "")).strip() or None + function = str(stall.get("function") or (known[1] if known else "")).strip() or None + resolved = bool(stall.get("resolved")) or function is not None + category = known[2] if known else _classify_stall_function(function) + dispatch_index = _parse_optional_int(stall.get("dispatchIndex")) + instruction_pointer = stall.get("instructionPointer") or legacy.get("instructionPointer") + argument0 = stall.get("argument0") + + export_label = f"{library}:{function}" if library and function else function or nid or "unknown import" + evidence = [ + "The import-loop watchdog observed a repeating pattern of at most two imports/return sites for several seconds.", + ] + if nid: + evidence.append( + f"NID {nid} resolves to {export_label}." if resolved else f"NID {nid} is not resolved to an HLE export." + ) + if dispatch_index is not None: + evidence.append(f"The stop occurred after import dispatch #{dispatch_index:,}, indicating sustained retry rather than a short spin.") + if instruction_pointer: + evidence.append(f"The repeating guest return site is {instruction_pointer}.") + if argument0 and str(argument0) not in {"0", "0x0000000000000000"}: + evidence.append(f"The first ABI argument is {argument0}; for synchronization calls this is usually the waited object address.") + + if not resolved: + return { + "severity": "High", + "confidence": "High", + "title": "Unresolved import is being retried", + "summary": f"The guest repeatedly calls {nid or 'an unknown NID'} because the expected service never completes.", + "cause": "The NID has no matching HLE export for this target generation/library, so the fallback result sends the guest back into its retry path.", + "fix": "Implement or correctly register the missing export, including its expected return code and output-memory side effects. Confirm the library and Gen5 target flags match the game import.", + "actions": [ + "Search the HLE export registry for the NID and verify its library and target generation.", + "Trace the import arguments and determine which output/status value the guest expects to change.", + "Add a focused export test before disabling the loop guard; disabling it only hides the livelock.", + ], + "evidence": evidence, + "kind": kind, + "category": "unresolved-import", + } + + if category == "mutex": + return { + "severity": "High", + "confidence": "High", + "title": "Mutex lock is livelocking", + "summary": f"The guest keeps re-entering {export_label} from the same small call pattern without acquiring the lock or blocking.", + "cause": "The mutex is probably contended or self-owned, but the HLE/scheduler path is returning control to the guest without durable progress. A mismatched owner identity, incorrect mutex type, or unlock path that does not wake the waiter can produce the same loop.", + "fix": "Start in KernelPthreadCompatExports.PthreadMutexLockCore: make sure a contended lock parks the guest waiter through GuestThreadExecution, and make sure PthreadMutexUnlockCore grants and wakes the queued waiter. Also validate NORMAL/RECURSIVE/ERRORCHECK self-lock behavior and thread-owner IDs.", + "actions": [ + f"Trace lock/unlock ownership for {argument0 or 'the arg0 mutex address'} and compare the current thread handle with OwnerThreadId.", + "Verify GuestThreadExecution.IsGuestThread and TryGetCurrentImportCallFrame are true on the contended path so RequestCurrentThreadBlock is actually used.", + "Break on scePthreadMutexUnlock (NID tn3VlD0hG60) and confirm it removes/grants a waiter and signals the same wake key.", + "Do not simply increase or disable SHARPEMU_IMPORT_LOOP_GUARD_SECONDS; that masks the synchronization bug.", + ], + "evidence": evidence, + "kind": kind, + "category": category, + } + + if category == "condition": + title = "Condition wait is not sleeping or waking correctly" + cause = "The condition wait is returning into the guest retry loop instead of atomically releasing its mutex and parking, or its signal/broadcast path never wakes the waiter." + fix = "Audit the condition wait lifecycle: release the mutex, enqueue and block the guest thread, then reacquire the mutex only after signal, broadcast, or timeout completion." + actions = [ + "Trace the condition and mutex addresses passed in arg0/arg1.", + "Verify signal/broadcast targets the same condition key and wakes at least one queued guest waiter.", + "Check timeout clock units and ensure a timed wait does not immediately retry forever.", + ] + elif category == "wait": + title = "Kernel wait primitive is not reaching completion" + cause = "The wait call returns or is redispatched without the object becoming signaled, usually because the waiter was not parked or the producer does not issue its wake/completion transition." + fix = "Connect the wait export to guest-thread blocking and audit the matching signal/post/event producer so it updates state before waking the waiter." + actions = [ + "Trace the waited object address/ID and its signal/post counterpart.", + "Confirm the wait path queues once instead of adding or polling a waiter on every import call.", + "Verify completion writes all output fields and returns the guest-expected status code.", + ] + elif category == "timeline": + title = "Producer timeline is not advancing" + cause = "The guest repeatedly waits for a vblank/GPU/event milestone whose producer counter or completion fence is not being advanced." + fix = "Audit the producer pump and completion callback, then ensure the wait export blocks until the timeline changes instead of immediately returning unchanged state." + actions = [ + "Compare the requested timeline/fence value with the current producer value.", + "Confirm the vblank, submit, or completion pump is running on the host.", + "Verify a completion wakes every waiter registered for the reached value.", + ] + elif category == "sleep": + title = "Sleep/yield boundary is being redispatched" + cause = "The delay call is not yielding host execution or advancing the guest-visible clock, so the guest immediately retries its timing loop." + fix = "Treat the call as a scheduler boundary, block for the requested duration using the correct clock units, and update guest-visible time before resuming." + actions = [ + "Verify the delay argument units and overflow handling.", + "Confirm the guest thread yields instead of busy-returning on the same host thread.", + "Check monotonic clock progression observed by the game's follow-up time query.", + ] + else: + title = "Resolved HLE import is not making forward progress" + cause = f"{export_label} exists, but its return value or side effects leave the guest condition unchanged, causing the same import path to repeat." + fix = "Audit the export contract: return status, output pointers, state transitions, and any scheduler wake/completion it promises. Compare those effects with the condition checked at the repeating guest return site." + actions = [ + "Trace the import arguments, return value, and output memory for several consecutive iterations.", + "Identify the guest branch at the repeating return site and the exact state it expects to change.", + "Add a focused regression test for that state transition before changing the loop detector.", + ] + + return { + "severity": "High", + "confidence": "Medium" if category == "generic" else "High", + "title": title, + "summary": f"{export_label} repeats without forward progress.", + "cause": cause, + "fix": fix, + "actions": actions, + "evidence": evidence, + "kind": kind, + "category": category, + } + + +def _parse_stall_detail(detail: str) -> dict[str, Any]: + patterns = { + "kind": r"(?:^|,\s*)kind=([^,]+)", + "nid": r"(?:^|,\s*)nid=([^,]+)", + "instructionPointer": r"(?:^|,\s*)rip=(0x[0-9a-fA-F]+)", + "dispatchIndex": r"(?:^|,\s*)dispatch#(\d+)", + "argument0": r"(?:^|,\s*)arg0=(0x[0-9a-fA-F]+)", + "argument1": r"(?:^|,\s*)arg1=(0x[0-9a-fA-F]+)", + } + result: dict[str, Any] = {} + for key, pattern in patterns.items(): + match = re.search(pattern, detail) + if match: + result[key] = int(match.group(1)) if key == "dispatchIndex" else match.group(1).strip() + return result + + +def _classify_stall_function(function: str | None) -> str: + normalized = (function or "").lower() + if "mutex" in normalized and ("lock" in normalized or "wait" in normalized): + return "mutex" + if "cond" in normalized and "wait" in normalized: + return "condition" + if any(token in normalized for token in ("vblank", "waitregmem", "fence", "flip")): + return "timeline" + if any(token in normalized for token in ("usleep", "nanosleep", "sleep")): + return "sleep" + if any(token in normalized for token in ("wait", "sema", "event")): + return "wait" + return "generic" + + +def _parse_optional_int(value: Any) -> int | None: + try: + return int(value) if value is not None else None + except (TypeError, ValueError): + return None + + +def _attach_stop_analysis(stop: dict[str, Any]) -> dict[str, Any]: + decorated = copy.deepcopy(stop) + analysis = analyze_debug_stop(decorated) + if analysis is not None: + decorated["analysis"] = analysis + return decorated + + +class BridgeError(RuntimeError): + """Raised when the debugger bridge cannot complete an operation.""" + + +class EventJournal: + """A bounded, cursor-addressable activity stream for the browser.""" + + def __init__(self, capacity: int = MAX_JOURNAL_MESSAGES) -> None: + self._lock = threading.Lock() + self._messages: deque[dict[str, Any]] = deque(maxlen=capacity) + self._next_id = 1 + + def append(self, kind: str, summary: str, payload: Any = None) -> None: + with self._lock: + item = { + "id": self._next_id, + "time": time.strftime("%H:%M:%S"), + "kind": kind, + "summary": summary, + } + if payload is not None: + item["payload"] = copy.deepcopy(payload) + self._messages.append(item) + self._next_id += 1 + + def since(self, cursor: int) -> tuple[list[dict[str, Any]], int]: + with self._lock: + messages = [copy.deepcopy(item) for item in self._messages if item["id"] > cursor] + return messages, self._next_id - 1 + + +class EmulatorProcessManager: + """Launches and supervises the one emulator process owned by the UI.""" + + def __init__(self, journal: EventJournal) -> None: + self.journal = journal + self._lock = threading.RLock() + self._process: subprocess.Popen[str] | None = None + self._reader_thread: threading.Thread | None = None + self._eboot_path: str | None = None + self._emulator_path: str | None = None + self._started_at: str | None = None + self._exit_code: int | None = None + + def launch( + self, + eboot_path: str, + debug_port: int, + emulator_path: str | None = None, + ) -> dict[str, Any]: + eboot = Path(eboot_path).expanduser().resolve() + if not eboot.is_file(): + raise BridgeError(f"EBOOT file does not exist: {eboot}") + if debug_port < 1 or debug_port > 65535: + raise BridgeError("The debugger port must be between 1 and 65535.") + if self._port_is_open("127.0.0.1", debug_port): + raise BridgeError(f"Port {debug_port} is already in use. Stop the existing debugger or choose another port.") + + executable = self.resolve_emulator(emulator_path) + with self._lock: + self._refresh_process_locked() + if self._process is not None and self._process.poll() is None: + raise BridgeError("An emulator launched by this frontend is already running.") + + environment = os.environ.copy() + local_dotnet = REPO_ROOT.parent / ".dotnet" + if local_dotnet.is_dir(): + environment["DOTNET_ROOT"] = str(local_dotnet) + environment["PATH"] = f"{local_dotnet}{os.pathsep}{environment.get('PATH', '')}" + + command = [ + str(executable), + f"--debug-server=127.0.0.1:{debug_port}", + str(eboot), + ] + popen_options: dict[str, Any] = { + "cwd": str(REPO_ROOT), + "env": environment, + "stdout": subprocess.PIPE, + "stderr": subprocess.STDOUT, + "text": True, + "encoding": "utf-8", + "errors": "replace", + "bufsize": 1, + } + if os.name == "nt": + popen_options["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + popen_options["start_new_session"] = True + + try: + process = subprocess.Popen(command, **popen_options) + except OSError as exc: + raise BridgeError(f"Could not launch SharpEmu: {exc}") from exc + + self._process = process + self._eboot_path = str(eboot) + self._emulator_path = str(executable) + self._started_at = time.strftime("%H:%M:%S") + self._exit_code = None + self.journal.append("emulator", f"Launched {eboot.name} (PID {process.pid})", { + "executable": str(executable), + "eboot": str(eboot), + "debugPort": debug_port, + }) + reader_thread = threading.Thread( + target=self._read_output, + args=(process,), + name="sharpemu-emulator-output", + daemon=True, + ) + self._reader_thread = reader_thread + reader_thread.start() + return self._snapshot_locked() + + def stop(self, timeout: float = 5.0, log: bool = True) -> dict[str, Any]: + with self._lock: + self._refresh_process_locked() + process = self._process + if process is None or process.poll() is not None: + return self._snapshot_locked() + if log: + self.journal.append("emulator", f"Stopping emulator (PID {process.pid})") + + try: + if os.name == "nt": + process.terminate() + else: + os.killpg(process.pid, signal.SIGTERM) + process.wait(timeout=timeout) + except ProcessLookupError: + pass + except subprocess.TimeoutExpired: + self.journal.append("error", "Emulator did not stop gracefully; terminating it now.") + try: + if os.name == "nt": + process.kill() + else: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait(timeout=2) + + with self._lock: + self._refresh_process_locked() + return self._snapshot_locked() + + def wait_for_debugger(self, port: int, timeout: float = 12.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + with self._lock: + self._refresh_process_locked() + process = self._process + exit_code = self._exit_code + if process is None or exit_code is not None: + raise BridgeError(f"SharpEmu exited before its debugger started (exit code {exit_code}).") + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.25): + return + except OSError: + time.sleep(0.15) + raise BridgeError(f"SharpEmu did not open debugger port {port} within {timeout:g} seconds.") + + def select_eboot(self, initial_path: str | None = None) -> str | None: + initial = self._picker_initial_path(initial_path) + zenity = shutil.which("zenity") + kdialog = shutil.which("kdialog") + if zenity: + command = [ + zenity, + "--file-selection", + "--title=Choose a SharpEmu eboot", + "--file-filter=Game executable | eboot.bin *.bin *.elf", + "--file-filter=All files | *", + ] + if initial: + command.append(f"--filename={initial}") + elif kdialog: + command = [ + kdialog, + "--getopenfilename", + initial or str(Path.home()), + "Game executable (eboot.bin *.bin *.elf)", + "--title", + "Choose a SharpEmu eboot", + ] + else: + raise BridgeError("No desktop file picker was found. Enter the full eboot path manually.") + + try: + result = subprocess.run(command, capture_output=True, text=True, check=False) + except OSError as exc: + raise BridgeError(f"Could not open the file picker: {exc}") from exc + if result.returncode != 0: + return None + selected = result.stdout.strip() + return str(Path(selected).expanduser().resolve()) if selected else None + + def resolve_emulator(self, explicit_path: str | None = None) -> Path: + if explicit_path: + candidate = Path(explicit_path).expanduser().resolve() + else: + system = platform.system().lower() + machine = platform.machine().lower() + rid = { + ("linux", "x86_64"): "linux-x64", + ("linux", "amd64"): "linux-x64", + ("windows", "amd64"): "win-x64", + ("windows", "x86_64"): "win-x64", + ("darwin", "x86_64"): "osx-x64", + }.get((system, machine)) + executable_name = "SharpEmu.exe" if os.name == "nt" else "SharpEmu" + candidates: list[Path] = [] + if rid: + candidates.append(REPO_ROOT / "artifacts" / "bin" / "Release" / "net10.0" / rid / executable_name) + candidates.extend( + path for path in (REPO_ROOT / "artifacts" / "bin" / "Release" / "net10.0").glob(f"*/{executable_name}") + if path not in candidates + ) + candidate = next((path.resolve() for path in candidates if path.is_file()), candidates[0] if candidates else Path()) + + if not candidate.is_file(): + raise BridgeError( + "The SharpEmu Release executable was not found. Build the solution first with " + "'dotnet build SharpEmu.slnx --configuration Release'." + ) + if os.name != "nt" and not os.access(candidate, os.X_OK): + raise BridgeError(f"The SharpEmu executable is not runnable: {candidate}") + return candidate + + def snapshot(self) -> dict[str, Any]: + with self._lock: + self._refresh_process_locked() + return self._snapshot_locked() + + def _read_output(self, process: subprocess.Popen[str]) -> None: + output = process.stdout + if output is not None: + try: + for line in output: + text = line.rstrip("\r\n") + if text: + self.journal.append("emulator", text[:4000]) + except (OSError, ValueError): + pass + finally: + output.close() + try: + process.wait() + except OSError: + pass + with self._lock: + self._refresh_process_locked() + + def _refresh_process_locked(self) -> None: + if self._process is None: + return + exit_code = self._process.poll() + if exit_code is not None and self._exit_code is None: + self._exit_code = exit_code + kind = "emulator" if exit_code == 0 else "error" + self.journal.append(kind, f"Emulator exited with code {exit_code}") + + def _snapshot_locked(self) -> dict[str, Any]: + process = self._process + running = process is not None and process.poll() is None + return { + "running": running, + "pid": process.pid if running else None, + "eboot": self._eboot_path, + "executable": self._emulator_path, + "startedAt": self._started_at, + "exitCode": self._exit_code, + } + + @staticmethod + def _port_is_open(host: str, port: int) -> bool: + try: + with socket.create_connection((host, port), timeout=0.2): + return True + except OSError: + return False + + @staticmethod + def _picker_initial_path(initial_path: str | None) -> str | None: + if not initial_path: + return None + path = Path(initial_path).expanduser() + if path.is_file(): + return str(path.resolve()) + if path.is_dir(): + return f"{path.resolve()}{os.sep}" + parent = path.parent + return f"{parent.resolve()}{os.sep}" if parent.is_dir() else None + + +class DebuggerBridge: + """Owns one debugger socket and serializes request/reply traffic.""" + + def __init__(self, default_host: str = "127.0.0.1", default_port: int = 5714) -> None: + self.default_host = default_host + self.default_port = default_port + self.journal = EventJournal() + + self._state_lock = threading.RLock() + self._lifecycle_lock = threading.RLock() + self._command_lock = threading.Lock() + self._send_lock = threading.Lock() + self._responses: queue.Queue[tuple[int, Any]] = queue.Queue() + self._generation = 0 + self._hello_event = threading.Event() + self._socket: socket.socket | None = None + self._reader_thread: threading.Thread | None = None + + self._connected = False + self._endpoint: str | None = None + self._protocol: str | None = None + self._target_state = "Disconnected" + self._last_stop: dict[str, Any] | None = None + self._registers: dict[str, Any] = {} + self._breakpoints: list[dict[str, Any]] = [] + + def connect(self, host: str, port: int, timeout: float = 3.0) -> None: + with self._lifecycle_lock: + self._connect(host, port, timeout) + + def _connect(self, host: str, port: int, timeout: float) -> None: + host = host.strip() + if not host: + raise BridgeError("A debugger host is required.") + if port < 1 or port > 65535: + raise BridgeError("The debugger port must be between 1 and 65535.") + + self.disconnect(log=False) + self._drain_responses() + try: + client = socket.create_connection((host, port), timeout=timeout) + client.settimeout(None) + client.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + reader = client.makefile("r", encoding="utf-8", newline="\n") + except OSError as exc: + message = f"Could not connect to {host}:{port}: {exc}" + self.journal.append("error", message) + raise BridgeError(message) from exc + + with self._state_lock: + self._generation += 1 + generation = self._generation + self._socket = client + self._connected = True + self._endpoint = f"{host}:{port}" + self._protocol = None + self._target_state = "Connecting" + self._last_stop = None + self._registers = {} + self._breakpoints = [] + self._hello_event = threading.Event() + + self.journal.append("system", f"Connected to {host}:{port}") + thread = threading.Thread( + target=self._read_loop, + args=(generation, client, reader), + name="sharpemu-debug-reader", + daemon=True, + ) + self._reader_thread = thread + thread.start() + + self._hello_event.wait(timeout=min(timeout, 1.5)) + with self._state_lock: + if generation != self._generation or not self._connected: + raise BridgeError(f"The debugger at {host}:{port} closed the connection.") + + def disconnect(self, reason: str = "Disconnected", log: bool = True) -> None: + with self._lifecycle_lock: + self._disconnect(reason, log) + + def _disconnect(self, reason: str, log: bool) -> None: + with self._state_lock: + client = self._socket + was_connected = self._connected + self._generation += 1 + generation = self._generation + self._socket = None + self._connected = False + self._protocol = None + self._target_state = "Disconnected" + self._last_stop = None + self._registers = {} + self._breakpoints = [] + + if client is not None: + try: + client.shutdown(socket.SHUT_RDWR) + except OSError: + pass + client.close() + + if was_connected: + self._responses.put((generation - 1, BridgeError(reason))) + if log: + self.journal.append("system", reason) + + def request(self, payload: dict[str, Any], timeout: float = 6.0) -> dict[str, Any]: + command = payload.get("command") + if not isinstance(command, str) or not command.strip(): + raise BridgeError("Every request requires a non-empty 'command'.") + + wire_data = (json.dumps(payload, separators=(",", ":")) + "\n").encode("utf-8") + if len(wire_data) > MAX_REQUEST_BYTES: + raise BridgeError("The debugger request is too large.") + + with self._command_lock: + with self._state_lock: + client = self._socket + generation = self._generation + connected = self._connected + if client is None or not connected: + raise BridgeError("Not connected to a SharpEmu debugger.") + + try: + with self._send_lock: + client.sendall(wire_data) + except OSError as exc: + self._connection_lost(generation, f"Connection lost while sending: {exc}") + raise BridgeError("The debugger connection was lost while sending.") from exc + + self.journal.append("request", command, payload) + deadline = time.monotonic() + timeout + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + self.disconnect(f"Debugger request '{command}' timed out.") + raise BridgeError(f"Debugger request '{command}' timed out.") + try: + response_generation, item = self._responses.get(timeout=remaining) + except queue.Empty as exc: + self.disconnect(f"Debugger request '{command}' timed out.") + raise BridgeError(f"Debugger request '{command}' timed out.") from exc + if response_generation != generation: + continue + if isinstance(item, BaseException): + raise BridgeError(str(item)) from item + response = item + break + + self._apply_response(response) + if response.get("ok"): + self.journal.append("response", f"{command} succeeded", response) + else: + self.journal.append("error", response.get("error", f"{command} failed"), response) + return response + + def snapshot(self, cursor: int = 0) -> dict[str, Any]: + messages, next_cursor = self.journal.since(max(0, cursor)) + with self._state_lock: + return { + "connected": self._connected, + "endpoint": self._endpoint, + "defaultEndpoint": { + "host": self.default_host, + "port": self.default_port, + }, + "protocol": self._protocol, + "state": self._target_state, + "lastStop": copy.deepcopy(self._last_stop), + "registers": copy.deepcopy(self._registers), + "breakpoints": copy.deepcopy(self._breakpoints), + "messages": messages, + "cursor": next_cursor, + } + + def _read_loop( + self, + generation: int, + client: socket.socket, + reader: Any, + ) -> None: + reason = "Debugger closed the connection." + try: + while True: + line = reader.readline() + if line == "": + break + line = line.strip() + if not line: + continue + try: + message = json.loads(line) + except json.JSONDecodeError: + self.journal.append("error", "Debugger sent malformed JSON", line) + continue + if not isinstance(message, dict): + self.journal.append("error", "Debugger sent a non-object message", message) + continue + + if "event" in message: + self._apply_event(message) + event_name = str(message.get("event", "event")) + self.journal.append("event", event_name, message) + else: + self._responses.put((generation, message)) + except (OSError, ValueError) as exc: + reason = f"Debugger connection lost: {exc}" + finally: + try: + reader.close() + except OSError: + pass + try: + client.close() + except OSError: + pass + self._connection_lost(generation, reason) + + def _connection_lost(self, generation: int, reason: str) -> None: + with self._state_lock: + if generation != self._generation: + return + self._socket = None + self._connected = False + self._protocol = None + self._target_state = "Disconnected" + self._last_stop = None + self._registers = {} + self._breakpoints = [] + self._generation += 1 + self._responses.put((generation, BridgeError(reason))) + self.journal.append("error", reason) + + def _apply_event(self, message: dict[str, Any]) -> None: + event = str(message.get("event", "")).lower() + with self._state_lock: + if event == "hello": + self._protocol = str(message.get("protocol", "unknown")) + self._target_state = str(message.get("state", "Connected")) + self._hello_event.set() + elif event == "stopped": + self._target_state = "Paused" + self._last_stop = _attach_stop_analysis({ + key: copy.deepcopy(value) for key, value in message.items() if key != "event" + }) + registers = message.get("registers") + if isinstance(registers, dict): + self._registers = copy.deepcopy(registers) + elif event == "resumed": + self._target_state = "Running" + elif event == "terminated": + self._target_state = "Terminated" + + def _apply_response(self, response: dict[str, Any]) -> None: + if not response.get("ok"): + return + command = str(response.get("command", "")).lower() + data = response.get("data") + if not isinstance(data, dict): + return + with self._state_lock: + if command in {"status", "info"}: + self._target_state = str(data.get("state", self._target_state)) + last_stop = data.get("lastStop") + if isinstance(last_stop, dict): + self._last_stop = _attach_stop_analysis(last_stop) + registers = last_stop.get("registers") + if isinstance(registers, dict): + self._registers = copy.deepcopy(registers) + elif command == "state": + self._target_state = str(data.get("state", self._target_state)) + elif command in {"registers", "regs"}: + registers = data.get("registers") + if isinstance(registers, dict): + self._registers = copy.deepcopy(registers) + elif command in {"list-breakpoints", "breakpoints"}: + breakpoints = data.get("breakpoints") + if isinstance(breakpoints, list): + self._breakpoints = copy.deepcopy(breakpoints) + + def _drain_responses(self) -> None: + while True: + try: + self._responses.get_nowait() + except queue.Empty: + return + + +class FrontendRequestHandler(BaseHTTPRequestHandler): + """Serves static UI assets and the local bridge API.""" + + bridge: DebuggerBridge + process_manager: EmulatorProcessManager + verbose = False + + def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + parsed = urlparse(self.path) + if parsed.path == "/api/snapshot": + values = parse_qs(parsed.query) + try: + cursor = int(values.get("since", ["0"])[0]) + except ValueError: + cursor = 0 + self._send_json(HTTPStatus.OK, self._combined_snapshot(cursor)) + return + if parsed.path == "/api/health": + self._send_json(HTTPStatus.OK, { + "ok": True, + "application": APPLICATION_ID, + "pid": os.getpid(), + }) + return + + asset_map = { + "/": ("index.html", "text/html; charset=utf-8"), + "/index.html": ("index.html", "text/html; charset=utf-8"), + "/app.js": ("app.js", "text/javascript; charset=utf-8"), + "/styles.css": ("styles.css", "text/css; charset=utf-8"), + "/sharpemu-logo.webp": ("sharpemu-logo.webp", "image/webp"), + } + asset = asset_map.get(parsed.path) + if asset is None: + self.send_error(HTTPStatus.NOT_FOUND) + return + file_name, content_type = asset + try: + body = (WEB_ROOT / file_name).read_bytes() + except OSError: + self.send_error(HTTPStatus.NOT_FOUND) + return + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header( + "Content-Security-Policy", + "default-src 'self'; connect-src 'self'; img-src 'self' data:; " + "script-src 'self'; style-src 'self'; base-uri 'none'; frame-ancestors 'none'", + ) + self.end_headers() + self.wfile.write(body) + + def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + parsed = urlparse(self.path) + try: + body = self._read_json_body() + if parsed.path == "/api/connect": + host = str(body.get("host", self.bridge.default_host)).strip() + port = int(body.get("port", self.bridge.default_port)) + self.bridge.connect(host, port) + self._prime_snapshot() + self._send_json(HTTPStatus.OK, self._combined_snapshot()) + return + if parsed.path == "/api/disconnect": + self.bridge.disconnect() + self._send_json(HTTPStatus.OK, self._combined_snapshot()) + return + if parsed.path == "/api/select-eboot": + selected = self.process_manager.select_eboot(body.get("initialPath")) + self._send_json(HTTPStatus.OK, {"path": selected}) + return + if parsed.path == "/api/launch": + eboot_path = body.get("ebootPath") + if not isinstance(eboot_path, str) or not eboot_path.strip(): + raise BridgeError("Choose an eboot file before launching.") + debug_port = int(body.get("debugPort", self.bridge.default_port)) + emulator_path = body.get("emulatorPath") + if emulator_path is not None and not isinstance(emulator_path, str): + raise BridgeError("'emulatorPath' must be a string.") + self.bridge.disconnect(log=False) + try: + self.process_manager.launch(eboot_path, debug_port, emulator_path) + self.process_manager.wait_for_debugger(debug_port) + self.bridge.connect("127.0.0.1", debug_port) + self._prime_snapshot() + except BridgeError: + self.process_manager.stop(log=False) + raise + self._send_json(HTTPStatus.OK, self._combined_snapshot()) + return + if parsed.path == "/api/stop-emulator": + self.bridge.disconnect(log=False) + self.process_manager.stop() + self._send_json(HTTPStatus.OK, self._combined_snapshot()) + return + if parsed.path == "/api/shutdown": + self.bridge.journal.append("system", "A replacement frontend requested shutdown.") + self._send_json(HTTPStatus.OK, {"ok": True, "pid": os.getpid()}) + threading.Thread( + target=self._shutdown_http_server, + name="sharpemu-frontend-shutdown", + daemon=True, + ).start() + return + if parsed.path == "/api/command": + request = body.get("request") + if not isinstance(request, dict): + raise BridgeError("Expected a JSON object in 'request'.") + response = self.bridge.request(request) + self._send_json(HTTPStatus.OK, {"response": response}) + return + self._send_json(HTTPStatus.NOT_FOUND, {"error": "Unknown API endpoint."}) + except (BridgeError, ValueError, TypeError) as exc: + self._send_json(HTTPStatus.BAD_GATEWAY, {"error": str(exc)}) + except json.JSONDecodeError: + self._send_json(HTTPStatus.BAD_REQUEST, {"error": "Request body is not valid JSON."}) + + def _prime_snapshot(self) -> None: + try: + self.bridge.request({"command": "status"}) + self.bridge.request({"command": "list-breakpoints"}) + except BridgeError as exc: + self.bridge.journal.append("error", f"Initial refresh failed: {exc}") + + def _combined_snapshot(self, cursor: int = 0) -> dict[str, Any]: + snapshot = self.bridge.snapshot(cursor) + snapshot["emulator"] = self.process_manager.snapshot() + return snapshot + + def _shutdown_http_server(self) -> None: + self.server.shutdown() + self.server.server_close() + + def _read_json_body(self) -> dict[str, Any]: + try: + length = int(self.headers.get("Content-Length", "0")) + except ValueError as exc: + raise BridgeError("Invalid Content-Length header.") from exc + if length < 0 or length > MAX_REQUEST_BYTES: + raise BridgeError("HTTP request body is too large.") + raw = self.rfile.read(length) + value = json.loads(raw.decode("utf-8") if raw else "{}") + if not isinstance(value, dict): + raise BridgeError("Expected a JSON object request body.") + return value + + def _send_json(self, status: HTTPStatus, payload: Any) -> None: + body = json.dumps(payload, separators=(",", ":")).encode("utf-8") + try: + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.end_headers() + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + pass + + def log_message(self, format_string: str, *args: Any) -> None: + if self.verbose: + super().log_message(format_string, *args) + + +class FrontendHttpServer(ThreadingHTTPServer): + daemon_threads = True + allow_reuse_address = True + + +def create_frontend_server( + listen_address: str, + port: int, + handler_type: type[FrontendRequestHandler], +) -> FrontendHttpServer: + """Binds the UI server, replacing an older frontend on the same port.""" + + try: + return FrontendHttpServer((listen_address, port), handler_type) + except OSError as exc: + if exc.errno != errno.EADDRINUSE or port == 0: + raise SystemExit(f"Could not start the frontend on {listen_address}:{port}: {exc}") from None + + if not request_existing_frontend_shutdown(listen_address, port): + raise SystemExit( + f"Port {port} is already in use by another application. " + "Choose a different port with --ui-port." + ) + + deadline = time.monotonic() + 10.0 + while time.monotonic() < deadline: + try: + return FrontendHttpServer((listen_address, port), handler_type) + except OSError as exc: + if exc.errno != errno.EADDRINUSE: + raise SystemExit(f"Could not start the frontend on {listen_address}:{port}: {exc}") from None + time.sleep(0.15) + raise SystemExit(f"The previous SharpEmu frontend did not release port {port} in time.") + + +def request_existing_frontend_shutdown(listen_address: str, port: int) -> bool: + """Stops only a verified SharpEmu frontend listening on the target port.""" + + probe_host = "127.0.0.1" if listen_address in {"0.0.0.0", "localhost"} else listen_address + url_host = f"[{probe_host}]" if ":" in probe_host and not probe_host.startswith("[") else probe_host + base_url = f"http://{url_host}:{port}" + try: + with urlopen(f"{base_url}/api/health", timeout=0.75) as response: + health = json.load(response) + except (OSError, URLError, ValueError, json.JSONDecodeError): + return False + + if isinstance(health, dict) and health.get("application") == APPLICATION_ID: + request = Request( + f"{base_url}/api/shutdown", + data=b"{}", + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urlopen(request, timeout=1.5) as response: + result = json.load(response) + return isinstance(result, dict) and result.get("ok") is True + except (OSError, URLError, ValueError, json.JSONDecodeError): + return False + + # Frontends created before the shutdown API only returned {"ok": true}. + # Verify both their page identity and the exact owning process before asking + # that legacy instance to exit through its normal Ctrl+C cleanup path. + if not isinstance(health, dict) or health.get("ok") is not True: + return False + try: + with urlopen(f"{base_url}/", timeout=0.75) as response: + page = response.read(128 * 1024) + except (OSError, URLError): + return False + if b"SharpEmu Debugger" not in page: + return False + legacy_pid = find_legacy_frontend_pid(port) + if legacy_pid is None: + return False + try: + os.kill(legacy_pid, signal.SIGINT) + return True + except (OSError, ProcessLookupError): + return False + + +def find_legacy_frontend_pid(port: int) -> int | None: + """Finds the verified legacy frontend owning a Linux listening socket.""" + + if platform.system() != "Linux" or shutil.which("ss") is None: + return None + try: + result = subprocess.run( + ["ss", "-ltnp", f"( sport = :{port} )"], + capture_output=True, + text=True, + timeout=2, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return None + script_path = str(Path(__file__).resolve()) + for match in re.finditer(r"pid=(\d+)", result.stdout): + pid = int(match.group(1)) + if pid == os.getpid(): + continue + try: + command_line = Path(f"/proc/{pid}/cmdline").read_bytes().replace(b"\0", b" ").decode("utf-8", "replace") + except OSError: + continue + if script_path in command_line: + return pid + return None + + +def build_argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="SharpEmu live debugger web frontend") + parser.add_argument("--debug-host", default="127.0.0.1", help="SharpEmu debugger host") + parser.add_argument("--debug-port", type=int, default=5714, help="SharpEmu debugger port") + parser.add_argument("--listen", default="127.0.0.1", help="Frontend HTTP bind address") + parser.add_argument("--ui-port", type=int, default=8765, help="Frontend HTTP port (0 chooses a free port)") + parser.add_argument("--no-connect", action="store_true", help="Do not connect to SharpEmu on startup") + parser.add_argument("--no-browser", action="store_true", help="Do not open the frontend in a browser") + parser.add_argument("--verbose", action="store_true", help="Print HTTP request logs") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_argument_parser().parse_args(argv) + if args.debug_port < 1 or args.debug_port > 65535: + raise SystemExit("--debug-port must be between 1 and 65535") + if args.ui_port < 0 or args.ui_port > 65535: + raise SystemExit("--ui-port must be between 0 and 65535") + + bridge = DebuggerBridge(args.debug_host, args.debug_port) + process_manager = EmulatorProcessManager(bridge.journal) + handler_type = type( + "ConfiguredFrontendRequestHandler", + (FrontendRequestHandler,), + {"bridge": bridge, "process_manager": process_manager, "verbose": args.verbose}, + ) + server = create_frontend_server(args.listen, args.ui_port, handler_type) + actual_port = server.server_address[1] + browser_host = args.listen if args.listen not in {"0.0.0.0", "::"} else "127.0.0.1" + url = f"http://{browser_host}:{actual_port}/" + + print(f"SharpEmu Debugger Frontend: {url}") + print(f"Debugger endpoint: {args.debug_host}:{args.debug_port}") + print("Press Ctrl+C to stop.") + if args.listen not in {"127.0.0.1", "localhost", "::1"}: + print("Warning: the frontend is listening beyond loopback; no authentication is provided.") + + if not args.no_connect: + def connect_default() -> None: + try: + bridge.connect(args.debug_host, args.debug_port) + bridge.request({"command": "status"}) + bridge.request({"command": "list-breakpoints"}) + except BridgeError as exc: + bridge.journal.append("system", f"Start SharpEmu with --debug-server, then reconnect: {exc}") + + threading.Thread(target=connect_default, name="sharpemu-auto-connect", daemon=True).start() + + if not args.no_browser: + threading.Timer(0.35, lambda: webbrowser.open(url)).start() + + try: + server.serve_forever(poll_interval=0.25) + except KeyboardInterrupt: + print("\nStopping frontend...") + finally: + bridge.disconnect(log=False) + process_manager.stop(log=False) + server.shutdown() + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/SharpEmu.DebuggerFrontend/run.sh b/tools/SharpEmu.DebuggerFrontend/run.sh new file mode 100755 index 0000000..9694f2e --- /dev/null +++ b/tools/SharpEmu.DebuggerFrontend/run.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env sh +# Copyright (C) 2026 SharpEmu Emulator Project +# SPDX-License-Identifier: GPL-2.0-or-later + +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec python3 "$script_dir/debugger_frontend.py" "$@" diff --git a/tools/SharpEmu.DebuggerFrontend/tests/test_debugger_frontend.py b/tools/SharpEmu.DebuggerFrontend/tests/test_debugger_frontend.py new file mode 100644 index 0000000..6c32c19 --- /dev/null +++ b/tools/SharpEmu.DebuggerFrontend/tests/test_debugger_frontend.py @@ -0,0 +1,309 @@ +# Copyright (C) 2026 SharpEmu Emulator Project +# SPDX-License-Identifier: GPL-2.0-or-later + +from __future__ import annotations + +import json +from pathlib import Path +import socket +import sys +import tempfile +import textwrap +import threading +import unittest +from urllib.request import Request, urlopen + + +FRONTEND_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(FRONTEND_ROOT)) + +from debugger_frontend import ( # noqa: E402 + BridgeError, + DebuggerBridge, + EmulatorProcessManager, + FrontendHttpServer, + FrontendRequestHandler, + analyze_debug_stop, + create_frontend_server, +) + + +REGISTERS = { + "rax": "0x0000000000000001", + "rip": "0x00000008801234A0", + "rflags": "0x0000000000000202", +} + + +class FakeDebuggerServer: + def __init__(self) -> None: + self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.listener.bind(("127.0.0.1", 0)) + self.listener.listen(1) + self.port = self.listener.getsockname()[1] + self.client: socket.socket | None = None + self.thread = threading.Thread(target=self._run, daemon=True) + self.thread.start() + + def _run(self) -> None: + try: + client, _ = self.listener.accept() + self.client = client + reader = client.makefile("r", encoding="utf-8", newline="\n") + writer = client.makefile("w", encoding="utf-8", newline="\n") + self._write(writer, {"event": "hello", "protocol": "json-lines/1", "state": "Paused"}) + for line in reader: + request = json.loads(line) + command = request["command"] + if command == "status": + self._write(writer, { + "ok": True, + "command": command, + "data": {"state": "Paused", "breakpoints": 1}, + }) + elif command == "registers": + self._write(writer, { + "event": "stopped", + "reason": "Breakpoint", + "address": REGISTERS["rip"], + "frameKind": "ProcessEntry", + "frameLabel": "eboot.bin", + "registers": REGISTERS, + }) + self._write(writer, { + "ok": True, + "command": command, + "data": {"registers": REGISTERS}, + }) + elif command == "list-breakpoints": + self._write(writer, { + "ok": True, + "command": command, + "data": { + "breakpoints": [{ + "id": 1, + "kind": "Execute", + "address": REGISTERS["rip"], + "length": 1, + "enabled": True, + }], + }, + }) + else: + self._write(writer, {"ok": True, "command": command}) + except OSError: + pass + + @staticmethod + def _write(writer: object, payload: dict[str, object]) -> None: + writer.write(json.dumps(payload, separators=(",", ":")) + "\n") + writer.flush() + + def close(self) -> None: + if self.client is not None: + try: + self.client.shutdown(socket.SHUT_RDWR) + except OSError: + pass + self.client.close() + self.listener.close() + self.thread.join(timeout=1) + + +class DebuggerBridgeTests(unittest.TestCase): + def setUp(self) -> None: + self.server = FakeDebuggerServer() + self.bridge = DebuggerBridge() + self.bridge.connect("127.0.0.1", self.server.port) + + def tearDown(self) -> None: + self.bridge.disconnect(log=False) + self.server.close() + + def test_connect_receives_hello(self) -> None: + snapshot = self.bridge.snapshot() + self.assertTrue(snapshot["connected"]) + self.assertEqual("json-lines/1", snapshot["protocol"]) + self.assertEqual("Paused", snapshot["state"]) + + def test_request_pairs_reply_while_processing_event(self) -> None: + reply = self.bridge.request({"command": "registers"}) + self.assertTrue(reply["ok"]) + snapshot = self.bridge.snapshot() + self.assertEqual(REGISTERS["rip"], snapshot["registers"]["rip"]) + self.assertEqual("Breakpoint", snapshot["lastStop"]["reason"]) + self.assertTrue(any(item["summary"] == "stopped" for item in snapshot["messages"])) + + def test_breakpoint_snapshot_is_updated(self) -> None: + self.bridge.request({"command": "list-breakpoints"}) + snapshot = self.bridge.snapshot() + self.assertEqual(1, len(snapshot["breakpoints"])) + self.assertEqual("Execute", snapshot["breakpoints"][0]["kind"]) + + def test_invalid_request_is_rejected_locally(self) -> None: + with self.assertRaises(BridgeError): + self.bridge.request({}) + + def test_journal_cursor_returns_only_new_messages(self) -> None: + cursor = self.bridge.snapshot()["cursor"] + self.bridge.request({"command": "status"}) + snapshot = self.bridge.snapshot(cursor) + self.assertGreaterEqual(len(snapshot["messages"]), 2) + self.assertTrue(all(message["id"] > cursor for message in snapshot["messages"])) + + +class StallAnalysisTests(unittest.TestCase): + def test_legacy_mutex_stall_identifies_likely_scheduler_fix(self) -> None: + analysis = analyze_debug_stop({ + "reason": "Stall", + "detail": "kind=ImportLoop, nid=9UK1vLZQft4, dispatch#40667904, rip=0x0000000801CE2418", + }) + self.assertIsNotNone(analysis) + self.assertEqual("Mutex lock is livelocking", analysis["title"]) + self.assertEqual("High", analysis["confidence"]) + self.assertIn("PthreadMutexLockCore", analysis["fix"]) + self.assertTrue(any("9UK1vLZQft4" in item for item in analysis["evidence"])) + + def test_unresolved_import_stall_recommends_export_implementation(self) -> None: + analysis = analyze_debug_stop({ + "reason": "Stall", + "stall": { + "kind": "ImportLoop", + "nid": "missing-nid", + "resolved": False, + "dispatchIndex": 8192, + "instructionPointer": "0x0000000000001234", + }, + }) + self.assertIsNotNone(analysis) + self.assertEqual("Unresolved import is being retried", analysis["title"]) + self.assertIn("Implement or correctly register", analysis["fix"]) + + +class FrontendHttpTests(unittest.TestCase): + def setUp(self) -> None: + self.debugger = FakeDebuggerServer() + self.bridge = DebuggerBridge() + self.process_manager = EmulatorProcessManager(self.bridge.journal) + handler = type( + "TestFrontendRequestHandler", + (FrontendRequestHandler,), + {"bridge": self.bridge, "process_manager": self.process_manager}, + ) + self.http_server = FrontendHttpServer(("127.0.0.1", 0), handler) + self.http_port = self.http_server.server_address[1] + self.http_thread = threading.Thread(target=self.http_server.serve_forever, daemon=True) + self.http_thread.start() + + def tearDown(self) -> None: + self.bridge.disconnect(log=False) + self.process_manager.stop(log=False) + self.http_server.shutdown() + self.http_server.server_close() + self.http_thread.join(timeout=1) + self.debugger.close() + + def post(self, path: str, payload: dict[str, object]) -> dict[str, object]: + request = Request( + f"http://127.0.0.1:{self.http_port}{path}", + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urlopen(request, timeout=2) as response: + return json.load(response) + + def test_api_connect_and_command_round_trip(self) -> None: + connected = self.post("/api/connect", {"host": "127.0.0.1", "port": self.debugger.port}) + self.assertTrue(connected["connected"]) + self.assertEqual("json-lines/1", connected["protocol"]) + + result = self.post("/api/command", {"request": {"command": "registers"}}) + self.assertTrue(result["response"]["ok"]) + + def test_static_frontend_is_served(self) -> None: + with urlopen(f"http://127.0.0.1:{self.http_port}/", timeout=2) as response: + body = response.read().decode("utf-8") + self.assertEqual("text/html; charset=utf-8", response.headers["Content-Type"]) + self.assertIn("SharpEmu Debugger", body) + self.assertIn('rel="icon" type="image/webp"', body) + + with urlopen(f"http://127.0.0.1:{self.http_port}/sharpemu-logo.webp", timeout=2) as response: + logo = response.read() + self.assertEqual("image/webp", response.headers["Content-Type"]) + self.assertTrue(logo.startswith(b"RIFF")) + + def test_api_launches_and_stops_emulator_with_auto_attach(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + eboot = root / "eboot.bin" + eboot.write_bytes(b"test") + emulator = root / "fake-sharpemu" + emulator.write_text(textwrap.dedent("""\ + #!/usr/bin/env python3 + import json + import socket + import sys + + endpoint = next(arg.split("=", 1)[1] for arg in sys.argv if arg.startswith("--debug-server=")) + port = int(endpoint.rsplit(":", 1)[1]) + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("127.0.0.1", port)) + listener.listen(4) + print("Fake SharpEmu debug server ready", flush=True) + while True: + client, _ = listener.accept() + with client: + reader = client.makefile("r", encoding="utf-8") + writer = client.makefile("w", encoding="utf-8") + writer.write(json.dumps({"event": "hello", "protocol": "json-lines/1", "state": "Paused"}) + "\\n") + writer.flush() + for line in reader: + request = json.loads(line) + command = request["command"] + data = {"state": "Paused", "breakpoints": 0} if command == "status" else {"breakpoints": []} + writer.write(json.dumps({"ok": True, "command": command, "data": data}) + "\\n") + writer.flush() + """), encoding="utf-8") + emulator.chmod(0o755) + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as reservation: + reservation.bind(("127.0.0.1", 0)) + debug_port = reservation.getsockname()[1] + + launched = self.post("/api/launch", { + "ebootPath": str(eboot), + "debugPort": debug_port, + "emulatorPath": str(emulator), + }) + self.assertTrue(launched["connected"]) + self.assertTrue(launched["emulator"]["running"]) + self.assertEqual(str(eboot), launched["emulator"]["eboot"]) + + stopped = self.post("/api/stop-emulator", {}) + self.assertFalse(stopped["connected"]) + self.assertFalse(stopped["emulator"]["running"]) + + def test_rerun_replaces_existing_frontend_on_same_port(self) -> None: + replacement_handler = type( + "ReplacementFrontendRequestHandler", + (FrontendRequestHandler,), + {"bridge": self.bridge, "process_manager": self.process_manager}, + ) + old_thread = self.http_thread + replacement = create_frontend_server("127.0.0.1", self.http_port, replacement_handler) + old_thread.join(timeout=2) + self.assertFalse(old_thread.is_alive()) + + self.http_server = replacement + self.http_thread = threading.Thread(target=replacement.serve_forever, daemon=True) + self.http_thread.start() + with urlopen(f"http://127.0.0.1:{self.http_port}/api/health", timeout=2) as response: + health = json.load(response) + self.assertEqual("sharpemu-debugger-frontend", health["application"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/SharpEmu.DebuggerFrontend/web/app.js b/tools/SharpEmu.DebuggerFrontend/web/app.js new file mode 100644 index 0000000..3c79522 --- /dev/null +++ b/tools/SharpEmu.DebuggerFrontend/web/app.js @@ -0,0 +1,626 @@ +/* +Copyright (C) 2026 SharpEmu Emulator Project +SPDX-License-Identifier: GPL-2.0-or-later +*/ + +"use strict"; + +const REGISTER_ORDER = [ + "rax", "rcx", "rdx", "rbx", "rsp", "rbp", "rsi", "rdi", + "r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", + "rip", "rflags", "fs_base", "gs_base", +]; + +const ui = { + cursor: 0, + snapshot: null, + activity: [], + polling: false, + refreshing: false, +}; + +const byId = (id) => document.getElementById(id); + +async function api(path, options = {}) { + const response = await fetch(path, { + headers: { "Content-Type": "application/json" }, + cache: "no-store", + ...options, + }); + let payload; + try { + payload = await response.json(); + } catch { + throw new Error(`Frontend returned HTTP ${response.status}.`); + } + if (!response.ok) { + throw new Error(payload.error || `Request failed with HTTP ${response.status}.`); + } + return payload; +} + +async function pollSnapshot() { + if (ui.polling) return; + ui.polling = true; + try { + const snapshot = await api(`/api/snapshot?since=${ui.cursor}`); + applySnapshot(snapshot); + } catch (error) { + showToast("Frontend unavailable", error.message, "error"); + } finally { + ui.polling = false; + } +} + +function applySnapshot(snapshot) { + const previous = ui.snapshot; + const previousCursor = ui.cursor; + const newMessages = (snapshot.messages || []).filter((message) => message.id > previousCursor); + ui.snapshot = snapshot; + ui.cursor = snapshot.cursor; + if (newMessages.length) { + ui.activity.push(...newMessages); + ui.activity = ui.activity.slice(-350); + renderActivity(); + for (const message of newMessages) { + handleActivityNotification(message); + } + } + + if (!previous && snapshot.defaultEndpoint) { + byId("debug-host").value = snapshot.defaultEndpoint.host; + byId("debug-port").value = snapshot.defaultEndpoint.port; + } + + renderConnection(snapshot); + renderEmulator(snapshot.emulator || {}); + renderTarget(snapshot); + renderRegisters(snapshot.registers || {}); + renderBreakpoints(snapshot.breakpoints || []); + updateControls(snapshot); +} + +function renderConnection(snapshot) { + const status = byId("connection-status"); + status.classList.toggle("connected", snapshot.connected); + status.classList.toggle("disconnected", !snapshot.connected); + byId("connection-state").textContent = snapshot.connected ? "Connected" : "Offline"; + byId("connection-endpoint").textContent = snapshot.endpoint || "Not connected"; + byId("connection-button-label").textContent = snapshot.connected ? "Disconnect" : "Connect"; + byId("connection-button").classList.toggle("button-danger", snapshot.connected); + byId("connection-button").classList.toggle("button-primary", !snapshot.connected); + byId("protocol-version").textContent = snapshot.protocol || "—"; +} + +function renderEmulator(emulator) { + const statusWrap = byId("emulator-status").closest(".launch-status"); + statusWrap.classList.toggle("running", Boolean(emulator.running)); + statusWrap.classList.toggle("exited", !emulator.running && emulator.exitCode !== null && emulator.exitCode !== undefined); + if (emulator.running) { + byId("emulator-status").textContent = `SharpEmu running · PID ${emulator.pid}`; + byId("emulator-detail").textContent = emulator.eboot || "Launching game executable"; + } else if (emulator.exitCode !== null && emulator.exitCode !== undefined) { + byId("emulator-status").textContent = `SharpEmu exited · code ${emulator.exitCode}`; + byId("emulator-detail").textContent = emulator.eboot || "The launched process has ended."; + } else { + byId("emulator-status").textContent = "No frontend-launched session"; + byId("emulator-detail").textContent = "You can still attach to an emulator that is already running."; + } + if (emulator.eboot && document.activeElement !== byId("eboot-path")) { + byId("eboot-path").value = emulator.eboot; + } + byId("eboot-path").disabled = Boolean(emulator.running); + byId("browse-eboot-button").disabled = Boolean(emulator.running); + byId("launch-button").disabled = Boolean(emulator.running); + byId("stop-emulator-button").disabled = !emulator.running; +} + +function renderTarget(snapshot) { + const state = snapshot.state || "Disconnected"; + const stop = snapshot.lastStop || {}; + const registers = snapshot.registers || {}; + byId("toolbar-target-state").textContent = state; + const badge = byId("target-state-badge"); + badge.textContent = state; + badge.className = `state-badge ${state.toLowerCase()}`; + byId("target-address").textContent = stop.address || registers.rip || "—"; + byId("stop-reason").textContent = stop.reason || "—"; + byId("frame-kind").textContent = stop.frameKind || "—"; + byId("frame-label").textContent = stop.frameLabel || "—"; + byId("stop-result").textContent = stop.result || "—"; + byId("opcode-bytes").textContent = stop.opcodeBytes || "—"; + const detailWrap = byId("stop-detail-wrap"); + detailWrap.classList.toggle("hidden", !stop.detail); + byId("stop-detail").textContent = stop.detail || ""; + renderStallAnalysis(stop.analysis); +} + +function renderStallAnalysis(analysis) { + const panel = byId("stall-analysis"); + if (!analysis) { + panel.classList.add("hidden"); + return; + } + panel.classList.remove("hidden"); + byId("stall-analysis-title").textContent = analysis.title || "Execution stall detected"; + byId("stall-confidence").textContent = `${analysis.confidence || "Medium"} confidence`; + byId("stall-summary").textContent = analysis.summary || "The guest is not making forward progress."; + byId("stall-cause").textContent = analysis.cause || "The repeated path is not changing the state checked by the guest."; + byId("stall-fix").textContent = analysis.fix || "Trace the repeated import and implement its missing state transition."; + + const actions = (analysis.actions || []).map((text) => createTextElement("li", text)); + byId("stall-actions").replaceChildren(...actions); + const evidence = (analysis.evidence || []).map((text) => createTextElement("li", text)); + byId("stall-evidence").replaceChildren(...evidence); +} + +function renderRegisters(registers) { + const grid = byId("register-grid"); + if (!Object.keys(registers).length) { + grid.className = "register-grid empty-state"; + grid.replaceChildren(createTextElement("p", "Pause the target to inspect registers.")); + return; + } + + grid.className = "register-grid"; + const fragment = document.createDocumentFragment(); + for (const name of REGISTER_ORDER) { + const value = registers[name] ?? "—"; + const item = document.createElement("div"); + item.className = `register-item ${["rip", "rflags", "fs_base", "gs_base"].includes(name) ? "special" : ""}`; + const label = createTextElement("span", name); + label.className = "register-name"; + const button = createTextElement("button", value); + button.type = "button"; + button.className = "register-value"; + button.dataset.register = name; + button.title = `Edit ${name}`; + button.addEventListener("click", () => openRegisterDialog(name, value)); + item.append(label, button); + fragment.append(item); + } + grid.replaceChildren(fragment); +} + +function renderBreakpoints(breakpoints) { + byId("breakpoint-count").textContent = breakpoints.length; + const body = byId("breakpoint-table"); + if (!breakpoints.length) { + const row = document.createElement("tr"); + row.className = "empty-row"; + const cell = createTextElement("td", "No breakpoints configured."); + cell.colSpan = 6; + row.append(cell); + body.replaceChildren(row); + return; + } + + const fragment = document.createDocumentFragment(); + for (const breakpoint of breakpoints) { + const row = document.createElement("tr"); + const enabledCell = document.createElement("td"); + const toggle = document.createElement("input"); + toggle.type = "checkbox"; + toggle.className = "switch"; + toggle.checked = Boolean(breakpoint.enabled); + toggle.title = toggle.checked ? "Disable breakpoint" : "Enable breakpoint"; + toggle.addEventListener("change", async () => { + toggle.disabled = true; + try { + await sendCommand({ command: "enable-breakpoint", id: breakpoint.id, enabled: toggle.checked }); + await refreshBreakpoints(); + } catch (error) { + toggle.checked = !toggle.checked; + showToast("Breakpoint update failed", error.message, "error"); + } finally { + toggle.disabled = false; + } + }); + enabledCell.append(toggle); + + const idCell = createTextElement("td", String(breakpoint.id)); + const kindCell = document.createElement("td"); + const kind = createTextElement("span", breakpoint.kind); + kind.className = "kind-pill"; + kindCell.append(kind); + const addressCell = createTextElement("td", breakpoint.address); + const lengthCell = createTextElement("td", String(breakpoint.length)); + const actionCell = document.createElement("td"); + const remove = createTextElement("button", "Remove"); + remove.type = "button"; + remove.className = "row-action"; + remove.addEventListener("click", async () => { + remove.disabled = true; + try { + await sendCommand({ command: "remove-breakpoint", id: breakpoint.id }); + await refreshBreakpoints(); + showToast("Breakpoint removed", `Breakpoint ${breakpoint.id} was removed.`, "success"); + } catch (error) { + showToast("Remove failed", error.message, "error"); + } finally { + remove.disabled = false; + } + }); + actionCell.append(remove); + row.append(enabledCell, idCell, kindCell, addressCell, lengthCell, actionCell); + fragment.append(row); + } + body.replaceChildren(fragment); +} + +function updateControls(snapshot) { + const connected = Boolean(snapshot.connected); + const state = String(snapshot.state || "").toLowerCase(); + const paused = connected && state === "paused"; + const running = connected && state === "running"; + byId("continue-button").disabled = !paused; + byId("step-button").disabled = !paused; + byId("pause-button").disabled = !running; + byId("refresh-button").disabled = !connected; + for (const id of [ + "breakpoint-address", "breakpoint-kind", "breakpoint-length", + "memory-address", "memory-length", "memory-write-address", "memory-write-bytes", + "raw-command-input", + ]) { + byId(id).disabled = !connected || (["memory-address", "memory-length", "memory-write-address", "memory-write-bytes"].includes(id) && !paused); + } + byId("breakpoint-form").querySelector("button").disabled = !connected; + byId("memory-read-form").querySelector("button").disabled = !paused; + byId("memory-write-form").querySelector("button").disabled = !paused; + byId("raw-command-form").querySelector("button").disabled = !connected; +} + +async function sendCommand(request) { + const payload = await api("/api/command", { + method: "POST", + body: JSON.stringify({ request }), + }); + const response = payload.response; + if (!response?.ok) { + throw new Error(response?.error || `${request.command} failed.`); + } + await pollSnapshot(); + return response; +} + +async function refreshTarget() { + if (!ui.snapshot?.connected || ui.refreshing) return; + ui.refreshing = true; + byId("refresh-button").disabled = true; + try { + const status = await sendCommand({ command: "status" }); + if (String(status.data?.state).toLowerCase() === "paused") { + await sendCommand({ command: "registers" }); + } + await sendCommand({ command: "list-breakpoints" }); + } catch (error) { + showToast("Refresh failed", error.message, "error"); + } finally { + ui.refreshing = false; + await pollSnapshot(); + } +} + +async function refreshBreakpoints() { + await sendCommand({ command: "list-breakpoints" }); +} + +async function connectOrDisconnect(event) { + event.preventDefault(); + const button = byId("connection-button"); + button.disabled = true; + try { + if (ui.snapshot?.connected) { + const snapshot = await api("/api/disconnect", { method: "POST", body: "{}" }); + applySnapshot(snapshot); + showToast("Disconnected", "The debugger connection was closed."); + return; + } + const host = byId("debug-host").value.trim(); + const port = Number.parseInt(byId("debug-port").value, 10); + const snapshot = await api("/api/connect", { + method: "POST", + body: JSON.stringify({ host, port }), + }); + applySnapshot(snapshot); + showToast("Debugger connected", `Attached to ${host}:${port}.`, "success"); + await refreshTarget(); + } catch (error) { + showToast("Connection failed", error.message, "error"); + await pollSnapshot(); + } finally { + button.disabled = false; + } +} + +async function browseForEboot() { + const button = byId("browse-eboot-button"); + button.disabled = true; + const previousLabel = button.textContent; + button.textContent = "Choosing…"; + try { + const result = await api("/api/select-eboot", { + method: "POST", + body: JSON.stringify({ initialPath: byId("eboot-path").value.trim() || null }), + }); + if (result.path) { + byId("eboot-path").value = result.path; + byId("eboot-path").focus(); + byId("eboot-path").setSelectionRange(result.path.length, result.path.length); + } + } catch (error) { + showToast("File picker unavailable", error.message, "error"); + } finally { + button.textContent = previousLabel; + button.disabled = Boolean(ui.snapshot?.emulator?.running); + } +} + +async function launchEboot(event) { + event.preventDefault(); + const ebootPath = byId("eboot-path").value.trim(); + const debugPort = Number.parseInt(byId("debug-port").value, 10); + const button = byId("launch-button"); + const previousHtml = button.innerHTML; + button.disabled = true; + button.textContent = "Starting SharpEmu…"; + showToast("Launching SharpEmu", "Waiting for the local debug server to become ready."); + try { + const snapshot = await api("/api/launch", { + method: "POST", + body: JSON.stringify({ ebootPath, debugPort }), + }); + byId("debug-host").value = "127.0.0.1"; + applySnapshot(snapshot); + showToast("Launch complete", "SharpEmu is running and the debugger attached automatically.", "success"); + await refreshTarget(); + } catch (error) { + showToast("Launch failed", error.message, "error"); + await pollSnapshot(); + } finally { + button.innerHTML = previousHtml; + button.disabled = Boolean(ui.snapshot?.emulator?.running); + } +} + +async function stopEmulator() { + if (!window.confirm("Stop the SharpEmu process launched by this frontend?")) return; + const button = byId("stop-emulator-button"); + button.disabled = true; + button.textContent = "Stopping…"; + try { + const snapshot = await api("/api/stop-emulator", { method: "POST", body: "{}" }); + applySnapshot(snapshot); + showToast("SharpEmu stopped", "The frontend-launched emulator process was stopped."); + } catch (error) { + showToast("Stop failed", error.message, "error"); + } finally { + button.textContent = "Stop"; + button.disabled = !ui.snapshot?.emulator?.running; + } +} + +async function executionCommand(command, label) { + try { + await sendCommand({ command }); + showToast(label, command === "pause" ? "Pause requested at the next frame boundary." : `${label} command accepted.`, "success"); + } catch (error) { + showToast(`${label} failed`, error.message, "error"); + } +} + +function openRegisterDialog(name, value) { + if (String(ui.snapshot?.state).toLowerCase() !== "paused") return; + byId("register-name").value = name; + byId("register-value").value = value; + byId("register-dialog").showModal(); + byId("register-value").focus(); + byId("register-value").select(); +} + +async function applyRegister(event) { + event.preventDefault(); + const name = byId("register-name").value; + const value = byId("register-value").value.trim(); + try { + await sendCommand({ command: "set-register", register: name, value }); + await sendCommand({ command: "registers" }); + byId("register-dialog").close(); + showToast("Register updated", `${name.toUpperCase()} is now ${value}.`, "success"); + } catch (error) { + showToast("Register update failed", error.message, "error"); + } +} + +async function addBreakpoint(event) { + event.preventDefault(); + const address = byId("breakpoint-address").value.trim(); + const kind = byId("breakpoint-kind").value; + const length = Number.parseInt(byId("breakpoint-length").value, 10); + try { + await sendCommand({ command: "add-breakpoint", address, kind, length }); + await refreshBreakpoints(); + byId("breakpoint-address").value = ""; + showToast("Breakpoint added", `${kind} breakpoint created at ${address}.`, "success"); + } catch (error) { + showToast("Breakpoint failed", error.message, "error"); + } +} + +async function readMemory(event) { + event.preventDefault(); + const address = byId("memory-address").value.trim(); + const length = Number.parseInt(byId("memory-length").value, 10); + try { + const response = await sendCommand({ command: "read-memory", address, length }); + const data = response.data || {}; + byId("memory-view").textContent = formatHexDump(data.address || address, data.bytes || ""); + byId("memory-meta").textContent = `${data.length || 0} bytes from ${data.address || address}`; + if (!byId("memory-write-address").value) byId("memory-write-address").value = data.address || address; + } catch (error) { + showToast("Memory read failed", error.message, "error"); + } +} + +async function writeMemory(event) { + event.preventDefault(); + const address = byId("memory-write-address").value.trim(); + const bytes = byId("memory-write-bytes").value.replace(/\s+/g, ""); + if (!bytes || bytes.length % 2 || !/^[0-9a-f]+$/i.test(bytes)) { + showToast("Invalid bytes", "Enter an even number of hexadecimal digits.", "error"); + return; + } + try { + const response = await sendCommand({ command: "write-memory", address, bytes }); + showToast("Memory written", `${response.data?.written || bytes.length / 2} bytes written at ${address}.`, "success"); + if (byId("memory-address").value.trim() === address) { + byId("memory-read-form").requestSubmit(); + } + } catch (error) { + showToast("Memory write failed", error.message, "error"); + } +} + +function formatHexDump(startAddress, hex) { + const clean = String(hex).replace(/\s+/g, ""); + if (!clean) return "No bytes returned."; + const bytes = clean.match(/.{1,2}/g) || []; + let base = 0n; + try { base = BigInt(startAddress); } catch { /* display from zero */ } + const lines = []; + for (let offset = 0; offset < bytes.length; offset += 16) { + const chunk = bytes.slice(offset, offset + 16); + const address = `0x${(base + BigInt(offset)).toString(16).toUpperCase().padStart(16, "0")}`; + const left = chunk.slice(0, 8).join(" ").padEnd(23, " "); + const right = chunk.slice(8).join(" ").padEnd(23, " "); + const ascii = chunk.map((value) => { + const code = Number.parseInt(value, 16); + return code >= 32 && code <= 126 ? String.fromCharCode(code) : "."; + }).join(""); + lines.push(`${address} ${left} ${right} |${ascii.padEnd(16, " ")}|`); + } + return lines.join("\n"); +} + +async function sendRawCommand(event) { + event.preventDefault(); + try { + const request = JSON.parse(byId("raw-command-input").value); + if (!request || Array.isArray(request) || typeof request !== "object") throw new Error("Request must be a JSON object."); + await sendCommand(request); + showToast("Raw request sent", request.command || "Request completed.", "success"); + } catch (error) { + showToast("Raw request failed", error.message, "error"); + } +} + +function renderActivity() { + const stream = byId("activity-stream"); + const query = byId("activity-search").value.trim().toLowerCase(); + const messages = query + ? ui.activity.filter((item) => `${item.kind} ${item.summary} ${JSON.stringify(item.payload || "")}`.toLowerCase().includes(query)) + : ui.activity; + if (!messages.length) { + const empty = createTextElement("div", query ? "No activity matches this filter." : "Protocol events and commands will appear here."); + empty.className = "activity-empty"; + stream.replaceChildren(empty); + return; + } + + const nearBottom = stream.scrollHeight - stream.scrollTop - stream.clientHeight < 70; + const fragment = document.createDocumentFragment(); + for (const item of messages.slice(-250)) { + const row = document.createElement("div"); + row.className = `activity-row ${item.kind}`; + const timestamp = createTextElement("span", item.time); + timestamp.className = "activity-time"; + const kind = createTextElement("span", item.kind); + kind.className = "activity-kind"; + const summary = document.createElement("div"); + summary.className = "activity-summary"; + summary.append(createTextElement("span", item.summary)); + if (item.payload !== undefined) { + const details = document.createElement("details"); + details.append(createTextElement("summary", "View payload")); + const pre = createTextElement("pre", JSON.stringify(item.payload, null, 2)); + details.append(pre); + summary.append(details); + } + row.append(timestamp, kind, summary); + fragment.append(row); + } + stream.replaceChildren(fragment); + if (nearBottom || !query) stream.scrollTop = stream.scrollHeight; +} + +function handleActivityNotification(message) { + if (message.kind === "event" && message.summary === "stopped") { + const reason = message.payload?.reason || "Target stopped"; + showToast("Target paused", `${reason} at ${message.payload?.address || "unknown address"}.`); + } else if (message.kind === "event" && message.summary === "terminated") { + showToast("Target terminated", "The emulation run has completed."); + } else if (message.kind === "emulator" && message.summary.startsWith("Emulator exited")) { + showToast("Emulator exited", message.summary); + } +} + +function showToast(title, message, tone = "") { + const toast = document.createElement("div"); + toast.className = `toast ${tone}`; + toast.append(createTextElement("strong", title), createTextElement("span", message)); + byId("toast-stack").append(toast); + window.setTimeout(() => toast.remove(), 4300); +} + +function createTextElement(tag, text) { + const element = document.createElement(tag); + element.textContent = text == null ? "" : String(text); + return element; +} + +function bindEvents() { + byId("connection-form").addEventListener("submit", connectOrDisconnect); + byId("launch-form").addEventListener("submit", launchEboot); + byId("browse-eboot-button").addEventListener("click", browseForEboot); + byId("stop-emulator-button").addEventListener("click", stopEmulator); + byId("continue-button").addEventListener("click", () => executionCommand("continue", "Continue")); + byId("pause-button").addEventListener("click", () => executionCommand("pause", "Pause")); + byId("step-button").addEventListener("click", () => executionCommand("step", "Step")); + byId("refresh-button").addEventListener("click", refreshTarget); + byId("breakpoint-form").addEventListener("submit", addBreakpoint); + byId("memory-read-form").addEventListener("submit", readMemory); + byId("memory-write-form").addEventListener("submit", writeMemory); + byId("raw-command-form").addEventListener("submit", sendRawCommand); + byId("register-form").addEventListener("submit", applyRegister); + byId("register-dialog-close").addEventListener("click", () => byId("register-dialog").close()); + byId("register-cancel").addEventListener("click", () => byId("register-dialog").close()); + byId("activity-search").addEventListener("input", renderActivity); + byId("clear-activity").addEventListener("click", () => { + ui.activity = []; + renderActivity(); + }); + document.addEventListener("keydown", (event) => { + if (["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement?.tagName)) return; + if (event.key === "F5") { + event.preventDefault(); + if (!byId("continue-button").disabled) executionCommand("continue", "Continue"); + } else if (event.key === "F6") { + event.preventDefault(); + if (!byId("pause-button").disabled) executionCommand("pause", "Pause"); + } else if (event.key === "F10") { + event.preventDefault(); + if (!byId("step-button").disabled) executionCommand("step", "Step"); + } + }); +} + +async function initialize() { + bindEvents(); + await pollSnapshot(); + window.setInterval(pollSnapshot, 700); + window.setInterval(() => { + if (ui.snapshot?.connected) refreshTarget(); + }, 7000); +} + +initialize(); diff --git a/tools/SharpEmu.DebuggerFrontend/web/index.html b/tools/SharpEmu.DebuggerFrontend/web/index.html new file mode 100644 index 0000000..1559644 --- /dev/null +++ b/tools/SharpEmu.DebuggerFrontend/web/index.html @@ -0,0 +1,302 @@ + + + + + + + + SharpEmu Debugger + + + + +
+
+ +
+
+ +
+
LIVE DEVELOPMENT TOOLS
+

SharpEmu Debugger

+
+
+ +
+ + + + +
+ +
+ +
+ Offline + Not connected +
+
+
+ + + +
+
+
+ +
+
LOCAL SESSION
+

Launch and attach

+

Choose a game executable and the frontend will start SharpEmu with its debugger enabled.

+
+
+
+ + + + +
+
+ +
+ No frontend-launched session + You can still attach to an emulator that is already running. +
+
+
+ +
+
+
+
+
SESSION
+

Target overview

+
+ Disconnected +
+
+ Instruction pointer + +
+
+
Stop reason
+
Frame
+
Image / module
+
Result
+
Opcode bytes
+
+ + +
+ +
+
+
+
CPU STATE
+

Registers

+
+ Select a value to edit +
+
+

Pause the target to inspect registers.

+
+
+ +
+
+
+
EXECUTION
+

Breakpoints

+
+ 0 +
+
+ + + + +
+
+ + + + + +
OnIDKindAddressLength
No breakpoints configured.
+
+

Execute breakpoints are active at frame boundaries. Data watchpoints are protocol-ready and await CPU backend hooks.

+
+ +
+
+
+
GUEST MEMORY
+

Memory inspector

+
+ Up to 64 KiB +
+
+ + + +
+
No memory loaded.
+
+ + + +
+
+ +
+
+
+
PROTOCOL
+

Activity

+
+
+ + +
+
+
+
Protocol events and commands will appear here.
+
+
+ Advanced: send raw JSON request +
+ + +
+
+
+
+
+ +
+ SharpEmu JSON-lines protocol + F5 Continue F6 Pause F10 Step +
+ + +
+ + + + +
+
+ +
+ + + diff --git a/tools/SharpEmu.DebuggerFrontend/web/sharpemu-logo.webp b/tools/SharpEmu.DebuggerFrontend/web/sharpemu-logo.webp new file mode 100644 index 0000000000000000000000000000000000000000..407397ddeb8771c6c63fcb4640058adf289b3a18 GIT binary patch literal 13388 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 iC)2Fax span { + display: block; + margin-bottom: 3px; + color: var(--text-dim); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.connection-field input { + width: 148px; + padding: 0; + border: 0; + outline: 0; + color: var(--text); + background: transparent; + font: 13px var(--mono); +} +.connection-field.port-field input { width: 62px; } +.connection-divider { align-self: center; color: var(--text-dim); font: 15px var(--mono); } + +input, select, textarea { + width: 100%; + border: 1px solid var(--border-strong); + border-radius: var(--radius-small); + outline: none; + color: var(--text); + background: var(--surface-input); + transition: border-color 140ms, box-shadow 140ms, background 140ms; +} +input, select { min-height: 38px; padding: 8px 11px; } +textarea { padding: 12px; resize: vertical; } +input:focus, select:focus, textarea:focus { + border-color: rgba(138, 125, 255, 0.7); + box-shadow: 0 0 0 3px rgba(138, 125, 255, 0.11); + background: #0f1420; +} +input:disabled, select:disabled, textarea:disabled { opacity: 0.52; cursor: not-allowed; } + +.button, .tool-button, .icon-button { + border: 0; + cursor: pointer; + transition: transform 130ms, border-color 130ms, background 130ms, opacity 130ms; +} +.button:hover:not(:disabled), .tool-button:hover:not(:disabled), .icon-button:hover:not(:disabled) { transform: translateY(-1px); } +.button:active:not(:disabled), .tool-button:active:not(:disabled) { transform: translateY(0); } +.button:disabled, .tool-button:disabled, .icon-button:disabled { opacity: 0.38; cursor: not-allowed; } + +.button { + min-height: 38px; + padding: 8px 15px; + border-radius: 8px; + font-weight: 680; + white-space: nowrap; +} +.button-primary { color: #fff; background: linear-gradient(135deg, #7568e8, #9184ff); box-shadow: 0 8px 24px rgba(103, 86, 226, 0.22); } +.button-secondary { border: 1px solid var(--border-strong); background: var(--surface-raised); } +.button-danger { border: 1px solid rgba(255, 111, 131, 0.3); color: #ffadba; background: rgba(255, 111, 131, 0.1); } +.button-ghost { border: 1px solid var(--border); background: transparent; } +.button-icon { margin-right: 5px; } + +.connection-status { justify-self: end; display: flex; align-items: center; gap: 10px; min-width: 190px; } +.connection-status strong { display: block; font-size: 13px; } +.connection-status small { display: block; margin-top: 1px; color: var(--text-dim); font: 11px var(--mono); } +.status-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--text-dim); box-shadow: 0 0 0 5px rgba(115, 125, 146, 0.09); } +.connection-status.connected .status-dot { background: var(--success); box-shadow: 0 0 0 5px rgba(72, 213, 151, 0.1), 0 0 14px rgba(72, 213, 151, 0.55); } + +.execution-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 20px; + min-height: 58px; + padding: 9px clamp(20px, 3vw, 52px); + border-bottom: 1px solid var(--border); + background: rgba(13, 17, 25, 0.84); +} +.toolbar-group { display: flex; align-items: center; gap: 7px; } +.tool-button { + display: flex; + align-items: center; + gap: 8px; + min-height: 37px; + padding: 7px 11px; + border: 1px solid transparent; + border-radius: 8px; + color: var(--text-soft); + background: transparent; + font-weight: 640; +} +.tool-button:hover:not(:disabled) { border-color: var(--border); color: var(--text); background: var(--surface-raised); } +.tool-button.accent:not(:disabled) { color: #c9c3ff; background: var(--accent-muted); } +.tool-button.compact { padding-inline: 10px; } +.tool-icon { width: 18px; color: var(--accent-bright); font-size: 16px; text-align: center; } +.toolbar-separator { width: 1px; height: 24px; margin: 0 5px; background: var(--border); } +kbd { padding: 2px 5px; border: 1px solid var(--border-strong); border-bottom-width: 2px; border-radius: 4px; color: var(--text-dim); background: #0a0e15; font: 9px var(--mono); } +.target-pill { display: flex; align-items: center; gap: 9px; color: var(--text-dim); font-size: 12px; } +.target-pill strong { color: var(--text-soft); font-family: var(--mono); } + +.workspace { width: min(1800px, 100%); margin: 0 auto; padding: 22px clamp(16px, 2.5vw, 42px) 34px; } +.dashboard-grid { display: grid; grid-template-columns: minmax(330px, 0.8fr) minmax(540px, 1.5fr); gap: 16px; } + +.launch-panel { + display: grid; + grid-template-columns: minmax(240px, 0.75fr) minmax(520px, 1.5fr) auto; + align-items: end; + gap: 22px; + margin-bottom: 16px; + padding: 17px 19px; + border: 1px solid rgba(138, 125, 255, 0.25); + border-radius: var(--radius); + background: + linear-gradient(105deg, rgba(138, 125, 255, 0.1), transparent 38%), + linear-gradient(145deg, rgba(20, 25, 37, 0.94), rgba(14, 18, 27, 0.94)); + box-shadow: var(--shadow), inset 0 1px rgba(255, 255, 255, 0.035); +} +.launch-heading { display: flex; align-items: center; gap: 13px; min-width: 0; } +.launch-heading p { margin-top: 4px; color: var(--text-dim); font-size: 11px; } +.launch-icon { + display: grid; + flex: 0 0 auto; + place-items: center; + width: 39px; + height: 39px; + border: 1px solid rgba(138, 125, 255, 0.35); + border-radius: 10px; + color: var(--accent-bright); + background: var(--accent-muted); + font-size: 18px; +} +.launch-form { display: flex; align-items: flex-end; gap: 8px; min-width: 0; } +.launch-path-field { flex: 1; min-width: 180px; } +.launch-status { display: flex; align-items: center; gap: 10px; min-width: 210px; padding-bottom: 3px; } +.launch-status strong { display: block; font-size: 12px; } +.launch-status small { display: block; overflow: hidden; max-width: 260px; margin-top: 2px; color: var(--text-dim); font: 10px var(--mono); text-overflow: ellipsis; white-space: nowrap; } +.launch-status.running .status-dot { background: var(--success); box-shadow: 0 0 0 5px rgba(72, 213, 151, 0.1), 0 0 14px rgba(72, 213, 151, 0.5); } +.launch-status.exited .status-dot { background: var(--danger); box-shadow: 0 0 0 5px rgba(255, 111, 131, 0.09); } + +.panel { + min-width: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + background: linear-gradient(145deg, rgba(20, 25, 37, 0.92), rgba(14, 18, 27, 0.92)); + box-shadow: var(--shadow), inset 0 1px rgba(255, 255, 255, 0.025); + backdrop-filter: blur(18px); + overflow: hidden; +} +.panel-header { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 18px 20px 15px; border-bottom: 1px solid var(--border); } +.panel-hint { color: var(--text-dim); font-size: 11px; } +.panel-note { padding: 11px 18px 15px; color: var(--text-dim); font-size: 11px; } + +.target-panel { grid-column: 1; } +.registers-panel { grid-column: 2; grid-row: span 2; } +.breakpoints-panel { grid-column: 1; } +.memory-panel, .activity-panel { grid-column: 1 / -1; } + +.state-badge, .count-badge { + padding: 5px 9px; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--text-soft); + background: rgba(255,255,255,0.025); + font: 600 10px var(--mono); + text-transform: uppercase; +} +.state-badge.paused { border-color: rgba(255, 189, 98, 0.25); color: var(--warning); background: rgba(255, 189, 98, 0.08); } +.state-badge.running { border-color: rgba(72, 213, 151, 0.25); color: var(--success); background: rgba(72, 213, 151, 0.08); } +.state-badge.terminated { border-color: rgba(255, 111, 131, 0.25); color: var(--danger); background: rgba(255, 111, 131, 0.08); } + +.target-address { padding: 21px 20px 19px; border-bottom: 1px solid var(--border); background: linear-gradient(90deg, rgba(138, 125, 255, 0.075), transparent); } +.target-address span { display: block; margin-bottom: 5px; color: var(--text-dim); font-size: 11px; } +.target-address strong { color: #d9d5ff; font: 650 clamp(17px, 1.5vw, 22px) var(--mono); letter-spacing: -0.035em; } +.fact-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0; margin: 0; } +.fact-grid div { min-width: 0; padding: 13px 20px; border-bottom: 1px solid var(--border); } +.fact-grid div:nth-child(odd):not(.wide) { border-right: 1px solid var(--border); } +.fact-grid .wide { grid-column: 1 / -1; } +.fact-grid dt { color: var(--text-dim); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; } +.fact-grid dd { overflow: hidden; margin: 4px 0 0; color: var(--text-soft); font: 12px var(--mono); text-overflow: ellipsis; white-space: nowrap; } +.stop-detail { padding: 13px 20px 17px; color: var(--text-soft); background: rgba(255, 111, 131, 0.045); } +.stop-detail span { color: var(--danger); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; } +.stop-detail p { margin-top: 5px; font-size: 12px; } +.stall-analysis { border-top: 1px solid rgba(255, 189, 98, 0.2); background: linear-gradient(145deg, rgba(255, 189, 98, 0.07), rgba(138, 125, 255, 0.035)); } +.stall-analysis-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 16px 20px 12px; } +.stall-analysis h3 { margin: 3px 0 0; color: #fff1da; font-size: 15px; letter-spacing: -0.015em; } +.confidence-badge { flex: 0 0 auto; padding: 4px 8px; border: 1px solid rgba(255, 189, 98, 0.25); border-radius: 999px; color: var(--warning); background: rgba(255, 189, 98, 0.075); font: 600 9px var(--mono); text-transform: uppercase; } +.stall-summary { padding: 0 20px 14px; color: var(--text-soft); font-size: 12px; } +.diagnosis-block { margin: 0 20px 10px; padding: 11px 12px; border: 1px solid var(--border); border-radius: 9px; background: rgba(7, 10, 16, 0.34); } +.diagnosis-block.fix-block { border-color: rgba(72, 213, 151, 0.2); background: rgba(72, 213, 151, 0.045); } +.diagnosis-label { display: block; margin-bottom: 5px; color: var(--text-dim); font-size: 9px; font-weight: 750; letter-spacing: 0.09em; text-transform: uppercase; } +.cause-block .diagnosis-label { color: var(--warning); } +.fix-block .diagnosis-label { color: var(--success); } +.diagnosis-block p { color: var(--text-soft); font-size: 11px; line-height: 1.55; } +.diagnosis-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 4px 20px 12px; } +.diagnosis-columns ol, .diagnosis-columns ul { margin: 0; padding-left: 18px; color: var(--text-soft); font-size: 10px; line-height: 1.55; } +.diagnosis-columns li + li { margin-top: 5px; } +.diagnosis-columns li::marker { color: var(--accent-bright); font-family: var(--mono); } +.diagnosis-disclaimer { padding: 10px 20px 14px; border-top: 1px solid var(--border); color: var(--text-dim); font-size: 9px; } +.hidden { display: none !important; } + +.register-grid { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)); gap: 1px; background: var(--border); } +.register-grid.empty-state { grid-template-columns: 1fr; } +.register-item { min-width: 0; padding: 12px 14px; background: rgba(14, 18, 27, 0.97); } +.register-item.special { background: linear-gradient(135deg, rgba(138, 125, 255, 0.09), rgba(14, 18, 27, 0.97)); } +.register-name { display: block; margin-bottom: 4px; color: var(--text-dim); font: 700 10px var(--mono); text-transform: uppercase; } +.register-value { + display: block; + overflow: hidden; + width: 100%; + padding: 0; + border: 0; + color: #d5d9e5; + background: transparent; + font: 11px var(--mono); + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} +.register-value:hover { color: var(--accent-bright); } +.empty-state { display: grid; place-items: center; min-height: 210px; color: var(--text-dim); background: transparent; } + +.inline-form, .write-form { display: flex; align-items: flex-start; gap: 9px; padding: 14px 16px; border-bottom: 1px solid var(--border); } +.inline-form label, .write-form label, .modal label { min-width: 0; } +.grow { flex: 1; } +.length-field { width: 82px; } +.memory-length { width: 90px; } +.align-end { align-self: flex-end; } + +.table-wrap { overflow-x: auto; } +.data-table { width: 100%; border-collapse: collapse; } +.data-table th { padding: 9px 11px; border-bottom: 1px solid var(--border); color: var(--text-dim); font-size: 9px; letter-spacing: 0.08em; text-align: left; text-transform: uppercase; } +.data-table td { padding: 10px 11px; border-bottom: 1px solid rgba(157, 172, 205, 0.08); color: var(--text-soft); font: 11px var(--mono); } +.data-table tr:last-child td { border-bottom: 0; } +.data-table tbody tr:hover:not(.empty-row) { background: rgba(138, 125, 255, 0.045); } +.empty-row td { padding: 25px; color: var(--text-dim); font-family: var(--sans); text-align: center; } +.kind-pill { padding: 3px 6px; border-radius: 5px; color: #bbb5ff; background: var(--accent-muted); font: 9px var(--mono); text-transform: uppercase; } +.row-action { padding: 3px 7px; border: 1px solid transparent; border-radius: 5px; color: var(--text-dim); background: transparent; cursor: pointer; } +.row-action:hover { border-color: rgba(255, 111, 131, 0.25); color: var(--danger); background: rgba(255, 111, 131, 0.07); } +.switch { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; } + +.memory-controls { padding-bottom: 12px; } +.memory-view { + min-height: 240px; + max-height: 430px; + margin: 0; + padding: 17px 20px; + overflow: auto; + border-bottom: 1px solid var(--border); + outline: none; + color: #bcc6da; + background: #090d14; + font: 12px/1.65 var(--mono); + tab-size: 4; +} +.memory-view:focus { box-shadow: inset 0 0 0 1px rgba(138, 125, 255, 0.38); } +.write-form { border-bottom: 0; } + +.activity-header { align-items: flex-end; } +.activity-actions { display: flex; align-items: center; gap: 8px; } +.search-box { position: relative; display: flex; align-items: center; } +.search-box > span { position: absolute; left: 10px; color: var(--text-dim); } +.search-box input { min-height: 34px; padding-left: 29px; } +.icon-button { min-height: 32px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 7px; color: var(--text-soft); background: transparent; font-size: 12px; } +.activity-stream { min-height: 180px; max-height: 360px; overflow: auto; background: #0a0e15; } +.activity-empty { display: grid; place-items: center; min-height: 180px; color: var(--text-dim); } +.activity-row { display: grid; grid-template-columns: 66px 76px 1fr; align-items: start; gap: 10px; padding: 9px 16px; border-bottom: 1px solid rgba(157, 172, 205, 0.07); } +.activity-row:hover { background: rgba(255, 255, 255, 0.018); } +.activity-time { padding-top: 2px; color: var(--text-dim); font: 10px var(--mono); } +.activity-kind { width: max-content; padding: 2px 6px; border-radius: 5px; color: var(--text-soft); background: rgba(170, 179, 198, 0.09); font: 700 9px var(--mono); text-transform: uppercase; } +.activity-row.event .activity-kind { color: var(--cyan); background: var(--cyan-muted); } +.activity-row.request .activity-kind { color: var(--accent-bright); background: var(--accent-muted); } +.activity-row.error .activity-kind { color: var(--danger); background: rgba(255, 111, 131, 0.1); } +.activity-row.response .activity-kind { color: var(--success); background: rgba(72, 213, 151, 0.09); } +.activity-row.emulator .activity-kind { color: var(--warning); background: rgba(255, 189, 98, 0.09); } +.activity-summary { min-width: 0; color: var(--text-soft); font: 11px var(--mono); } +.activity-summary details { margin-top: 3px; } +.activity-summary summary { color: var(--text-dim); cursor: pointer; font: 10px var(--sans); } +.activity-summary pre { overflow: auto; margin: 7px 0 1px; padding: 9px; border: 1px solid var(--border); border-radius: 7px; color: #aeb8cd; background: #080b11; font: 10px/1.5 var(--mono); } +.raw-command { border-top: 1px solid var(--border); color: var(--text-dim); } +.raw-command > summary { padding: 11px 16px; cursor: pointer; font-size: 11px; } +.raw-command form { display: flex; gap: 9px; padding: 0 16px 15px; } +.raw-command textarea { min-height: 62px; font: 11px/1.5 var(--mono); } +.raw-command .button { align-self: flex-end; } + +.app-footer { display: flex; justify-content: space-between; gap: 20px; padding: 14px clamp(20px, 3vw, 52px) 22px; color: var(--text-dim); font-size: 11px; } +.app-footer strong { color: var(--text-soft); font-family: var(--mono); } +.footer-shortcuts { display: flex; gap: 7px; align-items: center; } + +.modal { + width: min(430px, calc(100vw - 32px)); + padding: 0; + border: 1px solid var(--border-strong); + border-radius: 15px; + color: var(--text); + background: var(--surface-solid); + box-shadow: 0 30px 90px rgba(0,0,0,0.62); +} +.modal::backdrop { background: rgba(3, 5, 9, 0.74); backdrop-filter: blur(5px); } +.modal form { display: grid; gap: 15px; padding: 20px; } +.modal-header { display: flex; justify-content: space-between; align-items: start; } +.close-button { border: 0; font-size: 20px; } +.modal-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 4px; } + +.toast-stack { position: fixed; right: 18px; bottom: 18px; z-index: 60; display: grid; gap: 8px; width: min(380px, calc(100vw - 36px)); pointer-events: none; } +.toast { padding: 12px 14px; border: 1px solid var(--border-strong); border-left: 3px solid var(--accent); border-radius: 9px; color: var(--text-soft); background: rgba(20, 25, 37, 0.96); box-shadow: 0 14px 45px rgba(0,0,0,0.4); animation: toast-in 180ms ease-out; } +.toast.error { border-left-color: var(--danger); } +.toast.success { border-left-color: var(--success); } +.toast strong { display: block; margin-bottom: 2px; color: var(--text); font-size: 12px; } +@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } } + +@media (max-width: 1100px) { + .app-header { grid-template-columns: 1fr auto; } + .connection-bar { grid-column: 1 / -1; grid-row: 2; justify-self: stretch; } + .connection-field:first-child { flex: 1; } + .connection-field input { width: 100%; } + .connection-status { grid-column: 2; grid-row: 1; } + .dashboard-grid { grid-template-columns: 1fr; } + .launch-panel { grid-template-columns: 1fr; align-items: stretch; } + .launch-form { flex-wrap: wrap; } + .launch-status { min-width: 0; } + .target-panel, .registers-panel, .breakpoints-panel, .memory-panel, .activity-panel { grid-column: 1; grid-row: auto; } +} + +@media (max-width: 680px) { + .app-header { position: static; padding: 16px; gap: 16px; } + .brand-mark { width: 40px; height: 40px; } + .connection-status { min-width: 0; } + .connection-status small { display: none; } + .connection-bar { flex-wrap: wrap; } + .connection-field:first-child { min-width: 150px; } + .connection-bar .button { flex: 1; } + .execution-toolbar { align-items: stretch; padding: 8px 12px; overflow-x: auto; } + .target-pill { display: none; } + .tool-button kbd { display: none; } + .workspace { padding: 12px; } + .launch-panel { padding: 15px; gap: 15px; } + .launch-form { align-items: stretch; } + .launch-path-field { flex-basis: 100%; } + .launch-form .button { flex: 1 1 auto; } + .register-grid { grid-template-columns: repeat(2, minmax(130px, 1fr)); } + .inline-form, .write-form { flex-wrap: wrap; } + .inline-form label, .write-form label { flex: 1 1 140px; } + .align-end { flex: 1 1 auto; } + .activity-header { align-items: stretch; flex-direction: column; } + .activity-actions, .search-box { width: 100%; } + .search-box { flex: 1; } + .activity-row { grid-template-columns: 58px 64px 1fr; padding-inline: 10px; gap: 7px; } + .diagnosis-columns { grid-template-columns: 1fr; } + .raw-command form { flex-direction: column; } + .app-footer { padding-inline: 16px; } + .footer-shortcuts { display: none; } +}