Add live debugger frontend and mutex stall recovery (#383)

This commit is contained in:
Spooks
2026-07-17 22:41:07 -06:00
committed by GitHub
parent 1c8cdd6537
commit 13269797bf
57 changed files with 6534 additions and 22 deletions
+89
View File
@@ -0,0 +1,89 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
# 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.
File diff suppressed because it is too large Load Diff
+8
View File
@@ -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" "$@"
@@ -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 <span>Debugger</span>", 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()
+626
View File
@@ -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();
@@ -0,0 +1,302 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>SharpEmu Debugger</title>
<link rel="icon" type="image/webp" href="/sharpemu-logo.webp">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="ambient ambient-one"></div>
<div class="ambient ambient-two"></div>
<header class="app-header">
<div class="brand">
<div class="brand-mark" aria-hidden="true"><img src="/sharpemu-logo.webp" alt=""></div>
<div>
<div class="eyebrow">LIVE DEVELOPMENT TOOLS</div>
<h1>SharpEmu <span>Debugger</span></h1>
</div>
</div>
<form id="connection-form" class="connection-bar">
<label class="connection-field">
<span>Host</span>
<input id="debug-host" name="host" autocomplete="off" value="127.0.0.1" aria-label="Debugger host">
</label>
<div class="connection-divider" aria-hidden="true">:</div>
<label class="connection-field port-field">
<span>Port</span>
<input id="debug-port" name="port" type="number" min="1" max="65535" value="5714" aria-label="Debugger port">
</label>
<button id="connection-button" class="button button-primary" type="submit">
<span class="button-icon" aria-hidden="true"></span>
<span id="connection-button-label">Connect</span>
</button>
</form>
<div id="connection-status" class="connection-status disconnected" aria-live="polite">
<span class="status-dot"></span>
<div>
<strong id="connection-state">Offline</strong>
<small id="connection-endpoint">Not connected</small>
</div>
</div>
</header>
<nav class="execution-toolbar" aria-label="Execution controls">
<div class="toolbar-group">
<button id="continue-button" class="tool-button accent" type="button" title="Continue (F5)">
<span class="tool-icon"></span><span>Continue</span><kbd>F5</kbd>
</button>
<button id="pause-button" class="tool-button" type="button" title="Pause (F6)">
<span class="tool-icon"></span><span>Pause</span><kbd>F6</kbd>
</button>
<button id="step-button" class="tool-button" type="button" title="Step frame (F10)">
<span class="tool-icon"></span><span>Step frame</span><kbd>F10</kbd>
</button>
<div class="toolbar-separator"></div>
<button id="refresh-button" class="tool-button compact" type="button" title="Refresh target state">
<span class="tool-icon"></span><span>Refresh</span>
</button>
</div>
<div class="target-pill">
<span>Target</span>
<strong id="toolbar-target-state">Disconnected</strong>
</div>
</nav>
<main class="workspace">
<section class="launch-panel" aria-labelledby="launch-title">
<div class="launch-heading">
<div class="launch-icon" aria-hidden="true"></div>
<div>
<div class="eyebrow">LOCAL SESSION</div>
<h2 id="launch-title">Launch and attach</h2>
<p>Choose a game executable and the frontend will start SharpEmu with its debugger enabled.</p>
</div>
</div>
<form id="launch-form" class="launch-form">
<label class="launch-path-field">
<span>Game executable</span>
<input id="eboot-path" placeholder="/path/to/game/eboot.bin" autocomplete="off" required>
</label>
<button id="browse-eboot-button" class="button button-secondary align-end" type="button">Browse…</button>
<button id="launch-button" class="button button-primary align-end" type="submit">
<span class="button-icon" aria-hidden="true"></span>Launch &amp; attach
</button>
<button id="stop-emulator-button" class="button button-danger align-end" type="button" disabled>Stop</button>
</form>
<div class="launch-status">
<span id="emulator-status-dot" class="status-dot"></span>
<div>
<strong id="emulator-status">No frontend-launched session</strong>
<small id="emulator-detail">You can still attach to an emulator that is already running.</small>
</div>
</div>
</section>
<section class="dashboard-grid">
<article class="panel target-panel">
<div class="panel-header">
<div>
<div class="eyebrow">SESSION</div>
<h2>Target overview</h2>
</div>
<span id="target-state-badge" class="state-badge disconnected">Disconnected</span>
</div>
<div class="target-address">
<span>Instruction pointer</span>
<strong id="target-address"></strong>
</div>
<dl class="fact-grid">
<div><dt>Stop reason</dt><dd id="stop-reason"></dd></div>
<div><dt>Frame</dt><dd id="frame-kind"></dd></div>
<div class="wide"><dt>Image / module</dt><dd id="frame-label"></dd></div>
<div><dt>Result</dt><dd id="stop-result"></dd></div>
<div><dt>Opcode bytes</dt><dd id="opcode-bytes"></dd></div>
</dl>
<div id="stop-detail-wrap" class="stop-detail hidden">
<span>Detail</span>
<p id="stop-detail"></p>
</div>
<div id="stall-analysis" class="stall-analysis hidden">
<div class="stall-analysis-header">
<div>
<div class="eyebrow">STALL DIAGNOSIS</div>
<h3 id="stall-analysis-title">Execution is not making progress</h3>
</div>
<span id="stall-confidence" class="confidence-badge">Medium confidence</span>
</div>
<p id="stall-summary" class="stall-summary"></p>
<div class="diagnosis-block cause-block">
<span class="diagnosis-label">Why this happens</span>
<p id="stall-cause"></p>
</div>
<div class="diagnosis-block fix-block">
<span class="diagnosis-label">Most likely fix</span>
<p id="stall-fix"></p>
</div>
<div class="diagnosis-columns">
<div>
<span class="diagnosis-label">Recommended checks</span>
<ol id="stall-actions"></ol>
</div>
<div>
<span class="diagnosis-label">Evidence</span>
<ul id="stall-evidence"></ul>
</div>
</div>
<p class="diagnosis-disclaimer">Heuristic diagnosis based on the detected loop and resolved HLE import. Confirm with tracing before changing synchronization behavior.</p>
</div>
</article>
<article class="panel registers-panel">
<div class="panel-header">
<div>
<div class="eyebrow">CPU STATE</div>
<h2>Registers</h2>
</div>
<span class="panel-hint">Select a value to edit</span>
</div>
<div id="register-grid" class="register-grid empty-state">
<p>Pause the target to inspect registers.</p>
</div>
</article>
<article class="panel breakpoints-panel">
<div class="panel-header">
<div>
<div class="eyebrow">EXECUTION</div>
<h2>Breakpoints</h2>
</div>
<span id="breakpoint-count" class="count-badge">0</span>
</div>
<form id="breakpoint-form" class="inline-form breakpoint-form">
<label class="grow">
<span>Address</span>
<input id="breakpoint-address" placeholder="0x00000008801234A0" autocomplete="off" required>
</label>
<label>
<span>Kind</span>
<select id="breakpoint-kind">
<option value="execute">Execute</option>
<option value="readwatch">Read watch</option>
<option value="writewatch">Write watch</option>
<option value="accesswatch">Access watch</option>
</select>
</label>
<label class="length-field">
<span>Length</span>
<input id="breakpoint-length" type="number" min="1" value="1">
</label>
<button class="button button-secondary align-end" type="submit">Add</button>
</form>
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>On</th><th>ID</th><th>Kind</th><th>Address</th><th>Length</th><th></th></tr></thead>
<tbody id="breakpoint-table">
<tr class="empty-row"><td colspan="6">No breakpoints configured.</td></tr>
</tbody>
</table>
</div>
<p class="panel-note">Execute breakpoints are active at frame boundaries. Data watchpoints are protocol-ready and await CPU backend hooks.</p>
</article>
<article class="panel memory-panel">
<div class="panel-header">
<div>
<div class="eyebrow">GUEST MEMORY</div>
<h2>Memory inspector</h2>
</div>
<span id="memory-meta" class="panel-hint">Up to 64 KiB</span>
</div>
<form id="memory-read-form" class="inline-form memory-controls">
<label class="grow">
<span>Address</span>
<input id="memory-address" placeholder="0x0000000880200000" autocomplete="off" required>
</label>
<label class="memory-length">
<span>Bytes</span>
<input id="memory-length" type="number" min="1" max="65536" value="256" required>
</label>
<button class="button button-secondary align-end" type="submit">Read memory</button>
</form>
<pre id="memory-view" class="memory-view" tabindex="0">No memory loaded.</pre>
<form id="memory-write-form" class="write-form">
<label>
<span>Write address</span>
<input id="memory-write-address" placeholder="0x0000000880200000" autocomplete="off" required>
</label>
<label class="grow">
<span>Hex bytes</span>
<input id="memory-write-bytes" placeholder="90 90 CC" autocomplete="off" required>
</label>
<button class="button button-danger align-end" type="submit">Write</button>
</form>
</article>
<article class="panel activity-panel">
<div class="panel-header activity-header">
<div>
<div class="eyebrow">PROTOCOL</div>
<h2>Activity</h2>
</div>
<div class="activity-actions">
<label class="search-box">
<span aria-hidden="true"></span>
<input id="activity-search" placeholder="Filter activity" aria-label="Filter activity">
</label>
<button id="clear-activity" class="icon-button" type="button" title="Clear activity">Clear</button>
</div>
</div>
<div id="activity-stream" class="activity-stream" role="log" aria-live="polite">
<div class="activity-empty">Protocol events and commands will appear here.</div>
</div>
<details class="raw-command">
<summary>Advanced: send raw JSON request</summary>
<form id="raw-command-form">
<textarea id="raw-command-input" spellcheck="false">{"command":"status"}</textarea>
<button class="button button-secondary" type="submit">Send request</button>
</form>
</details>
</article>
</section>
</main>
<footer class="app-footer">
<span>SharpEmu JSON-lines protocol <strong id="protocol-version"></strong></span>
<span class="footer-shortcuts"><kbd>F5</kbd> Continue <kbd>F6</kbd> Pause <kbd>F10</kbd> Step</span>
</footer>
<dialog id="register-dialog" class="modal">
<form id="register-form" method="dialog">
<div class="modal-header">
<div><div class="eyebrow">CPU STATE</div><h2>Edit register</h2></div>
<button id="register-dialog-close" class="icon-button close-button" type="button" aria-label="Close">×</button>
</div>
<label>
<span>Register</span>
<input id="register-name" readonly>
</label>
<label>
<span>Value</span>
<input id="register-value" autocomplete="off" required>
</label>
<div class="modal-actions">
<button id="register-cancel" class="button button-ghost" type="button">Cancel</button>
<button class="button button-primary" type="submit">Apply value</button>
</div>
</form>
</dialog>
<div id="toast-stack" class="toast-stack" aria-live="assertive"></div>
<script src="/app.js"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
@@ -0,0 +1,463 @@
/*
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
*/
:root {
--bg: #090c12;
--surface: rgba(18, 23, 34, 0.88);
--surface-solid: #121722;
--surface-raised: #181e2b;
--surface-input: #0d111a;
--border: rgba(157, 172, 205, 0.14);
--border-strong: rgba(157, 172, 205, 0.25);
--text: #f1f4fb;
--text-soft: #aab3c6;
--text-dim: #737d92;
--accent: #8a7dff;
--accent-bright: #a89fff;
--accent-muted: rgba(138, 125, 255, 0.14);
--cyan: #48d9d0;
--cyan-muted: rgba(72, 217, 208, 0.12);
--success: #48d597;
--warning: #ffbd62;
--danger: #ff6f83;
--radius: 16px;
--radius-small: 9px;
--shadow: 0 22px 60px rgba(0, 0, 0, 0.32);
--mono: "JetBrains Mono", "Cascadia Code", "SFMono-Regular", Consolas, monospace;
--sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html { min-width: 360px; background: var(--bg); }
body {
min-height: 100vh;
margin: 0;
color: var(--text);
background:
radial-gradient(circle at 8% 2%, rgba(113, 92, 246, 0.13), transparent 27rem),
radial-gradient(circle at 94% 22%, rgba(41, 190, 196, 0.08), transparent 32rem),
linear-gradient(145deg, #080b10, #0b0e16 48%, #090c12);
font: 14px/1.45 var(--sans);
}
button, input, select, textarea { font: inherit; }
button { color: inherit; }
.ambient {
position: fixed;
z-index: -1;
width: 28rem;
height: 28rem;
border-radius: 50%;
filter: blur(110px);
opacity: 0.13;
pointer-events: none;
}
.ambient-one { top: -15rem; left: 18%; background: var(--accent); }
.ambient-two { right: -18rem; top: 32%; background: var(--cyan); }
.app-header {
display: grid;
grid-template-columns: minmax(260px, 1fr) auto minmax(220px, 1fr);
align-items: center;
gap: 26px;
padding: 21px clamp(20px, 3vw, 52px);
border-bottom: 1px solid var(--border);
background: rgba(9, 12, 18, 0.76);
backdrop-filter: blur(22px);
position: sticky;
top: 0;
z-index: 30;
}
.brand { display: flex; align-items: center; gap: 14px; }
.brand-mark {
display: grid;
place-items: center;
width: 46px;
height: 46px;
border-radius: 13px;
box-shadow: 0 10px 30px rgba(89, 83, 255, 0.24);
overflow: hidden;
}
.brand-mark img { display: block; width: 100%; height: 100%; object-fit: cover; }
.eyebrow {
color: var(--text-dim);
font-size: 10px;
font-weight: 750;
letter-spacing: 0.16em;
}
h1, h2, p { margin: 0; }
h1 { font-size: 20px; line-height: 1.15; letter-spacing: -0.025em; }
h1 span { color: var(--accent-bright); font-weight: 500; }
h2 { margin-top: 3px; font-size: 17px; letter-spacing: -0.02em; }
.connection-bar {
display: flex;
align-items: stretch;
padding: 4px;
border: 1px solid var(--border-strong);
border-radius: 12px;
background: var(--surface-input);
box-shadow: inset 0 1px rgba(255, 255, 255, 0.025);
}
.connection-field { display: grid; align-content: center; padding: 1px 10px; }
.connection-field span, label > 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; }
}