Files
sharpemu/tools/SharpEmu.DebuggerFrontend/web/app.js
T

627 lines
24 KiB
JavaScript

/*
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();