From 679380f72b813179046607247ba682ddc18ca835 Mon Sep 17 00:00:00 2001 From: "hashroll\\john" Date: Sun, 11 Oct 2026 16:15:43 +0800 Subject: [PATCH] Add opt-in debugger protocol v2 for Godot 3 and 4 --- README.md | 17 + package.json | 16 +- src/debugger/debugger.ts | 18 +- src/debugger/debugger_mode.test.ts | 18 + src/debugger/debugger_mode.ts | 7 + src/debugger/godot3/debug_session_v2.ts | 205 ++++++++++ src/debugger/godot3/protocol_v2.test.ts | 23 ++ src/debugger/godot3/protocol_v2.ts | 106 +++++ src/debugger/godot3/server_controller_v2.ts | 370 +++++++++++++++++ src/debugger/godot3/session_state_v2.test.ts | 51 +++ src/debugger/godot3/session_state_v2.ts | 96 +++++ src/debugger/godot4/debug_session_v2.ts | 204 ++++++++++ src/debugger/godot4/protocol_v2.test.ts | 23 ++ src/debugger/godot4/protocol_v2.ts | 106 +++++ src/debugger/godot4/server_controller_v2.ts | 384 ++++++++++++++++++ src/debugger/godot4/session_state_v2.test.ts | 51 +++ src/debugger/godot4/session_state_v2.ts | 96 +++++ src/test/debugger_v2.integration.ts | 136 +++++++ src/test/debugger_v2_suite.ts | 8 + src/test/godot4_debugger_suite.ts | 8 + src/test/godot4_v2.integration.ts | 78 ++++ src/test/godot4_v2_suite.ts | 8 + .../call_stepping.gd | 24 ++ .../test-dap-project-godot3-v2/main.gd | 17 + .../test-dap-project-godot3-v2/project.godot | 5 + .../test-dap-project-godot4-v2/main.gd | 12 + .../test-dap-project-godot4-v2/project.godot | 9 + tools/run_debugger_v2_tests.ts | 15 + tools/run_godot4_debugger_tests.ts | 21 + tools/run_godot4_v2_tests.ts | 16 + 30 files changed, 2142 insertions(+), 6 deletions(-) create mode 100644 src/debugger/debugger_mode.test.ts create mode 100644 src/debugger/debugger_mode.ts create mode 100644 src/debugger/godot3/debug_session_v2.ts create mode 100644 src/debugger/godot3/protocol_v2.test.ts create mode 100644 src/debugger/godot3/protocol_v2.ts create mode 100644 src/debugger/godot3/server_controller_v2.ts create mode 100644 src/debugger/godot3/session_state_v2.test.ts create mode 100644 src/debugger/godot3/session_state_v2.ts create mode 100644 src/debugger/godot4/debug_session_v2.ts create mode 100644 src/debugger/godot4/protocol_v2.test.ts create mode 100644 src/debugger/godot4/protocol_v2.ts create mode 100644 src/debugger/godot4/server_controller_v2.ts create mode 100644 src/debugger/godot4/session_state_v2.test.ts create mode 100644 src/debugger/godot4/session_state_v2.ts create mode 100644 src/test/debugger_v2.integration.ts create mode 100644 src/test/debugger_v2_suite.ts create mode 100644 src/test/godot4_debugger_suite.ts create mode 100644 src/test/godot4_v2.integration.ts create mode 100644 src/test/godot4_v2_suite.ts create mode 100644 test_projects/test-dap-project-godot3-v2/call_stepping.gd create mode 100644 test_projects/test-dap-project-godot3-v2/main.gd create mode 100644 test_projects/test-dap-project-godot3-v2/project.godot create mode 100644 test_projects/test-dap-project-godot4-v2/main.gd create mode 100644 test_projects/test-dap-project-godot4-v2/project.godot create mode 100644 tools/run_debugger_v2_tests.ts create mode 100644 tools/run_godot4_debugger_tests.ts create mode 100644 tools/run_godot4_v2_tests.ts diff --git a/README.md b/README.md index 830d8f99d..2396a6b94 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,23 @@ _Usage_ ![Showing the debugger in action](img/godot-debug.png) +### Dual-channel debugger protocol v2 (opt-in) + +The extension selects the Godot 3 or Godot 4 debugger from the project's engine version. Both use the stock protocol by default. Protocol v2 requires a matching custom Godot 3.6 or 4.6 executable; set `debug_protocol_v2` only when using one of those builds: + +```json +{ + "name": "Godot Debug v2", + "type": "godot", + "request": "launch", + "project": "${workspaceFolder}", + "editor_path": "/path/to/custom/godot", + "debug_protocol_v2": true +} +``` + +For Godot 4, the v2 launch adapter uses a `debugv2://` endpoint. For attach mode, start the custom Godot 4 engine with `--remote-debug debugv2://127.0.0.1:` and set the same port in the attach configuration. The custom Godot 3 engine uses its normal `--remote-debug 127.0.0.1:` syntax but speaks the new protocol. Do not enable this setting with an unmodified engine; its legacy debugger remains the default. + ## Issues and contributions The [Godot Tools](https://github.com/godotengine/godot-vscode-plugin) extension diff --git a/package.json b/package.json index 49b4e79bf..1966a5ac9 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,11 @@ "generate-icons": "ts-node tools/generate_icons.ts", "test": "vscode-test", "test:headless": "WAYLAND_DISPLAY= xvfb-run --auto-servernum --server-args='-screen 0 1024x768x24 -ac +extension GLX' npm test", - "test:engine": "ts-node tools/run_tests.ts" + "test:engine": "ts-node tools/run_tests.ts", + "test:debugger-v2": "npm run compile && mocha --ui tdd out/debugger/godot3/protocol_v2.test.js out/debugger/godot3/session_state_v2.test.js", + "test:godot4:v2": "npm run compile && mocha --ui tdd out/debugger/godot4/protocol_v2.test.js out/debugger/godot4/session_state_v2.test.js && ts-node tools/run_godot4_v2_tests.ts", + "test:debugger-v2:integration": "npm run compile && ts-node tools/run_debugger_v2_tests.ts", + "test:godot4:integration": "npm run compile && ts-node tools/run_godot4_debugger_tests.ts" }, "contributes": { "customEditors": [ @@ -469,6 +473,11 @@ "launch": { "required": [], "properties": { + "debug_protocol_v2": { + "type": "boolean", + "description": "Use the dual-channel debug protocol v2 with a matching custom Godot 3.6 or 4.6 executable. Disabled for stock Godot builds.", + "default": false + }, "project": { "type": "string", "description": "Absolute path to a directory with a project.godot file.", @@ -566,6 +575,11 @@ "attach": { "required": [], "properties": { + "debug_protocol_v2": { + "type": "boolean", + "description": "Attach to a matching custom Godot 3.6 or 4.6 engine using the dual-channel debug protocol v2.", + "default": false + }, "address": { "type": "string", "description": "The IP address for the Godot remote debugger to use.", diff --git a/src/debugger/debugger.ts b/src/debugger/debugger.ts index 73563dd0b..27f27cd88 100644 --- a/src/debugger/debugger.ts +++ b/src/debugger/debugger.ts @@ -23,7 +23,10 @@ import { import { createLogger, get_project_version, register_command, set_context } from "../utils"; import { GodotVariable } from "./debug_runtime"; import { GodotDebugSession as Godot3DebugSession } from "./godot3/debug_session"; +import { GodotDebugSessionV2 as Godot3DebugSessionV2 } from "./godot3/debug_session_v2"; import { GodotDebugSession as Godot4DebugSession } from "./godot4/debug_session"; +import { GodotDebugSessionV2 as Godot4DebugSessionV2 } from "./godot4/debug_session_v2"; +import { select_debugger_mode } from "./debugger_mode"; import { GodotObject } from "./godot4/variables/godot_object_promise"; import { InspectorProvider, RemoteProperty } from "./inspector_provider"; import { SceneNode, SceneTreeProvider } from "./scene_tree_provider"; @@ -31,6 +34,7 @@ import { SceneNode, SceneTreeProvider } from "./scene_tree_provider"; const log = createLogger("debugger", { output: "Godot Debugger" }); export interface LaunchRequestArguments extends DebugProtocol.LaunchRequestArguments { + debug_protocol_v2?: boolean; address: string; port: number; project: string; @@ -51,6 +55,7 @@ export interface LaunchRequestArguments extends DebugProtocol.LaunchRequestArgum } export interface AttachRequestArguments extends DebugProtocol.AttachRequestArguments { + debug_protocol_v2?: boolean; address: string; port: number; project: string; @@ -79,7 +84,7 @@ class GDFileDecorationProvider implements FileDecorationProvider { } export class GodotDebugger implements DebugAdapterDescriptorFactory, DebugConfigurationProvider { - public session?: Godot3DebugSession | Godot4DebugSession; + public session?: Godot3DebugSession | Godot3DebugSessionV2 | Godot4DebugSession | Godot4DebugSessionV2; public sceneTree = new SceneTreeProvider(); public inspector = new InspectorProvider(); @@ -113,10 +118,13 @@ export class GodotDebugger implements DebugAdapterDescriptorFactory, DebugConfig const projectVersion = await get_project_version(); log.info(`Project version identified as ${projectVersion}`); - if (projectVersion?.startsWith("4")) { - this.session = new Godot4DebugSession(projectVersion); - } else { - this.session = new Godot3DebugSession(); + const mode = select_debugger_mode(projectVersion, session.configuration.debug_protocol_v2); + log.info(`Selected ${mode} debugger`); + switch (mode) { + case "godot3-v2": this.session = new Godot3DebugSessionV2(); break; + case "godot4-v2": this.session = new Godot4DebugSessionV2(); break; + case "godot4-legacy": this.session = new Godot4DebugSession(projectVersion ?? "4"); break; + default: this.session = new Godot3DebugSession(); } this.context.subscriptions.push(this.session); diff --git a/src/debugger/debugger_mode.test.ts b/src/debugger/debugger_mode.test.ts new file mode 100644 index 000000000..8512979d9 --- /dev/null +++ b/src/debugger/debugger_mode.test.ts @@ -0,0 +1,18 @@ +import { strictEqual } from "node:assert"; +import { select_debugger_mode } from "./debugger_mode"; + +suite("Debugger protocol selection", () => { + test("uses the legacy adapter by default for stock Godot 3 and 4", () => { + strictEqual(select_debugger_mode("3.6.2", undefined), "godot3-legacy"); + strictEqual(select_debugger_mode("4.6.2", false), "godot4-legacy"); + }); + + test("enables v2 only for the selected project major version", () => { + strictEqual(select_debugger_mode("3.6.2", true), "godot3-v2"); + strictEqual(select_debugger_mode("4.6.2", true), "godot4-v2"); + }); + + test("does not treat a truthy string as an opt-in", () => { + strictEqual(select_debugger_mode("3.6.2", "true"), "godot3-legacy"); + }); +}); diff --git a/src/debugger/debugger_mode.ts b/src/debugger/debugger_mode.ts new file mode 100644 index 000000000..2d2bf6bff --- /dev/null +++ b/src/debugger/debugger_mode.ts @@ -0,0 +1,7 @@ +export type DebuggerMode = "godot3-legacy" | "godot3-v2" | "godot4-legacy" | "godot4-v2"; + +/** Project version selects the adapter; the protocol opt-in preserves stock-engine debugging. */ +export function select_debugger_mode(projectVersion: string | undefined, protocolV2: unknown): DebuggerMode { + const major = projectVersion?.startsWith("4") ? "godot4" : "godot3"; + return `${major}-${protocolV2 === true ? "v2" : "legacy"}`; +} diff --git a/src/debugger/godot3/debug_session_v2.ts b/src/debugger/godot3/debug_session_v2.ts new file mode 100644 index 000000000..74d752a08 --- /dev/null +++ b/src/debugger/godot3/debug_session_v2.ts @@ -0,0 +1,205 @@ +import * as fs from "node:fs"; +import { Breakpoint, InitializedEvent, LoggingDebugSession, Source, StoppedEvent, TerminatedEvent, Thread } from "@vscode/debugadapter"; +import { DebugProtocol } from "@vscode/debugprotocol"; +import { Subject } from "await-notify"; +import { GodotDebugData, GodotVariable } from "../debug_runtime"; +import { AttachRequestArguments, LaunchRequestArguments } from "../debugger"; +import { InspectorProvider } from "../inspector_provider"; +import { stopPresentationV2 } from "./session_state_v2"; +import { SceneTreeProvider } from "../scene_tree_provider"; +import { build_sub_values } from "./helpers"; +import { ServerController } from "./server_controller_v2"; +import { RawObject } from "./variables/variants"; + +export class GodotDebugSessionV2 extends LoggingDebugSession { + public controller = new ServerController(this); + public debug_data = new GodotDebugData(this); + public sceneTree: SceneTreeProvider; + public inspector: InspectorProvider; + public inspect_callbacks = new Map void>(); + private configurationDone = new Subject(); + private mode: "launch" | "attach" | "" = ""; + private nextReference = 1; + private referenceToHandle = new Map(); + private handleToReference = new Map(); + private requestCancellations = new Map(); + + public constructor() { + super(); + this.setDebuggerLinesStartAt1(false); + this.setDebuggerColumnsStartAt1(false); + } + + public dispose() { this.controller.stop(); } + + protected initializeRequest(response: DebugProtocol.InitializeResponse) { + response.body = response.body || {}; + response.body.supportsConfigurationDoneRequest = true; + response.body.supportsTerminateRequest = true; + response.body.supportsEvaluateForHovers = false; + response.body.supportsStepBack = false; + response.body.supportsFunctionBreakpoints = false; + response.body.supportsConditionalBreakpoints = false; + response.body.supportsCancelRequest = true; + (response.body as any).supportsVariablePaging = true; + this.sendResponse(response); + this.sendEvent(new InitializedEvent()); + } + + protected async launchRequest(response: DebugProtocol.LaunchResponse, args: LaunchRequestArguments) { + await this.configurationDone.wait(1000); + this.mode = "launch"; + this.debug_data.projectPath = args.project; + try { await this.controller.launch(args); this.sendResponse(response); } catch (error) { this.fail(response, error); } + } + + protected async attachRequest(response: DebugProtocol.AttachResponse, args: AttachRequestArguments) { + await this.configurationDone.wait(1000); + this.mode = "attach"; + try { await this.controller.attach(args); this.sendResponse(response); } catch (error) { this.fail(response, error); } + } + + public configurationDoneRequest(response: DebugProtocol.ConfigurationDoneResponse) { + this.configurationDone.notify(); + this.sendResponse(response); + } + + public onStopped(_stopId: bigint, _canContinue: boolean, error: string, isError: boolean) { + this.nextReference = 1; + this.referenceToHandle.clear(); + this.handleToReference.clear(); + const presentation = stopPresentationV2(error, isError); + this.sendEvent(new StoppedEvent(presentation.reason, 0, presentation.text)); + } + + protected async continueRequest(response: DebugProtocol.ContinueResponse) { + response.body = { allThreadsContinued: true }; + await this.execution(response, () => this.controller.continue()); + } + protected async nextRequest(response: DebugProtocol.NextResponse) { await this.execution(response, () => this.controller.next()); } + protected async pauseRequest(response: DebugProtocol.PauseResponse) { await this.execution(response, () => this.controller.break()); } + protected async stepInRequest(response: DebugProtocol.StepInResponse) { await this.execution(response, () => this.controller.step()); } + protected async stepOutRequest(response: DebugProtocol.StepOutResponse) { await this.execution(response, () => this.controller.step_out()); } + + private async execution(response: DebugProtocol.Response, action: () => Promise) { + try { + const latency = await action(); + response.message = `Godot acknowledged in ${latency.toFixed(1)} ms`; + this.sendResponse(response); + } catch (error) { this.fail(response, error); } + } + + protected async stackTraceRequest(response: DebugProtocol.StackTraceResponse, args: DebugProtocol.StackTraceArguments) { + const cancellation = this.beginCancellable(response); + try { + const payload = await this.controller.request_stack_trace(args.startFrame ?? 0, args.levels ?? 100, cancellation.signal); + const frames = Array.isArray(payload[1]) ? payload[1] : []; + response.body = { + totalFrames: Number(payload[0] ?? 0), + stackFrames: frames.map((frame: any[]) => ({ + id: Number(frame[0]), name: String(frame[3]), line: Number(frame[2]), column: 1, + source: new Source(String(frame[1]), `${this.debug_data.projectPath}/${String(frame[1]).replace("res://", "")}`), + })), + }; + this.sendResponse(response); + } catch (error) { this.fail(response, error); } finally { this.requestCancellations.delete(response.request_seq); } + } + + protected async scopesRequest(response: DebugProtocol.ScopesResponse, args: DebugProtocol.ScopesArguments) { + const cancellation = this.beginCancellable(response); + try { + const payload = await this.controller.request_scopes(args.frameId, cancellation.signal); + const scopes = Array.isArray(payload[0]) ? payload[0] : []; + response.body = { scopes: scopes.map((scope: any[]) => ({ name: String(scope[0]), variablesReference: this.reference(BigInt(scope[1])), expensive: Boolean(scope[2]) })) }; + this.sendResponse(response); + } catch (error) { this.fail(response, error); } finally { this.requestCancellations.delete(response.request_seq); } + } + + protected async variablesRequest(response: DebugProtocol.VariablesResponse, args: DebugProtocol.VariablesArguments) { + const handle = this.referenceToHandle.get(args.variablesReference); + if (handle === undefined) { response.body = { variables: [] }; this.sendResponse(response); return; } + const cancellation = this.beginCancellable(response); + try { + const payload = await this.controller.request_variables(handle, args.start ?? 0, args.count ?? 100, cancellation.signal); + const items = Array.isArray(payload[2]) ? payload[2] : []; + response.body = { variables: items.map((item: any[]) => this.variable(item)) }; + this.sendResponse(response); + } catch (error) { this.fail(response, error); } finally { this.requestCancellations.delete(response.request_seq); } + } + + protected cancelRequest(response: DebugProtocol.CancelResponse, args: DebugProtocol.CancelArguments) { + if (args.requestId !== undefined) this.requestCancellations.get(args.requestId)?.abort(); + this.sendResponse(response); + } + + private beginCancellable(response: DebugProtocol.Response) { + const cancellation = new AbortController(); + this.requestCancellations.set(response.request_seq, cancellation); + return cancellation; + } + + private variable(item: any[]): DebugProtocol.Variable { + const handle = BigInt(item[3]); + const indexed = Number(item[4]); + const named = Number(item[5]); + return { + name: String(item[0]), type: String(item[1]), value: String(item[2]), + variablesReference: handle === 0n ? 0 : this.reference(handle), + indexedVariables: indexed || undefined, namedVariables: named || undefined, + }; + } + + private reference(handle: bigint) { + const existing = this.handleToReference.get(handle); + if (existing !== undefined) return existing; + const reference = this.nextReference++; + this.handleToReference.set(handle, reference); + this.referenceToHandle.set(reference, handle); + return reference; + } + + protected setBreakPointsRequest(response: DebugProtocol.SetBreakpointsResponse, args: DebugProtocol.SetBreakpointsArguments) { + const path = args.source.path?.replace(/\\/g, "/"); + if (!path || !fs.existsSync(path)) { response.body = { breakpoints: [] }; this.sendResponse(response); return; } + const requested = args.lines || []; + for (const breakpoint of this.debug_data.get_breakpoints(path)) if (!requested.includes(breakpoint.line)) this.debug_data.remove_breakpoint(path, breakpoint.line); + const existing = this.debug_data.get_breakpoints(path).map((breakpoint) => breakpoint.line); + for (const line of requested) { + const item = args.breakpoints?.find((breakpoint) => breakpoint.line === line); + if (!existing.includes(line) && !item?.condition) this.debug_data.set_breakpoint(path, line); + } + const breakpoints = this.debug_data.get_breakpoints(path).sort((a, b) => a.line - b.line); + response.body = { breakpoints: breakpoints.map((breakpoint) => new Breakpoint(true, breakpoint.line, 1, new Source(breakpoint.file.split("/").pop() || "", breakpoint.file))) }; + this.sendResponse(response); + } + + protected threadsRequest(response: DebugProtocol.ThreadsResponse) { response.body = { threads: [new Thread(0, "Main Thread")] }; this.sendResponse(response); } + protected evaluateRequest(response: DebugProtocol.EvaluateResponse) { + response.success = false; + response.message = "Evaluation is disabled in protocol v2; expand the required scope lazily."; + response.body = { result: "null", variablesReference: 0 }; + this.sendResponse(response); + } + protected terminateRequest(response: DebugProtocol.TerminateResponse) { + if (this.mode === "launch") { this.controller.stop(); this.sendEvent(new TerminatedEvent()); } + this.sendResponse(response); + } + + public acceptInspection(objectId: bigint, payload: any[]) { + const id = BigInt(payload[0] ?? objectId); + const className = String(payload[1] ?? "Object"); + const properties = Array.isArray(payload[2]) ? payload[2] : []; + const object = new RawObject(className); + for (const property of properties) if (Array.isArray(property) && property.length >= 6) object.set(property[0], property[5]); + const variable: GodotVariable = { name: "", value: object }; + build_sub_values(variable); + this.inspect_callbacks.get(id)?.(className, variable); + this.inspect_callbacks.delete(id); + } + + private fail(response: DebugProtocol.Response, error: unknown) { + response.success = false; + response.message = error instanceof Error ? error.message : String(error); + this.sendResponse(response); + } +} diff --git a/src/debugger/godot3/protocol_v2.test.ts b/src/debugger/godot3/protocol_v2.test.ts new file mode 100644 index 000000000..875cdd776 --- /dev/null +++ b/src/debugger/godot3/protocol_v2.test.ts @@ -0,0 +1,23 @@ +import { strict as assert } from "node:assert"; +import { DebugChannelV2, DebugEnvelopeV2, DebugPacketStreamV2, decodeEnvelopeV2, encodeEnvelopeV2 } from "./protocol_v2"; + +suite("Godot 3 debugger protocol v2", () => { + const message: DebugEnvelopeV2 = { sessionId: 9_007_199_254_740_993n, generation: 2, channel: DebugChannelV2.Control, sequence: 7n, requestId: 5n, stopId: 3n, type: "next", payload: [true, "ok"] }; + + test("round trips an envelope without losing 64-bit ids", () => { + const stream = new DebugPacketStreamV2(); + assert.deepEqual(stream.push(encodeEnvelopeV2(message)), [message]); + }); + + test("handles partial and coalesced TCP buffers", () => { + const first = encodeEnvelopeV2(message); + const second = encodeEnvelopeV2({ ...message, sequence: 8n, requestId: 6n }); + const stream = new DebugPacketStreamV2(); + assert.deepEqual(stream.push(first.subarray(0, 3)), []); + assert.deepEqual(stream.push(Buffer.concat([first.subarray(3), second])).map((item) => item.requestId), [5n, 6n]); + }); + + test("rejects incompatible versions", () => { + assert.throws(() => decodeEnvelopeV2(["GDDBG2", 3, 0, 1, 1, 0, 1, 1, 0, "hello", []]), /incompatible/); + }); +}); diff --git a/src/debugger/godot3/protocol_v2.ts b/src/debugger/godot3/protocol_v2.ts new file mode 100644 index 000000000..a7fcf7203 --- /dev/null +++ b/src/debugger/godot3/protocol_v2.ts @@ -0,0 +1,106 @@ +import { VariantDecoder } from "./variables/variant_decoder"; +import { VariantEncoder } from "./variables/variant_encoder"; + +export const DEBUG_PROTOCOL_V2_MAGIC = "GDDBG2"; +export const DEBUG_PROTOCOL_V2_MAJOR = 2; +export const DEBUG_PROTOCOL_V2_MINOR = 0; +export const DEBUG_PROTOCOL_V2_FIELDS = 11; + +export enum DebugChannelV2 { + Control = 0, + Data = 1, +} + +export interface DebugEnvelopeV2 { + sessionId: bigint; + generation: number; + channel: DebugChannelV2; + sequence: bigint; + requestId: bigint; + stopId: bigint; + type: string; + payload: any[]; +} + +function integer(value: unknown, field: string): bigint { + if (typeof value === "bigint") return value; + if (typeof value === "number" && Number.isSafeInteger(value)) return BigInt(value); + throw new Error(`Debugger v2 ${field} must be an integer`); +} + +export function decodeEnvelopeV2(packet: unknown): DebugEnvelopeV2 { + if (!Array.isArray(packet) || packet.length !== DEBUG_PROTOCOL_V2_FIELDS) { + throw new Error("Debugger v2 envelope has an invalid field count"); + } + if (packet[0] !== DEBUG_PROTOCOL_V2_MAGIC) throw new Error("Debugger v2 envelope has invalid magic"); + if (integer(packet[1], "major") !== BigInt(DEBUG_PROTOCOL_V2_MAJOR) || integer(packet[2], "minor") !== BigInt(DEBUG_PROTOCOL_V2_MINOR)) { + throw new Error("Debugger v2 envelope has an incompatible protocol version"); + } + const channel = Number(integer(packet[5], "channel")); + if (channel !== DebugChannelV2.Control && channel !== DebugChannelV2.Data) { + throw new Error("Debugger v2 envelope has an invalid channel"); + } + if (typeof packet[9] !== "string" || !Array.isArray(packet[10])) { + throw new Error("Debugger v2 envelope has an invalid body"); + } + return { + sessionId: integer(packet[3], "session id"), + generation: Number(integer(packet[4], "generation")), + channel, + sequence: integer(packet[6], "sequence"), + requestId: integer(packet[7], "request id"), + stopId: integer(packet[8], "stop id"), + type: packet[9], + payload: packet[10], + }; +} + +export function encodeEnvelopeV2(message: DebugEnvelopeV2): Buffer { + const packet = [ + DEBUG_PROTOCOL_V2_MAGIC, + DEBUG_PROTOCOL_V2_MAJOR, + DEBUG_PROTOCOL_V2_MINOR, + message.sessionId, + message.generation, + message.channel, + message.sequence, + message.requestId, + message.stopId, + message.type, + message.payload, + ]; + return new VariantEncoder().encode_variant(packet); +} + +/** Incrementally decodes PacketPeerStream frames, including split and coalesced TCP chunks. */ +export class DebugPacketStreamV2 { + private buffer: Buffer = Buffer.alloc(0); + private readonly decoder = new VariantDecoder(); + + public constructor(private readonly maxPacketBytes = 16 * 1024 * 1024) {} + + public push(chunk: Buffer): DebugEnvelopeV2[] { + if (chunk.length === 0) return []; + this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]); + const messages: DebugEnvelopeV2[] = []; + while (this.buffer.length >= 4) { + const bodyLength = this.buffer.readUInt32LE(0); + if (bodyLength <= 0 || bodyLength > this.maxPacketBytes) { + this.buffer = Buffer.alloc(0); + throw new Error(`Debugger v2 packet length ${bodyLength} is invalid`); + } + const packetLength = bodyLength + 4; + if (this.buffer.length < packetLength) break; + const packet = this.buffer.subarray(0, packetLength); + this.buffer = this.buffer.subarray(packetLength); + const dataset = this.decoder.get_dataset(packet); + if (!dataset || dataset.length !== 2) throw new Error("Debugger v2 packet must contain exactly one Variant"); + messages.push(decodeEnvelopeV2(dataset[1])); + } + return messages; + } + + public reset() { + this.buffer = Buffer.alloc(0); + } +} diff --git a/src/debugger/godot3/server_controller_v2.ts b/src/debugger/godot3/server_controller_v2.ts new file mode 100644 index 000000000..b1036c329 --- /dev/null +++ b/src/debugger/godot3/server_controller_v2.ts @@ -0,0 +1,370 @@ +import { StoppedEvent, TerminatedEvent } from "@vscode/debugadapter"; +import { DebugProtocol } from "@vscode/debugprotocol"; +import * as fs from "node:fs"; +import * as net from "node:net"; +import { debug, window } from "vscode"; +import BBCodeToAnsi from "bbcode-to-ansi"; + +import { ansi, convert_resource_path_to_uri, createLogger, get_configuration, get_free_port, get_project_version, verify_godot_version, VERIFY_RESULT } from "../../utils"; +import { prompt_for_godot_executable } from "../../utils/prompts"; +import { killSubProcesses, subProcess } from "../../utils/subspawn"; +import { LaunchRequestArguments, AttachRequestArguments, pinnedScene } from "../debugger"; +import { parse_next_scene_node } from "./helpers"; +import { DebugChannelV2, DebugEnvelopeV2, DebugPacketStreamV2, encodeEnvelopeV2 } from "./protocol_v2"; +import { DebugChannelPairerV2, DebugSessionStateV2, ExecutionCommandV2 } from "./session_state_v2"; +import type { GodotDebugSessionV2 } from "./debug_session_v2"; + +const log = createLogger("debugger.controller.v2", { output: "Godot Debugger" }); +const socketLog = createLogger("debugger.socket.v2"); +const bbcodeParser = new BBCodeToAnsi("\u001b[38;2;211;211;211m"); + +interface SocketContextV2 { + socket: net.Socket; + stream: DebugPacketStreamV2; + hello?: DebugEnvelopeV2; +} + +interface PendingControlV2 { + command: ExecutionCommandV2; + started: number; + resolve: (latencyMs: number) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; +} + +interface PendingDataV2 { + resolve: (message: DebugEnvelopeV2) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; + cleanup: () => void; +} + +export class ServerController { + private server?: net.Server; + private controlSocket?: net.Socket; + private dataSocket?: net.Socket; + private contexts = new Map(); + private pairer = new DebugChannelPairerV2(); + private state = new DebugSessionStateV2(); + private pendingControl = new Map(); + private pendingData = new Map(); + private heartbeat?: NodeJS.Timeout; + private stopping = false; + + public constructor(public session: GodotDebugSessionV2) {} + + public get currentStopId() { return this.state.stopId; } + public get isPaused() { return this.state.paused; } + + public break() { return this.execute("pause"); } + public continue() { return this.execute("continue"); } + public next() { return this.execute("next"); } + public step() { return this.execute("step"); } + public step_out() { return this.execute("step_out"); } + + public set_breakpoint(path: string, line: number) { + void this.requestData("breakpoint", [path, line, true], 0n).catch((error) => log.warn(error.message)); + } + + public remove_breakpoint(path: string, line: number) { + this.session.debug_data.remove_breakpoint(path, line); + void this.requestData("breakpoint", [path, line, false], 0n).catch((error) => log.warn(error.message)); + } + + public async request_stack_trace(start = 0, count = 100, signal?: AbortSignal) { + return (await this.requestData("stack_trace", [start, count], this.state.stopId, 5000, signal)).payload; + } + + public async request_scopes(frameId: number, signal?: AbortSignal) { + return (await this.requestData("scopes", [frameId], this.state.stopId, 5000, signal)).payload; + } + + public async request_variables(handle: bigint, start = 0, count = 100, signal?: AbortSignal) { + return (await this.requestData("variables", [handle, start, count], this.state.stopId, 5000, signal)).payload; + } + + public request_inspect_object(objectId: bigint) { + void this.requestData("inspect_object_id", [objectId], this.state.paused ? this.state.stopId : 0n).then((message) => { + this.session.acceptInspection(objectId, message.payload); + }).catch((error) => log.warn(error.message)); + } + + public request_scene_tree() { + void this.requestData("scene_snapshot", [], this.state.paused ? this.state.stopId : 0n).then((message) => { + const treeData = message.payload[1]; + if (Array.isArray(treeData)) this.session.sceneTree.fill_tree(parse_next_scene_node(treeData)); + }).catch((error) => log.warn(error.message)); + } + + public set_object_property(objectId: bigint, label: string, value: any) { + void this.requestData("set_object_property", [objectId, label, value], this.state.paused ? this.state.stopId : 0n).catch((error) => log.warn(error.message)); + } + + public async launch(args: LaunchRequestArguments) { + log.info("Starting Godot 3 debugger protocol v2 in launch mode"); + await this.listen(args); + await this.startGame(args); + } + + public async attach(args: AttachRequestArguments) { + log.info("Starting Godot 3 debugger protocol v2 in attach mode"); + await this.listen(args); + } + + private async listen(args: AttachRequestArguments) { + if (args.port === -1) args.port = await get_free_port(); + this.server = net.createServer((socket) => this.acceptSocket(socket)); + await new Promise((resolve, reject) => { + this.server?.once("error", reject); + this.server?.listen(args.port, args.address, () => { + this.server?.off("error", reject); + resolve(); + }); + }); + } + + private acceptSocket(socket: net.Socket) { + socket.setNoDelay(true); + const context: SocketContextV2 = { socket, stream: new DebugPacketStreamV2() }; + this.contexts.set(socket, context); + socket.on("data", (chunk) => { + try { + for (const message of context.stream.push(chunk)) this.receive(context, message); + } catch (error) { + log.error(error as Error); + socket.destroy(); + } + }); + socket.on("close", () => this.socketClosed(context)); + socket.on("error", (error) => log.warn(`Debugger v2 socket error: ${error.message}`)); + } + + private receive(context: SocketContextV2, message: DebugEnvelopeV2) { + if (!context.hello) { + if (message.type !== "control_hello" && message.type !== "data_hello") throw new Error("Debugger v2 channel did not begin with hello"); + context.hello = message; + const pair = this.pairer.accept(message, context.socket); + const ack: DebugEnvelopeV2 = { sessionId: message.sessionId, generation: message.generation, channel: message.channel, sequence: 1n, requestId: 1n, stopId: 0n, type: "hello_ack", payload: [250, 1000, 100] }; + context.socket.write(encodeEnvelopeV2(ack)); + if (pair) { + this.state.activate(pair); + this.controlSocket = pair.control as net.Socket; + this.dataSocket = pair.data as net.Socket; + this.startHeartbeat(); + log.info(`Debugger v2 paired session ${pair.sessionId}, generation ${pair.generation}`); + } + return; + } + + if (context.socket !== this.controlSocket && context.socket !== this.dataSocket) return; + if (this.state.receive(message) === "duplicate") return; + socketLog.debug("rx:", [message.type, message.requestId.toString(), message.stopId.toString()]); + if (message.channel === DebugChannelV2.Control) this.receiveControl(message); + else this.receiveData(message); + } + + private receiveControl(message: DebugEnvelopeV2) { + if (message.type === "ack") { + const pending = this.pendingControl.get(message.requestId); + if (!pending) return; + this.pendingControl.delete(message.requestId); + clearTimeout(pending.timer); + const accepted = Boolean(message.payload[0]); + const reason = String(message.payload[2] ?? "rejected"); + if (!accepted) pending.reject(new Error(`Godot rejected ${pending.command}: ${reason}`)); + else { + if (pending.command !== "pause") this.state.paused = false; + pending.resolve(performance.now() - pending.started); + } + return; + } + if (message.type === "stopped") { + this.session.onStopped(message.stopId, Boolean(message.payload[0]), String(message.payload[1] ?? ""), Boolean(message.payload[2])); + return; + } + if (message.type === "diagnostic") this.stderr(`[debugger] ${message.payload.join(" ")}\n`); + } + + private receiveData(message: DebugEnvelopeV2) { + if (message.requestId !== 0n) { + const pending = this.pendingData.get(message.requestId); + if (pending) { + this.pendingData.delete(message.requestId); + clearTimeout(pending.timer); + pending.cleanup(); + if (message.type === "request_error") pending.reject(new Error(`${message.payload[0]}: ${message.payload[1]}`)); + else pending.resolve(message); + return; + } + } + if (message.type === "output") { + for (const line of String(message.payload[0] ?? "").split("\n")) debug.activeDebugConsole.appendLine(bbcodeParser.parse(line)); + } else if (message.type === "error") { + void this.handleError(message.payload); + } else if (message.type === "data_drop_summary") { + this.stderr(`[debugger] dropped ${message.payload[0]} data messages (${message.payload[1]} bytes); queue=${message.payload[2]} bytes\n`); + } else if (message.type === "noncontinuable_error") { + const top = Array.isArray(message.payload[1]) && message.payload[1].length ? ` at ${message.payload[1][0][0]}:${message.payload[1][0][1]}` : ""; + this.stderr(`[debugger] non-continuable script error (execution was not left paused): ${message.payload[0]}${top}\n`); + } else if (message.type === "kill_me") { + this.abort(); + } + } + + private execute(command: ExecutionCommandV2): Promise { + try { this.state.validateExecution(command); } catch (error) { return Promise.reject(error); } + if (!this.controlSocket) return Promise.reject(new Error("Debugger v2 control channel is not connected")); + const message = this.state.nextEnvelope(DebugChannelV2.Control, command, [], command === "pause" ? 0n : this.state.stopId); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingControl.delete(message.requestId); + reject(new Error(`Timed out waiting for Godot ${command} acknowledgement`)); + }, 1250); + this.pendingControl.set(message.requestId, { command, started: performance.now(), resolve, reject, timer }); + this.write(this.controlSocket as net.Socket, message); + }); + } + + private requestData(type: string, payload: any[], stopId = this.state.stopId, timeoutMs = 5000, signal?: AbortSignal): Promise { + if (!this.dataSocket) return Promise.reject(new Error("Debugger v2 data channel is not connected")); + if (signal?.aborted) return Promise.reject(new Error(`Godot ${type} request was cancelled`)); + const message = this.state.nextEnvelope(DebugChannelV2.Data, type, payload, stopId); + return new Promise((resolve, reject) => { + const cancel = () => { + const pending = this.pendingData.get(message.requestId); + if (!pending) return; + this.pendingData.delete(message.requestId); + clearTimeout(pending.timer); + pending.cleanup(); + reject(new Error(`Godot ${type} request was cancelled`)); + }; + const cleanup = () => signal?.removeEventListener("abort", cancel); + const timer = setTimeout(() => { + this.pendingData.delete(message.requestId); + cleanup(); + reject(new Error(`Timed out waiting for Godot ${type} response`)); + }, timeoutMs); + this.pendingData.set(message.requestId, { resolve, reject, timer, cleanup }); + signal?.addEventListener("abort", cancel, { once: true }); + this.write(this.dataSocket as net.Socket, message); + }); + } + + private write(socket: net.Socket, message: DebugEnvelopeV2) { + socketLog.debug("tx:", [message.type, message.requestId.toString(), message.stopId.toString()]); + socket.write(encodeEnvelopeV2(message)); + } + + private startHeartbeat() { + if (this.heartbeat) clearInterval(this.heartbeat); + this.heartbeat = setInterval(() => { + if (!this.controlSocket || this.controlSocket.destroyed) return; + const message = this.state.envelope(DebugChannelV2.Control, "heartbeat", [], 0n, this.state.stopId); + this.write(this.controlSocket, message); + }, 250); + } + + private socketClosed(context: SocketContextV2) { + this.contexts.delete(context.socket); + this.pairer.discard(context.socket); + if (context.socket === this.controlSocket) this.controlSocket = undefined; + if (context.socket === this.dataSocket) this.dataSocket = undefined; + if (!this.controlSocket || !this.dataSocket) { + if (this.heartbeat) clearInterval(this.heartbeat); + this.heartbeat = undefined; + this.rejectPending(new Error("Debugger v2 channel disconnected; waiting for engine reconnect")); + } + } + + private rejectPending(error: Error) { + for (const pending of this.pendingControl.values()) { clearTimeout(pending.timer); pending.reject(error); } + for (const pending of this.pendingData.values()) { clearTimeout(pending.timer); pending.cleanup(); pending.reject(error); } + this.pendingControl.clear(); + this.pendingData.clear(); + } + + private async startGame(args: LaunchRequestArguments) { + const { godotPath } = await this.resolveGodot(args); + let command = `"${godotPath}" --path "${args.project}"`; + command += ` --remote-debug "${args.address.replace("tcp://", "")}:${args.port}"`; + if (args.profiling) command += " --profiling"; + if (args.debug_collisions) command += " --debug-collisions"; + if (args.debug_paths) command += " --debug-paths"; + if (args.frame_delay) command += ` --frame-delay ${args.frame_delay}`; + if (args.time_scale) command += ` --time-scale ${args.time_scale}`; + if (args.fixed_fps) command += ` --fixed-fps ${args.fixed_fps}`; + if (args.scene && args.scene !== "main") command += ` "${this.resolveScene(args.scene)}"`; + command += this.session.debug_data.get_breakpoint_string(); + if (args.additional_options) command += ` ${args.additional_options}`; + log.info(`Launching game process using command: '${command}'`); + const child = subProcess("debug", command, { shell: true, detached: true }); + child.stdout.on("data", () => {}); + child.stderr.on("data", () => {}); + child.on("close", () => { if (!this.stopping) this.abort(); }); + } + + private async resolveGodot(args: LaunchRequestArguments): Promise<{ godotPath: string; result: VERIFY_RESULT }> { + const settingName = "editorPath.godot3"; + const requested = args.editor_path || get_configuration(settingName); + const result = verify_godot_version(requested, "3"); + if (result.status === "WRONG_VERSION") { + const projectVersion = await get_project_version(); + const message = `Cannot launch debug session: project uses Godot v${projectVersion}, executable is v${result.version}`; + if (args.editor_path) window.showErrorMessage(message, "Ok"); else prompt_for_godot_executable(message, settingName); + throw new Error(message); + } + if (result.status === "INVALID_EXE") { + const message = `Cannot launch debug session: '${result.godotPath}' is not a valid Godot executable`; + if (args.editor_path) window.showErrorMessage(message, "Ok"); else prompt_for_godot_executable(message, settingName); + throw new Error(message); + } + return { godotPath: result.godotPath, result }; + } + + private resolveScene(scene: string) { + if (scene === "pinned") { + if (!pinnedScene) throw new Error("No pinned scene found"); + return pinnedScene.fsPath.endsWith(".gd") ? pinnedScene.fsPath.replace(/\.gd$/, ".tscn") : pinnedScene.fsPath; + } + if (scene === "current") { + const current = window.activeTextEditor?.document.fileName; + if (!current) throw new Error("No active editor. Open a file to launch it."); + const resolved = current.endsWith(".gd") ? current.replace(/\.gd$/, ".tscn") : current; + if (!fs.existsSync(resolved)) throw new Error(`Can't find associated scene file for ${resolved}`); + return resolved; + } + return scene; + } + + private async handleError(payload: any[]) { + const params = payload[0] ?? []; + const warning = Boolean(params[9]); + const time = `${params[0]}:${params[1]}:${params[2]}.${params[3]}`; + const location = `${params[5]}:${params[6]} @ ${params[4]}()`; + const uri = await convert_resource_path_to_uri(params[5]); + const extras = { source: { name: uri?.toString() ?? "" }, line: params[6], group: "startCollapsed" }; + this.stderr(`${warning ? ansi.yellow : ansi.red}${time} | ${params[8] || params[7]}\n`, extras); + this.stderr(`${ansi.dim.white} ${ansi.white}${location}\n`, { group: "end" }); + } + + private stderr(output = "", extra = {}) { + this.session.sendEvent({ event: "output", body: { category: "stderr", output: output + ansi.reset, ...extra } } as DebugProtocol.OutputEvent); + } + + public abort() { + if (this.stopping) return; + this.session.sendEvent(new TerminatedEvent()); + this.stop(); + } + + public stop() { + this.stopping = true; + if (this.heartbeat) clearInterval(this.heartbeat); + this.rejectPending(new Error("Debugger stopped")); + killSubProcesses("debug"); + for (const context of this.contexts.values()) context.socket.destroy(); + this.contexts.clear(); + this.server?.close(); + this.server = undefined; + } +} diff --git a/src/debugger/godot3/session_state_v2.test.ts b/src/debugger/godot3/session_state_v2.test.ts new file mode 100644 index 000000000..4f29a499b --- /dev/null +++ b/src/debugger/godot3/session_state_v2.test.ts @@ -0,0 +1,51 @@ +import { strict as assert } from "node:assert"; +import { DebugChannelV2, DebugEnvelopeV2 } from "./protocol_v2"; +import { DebugChannelPairerV2, DebugSessionStateV2, stopPresentationV2 } from "./session_state_v2"; + +function hello(channel: DebugChannelV2, generation = 1): DebugEnvelopeV2 { + return { sessionId: 42n, generation, channel, sequence: 1n, requestId: 1n, stopId: 0n, type: channel === DebugChannelV2.Control ? "control_hello" : "data_hello", payload: [] }; +} + +suite("Godot 3 debugger session v2", () => { + test("pairs only matching control and data channels", () => { + const pairer = new DebugChannelPairerV2(); + assert.equal(pairer.accept(hello(DebugChannelV2.Control), "control"), undefined); + assert.deepEqual(pairer.accept(hello(DebugChannelV2.Data), "data"), { sessionId: 42n, generation: 1, control: "control", data: "data" }); + }); + + test("rejects duplicate channels", () => { + const pairer = new DebugChannelPairerV2(); + pairer.accept(hello(DebugChannelV2.Control), "a"); + assert.throws(() => pairer.accept(hello(DebugChannelV2.Control), "b"), /Duplicate/); + }); + + test("deduplicates acknowledgements and rejects stale stops", () => { + const state = new DebugSessionStateV2(); + state.activate({ sessionId: 42n, generation: 1, control: {}, data: {} }); + const stopped = { ...hello(DebugChannelV2.Control), type: "stopped", requestId: 0n, stopId: 4n }; + assert.equal(state.receive(stopped), "accepted"); + const ack = { ...stopped, type: "ack", requestId: 8n }; + assert.equal(state.receive(ack), "accepted"); + assert.equal(state.receive(ack), "duplicate"); + assert.throws(() => state.receive({ ...stopped, type: "running", stopId: 3n }), /stale stop/); + }); + + test("requires a newer generation when reconnecting", () => { + const state = new DebugSessionStateV2(); + state.activate({ sessionId: 42n, generation: 1, control: {}, data: {} }); + assert.throws(() => state.activate({ sessionId: 42n, generation: 1, control: {}, data: {} }), /generation/); + state.activate({ sessionId: 42n, generation: 2, control: {}, data: {} }); + }); + + test("presents ordinary stops without exception text", () => { + assert.deepEqual(stopPresentationV2("Breakpoint", false), { reason: "breakpoint" }); + assert.deepEqual(stopPresentationV2("Invalid get index", true), { reason: "exception", text: "Invalid get index" }); + }); + + test("accepts step out while paused", () => { + const state = new DebugSessionStateV2(); + state.activate({ sessionId: 42n, generation: 1, control: {}, data: {} }); + state.receive({ ...hello(DebugChannelV2.Control), type: "stopped", requestId: 0n, stopId: 4n }); + assert.doesNotThrow(() => state.validateExecution("step_out")); + }); +}); diff --git a/src/debugger/godot3/session_state_v2.ts b/src/debugger/godot3/session_state_v2.ts new file mode 100644 index 000000000..4031860a0 --- /dev/null +++ b/src/debugger/godot3/session_state_v2.ts @@ -0,0 +1,96 @@ +import { DebugChannelV2, DebugEnvelopeV2 } from "./protocol_v2"; + +export interface DebugPairV2 { + sessionId: bigint; + generation: number; + control: unknown; + data: unknown; +} + +interface CandidatePairV2 { + control?: unknown; + data?: unknown; +} + +export class DebugChannelPairerV2 { + private candidates = new Map(); + + public accept(hello: DebugEnvelopeV2, connection: unknown): DebugPairV2 | undefined { + const expected = hello.channel === DebugChannelV2.Control ? "control_hello" : "data_hello"; + if (hello.type !== expected) throw new Error(`Expected ${expected}, received ${hello.type}`); + const key = `${hello.sessionId}:${hello.generation}`; + const candidate = this.candidates.get(key) ?? {}; + const role = hello.channel === DebugChannelV2.Control ? "control" : "data"; + if (candidate[role]) throw new Error(`Duplicate debugger v2 ${role} channel`); + candidate[role] = connection; + this.candidates.set(key, candidate); + if (!candidate.control || !candidate.data) return undefined; + this.candidates.delete(key); + return { sessionId: hello.sessionId, generation: hello.generation, control: candidate.control, data: candidate.data }; + } + + public discard(connection: unknown) { + for (const [key, candidate] of this.candidates) { + if (candidate.control === connection || candidate.data === connection) this.candidates.delete(key); + } + } +} + +export type ExecutionCommandV2 = "pause" | "continue" | "step" | "next" | "step_out"; + +export interface StopPresentationV2 { + reason: "breakpoint" | "exception"; + text?: string; +} + +export function stopPresentationV2(error: string, isError: boolean): StopPresentationV2 { + if (!isError) return { reason: "breakpoint" }; + return error ? { reason: "exception", text: error } : { reason: "exception" }; +} + +export class DebugSessionStateV2 { + public sessionId = 0n; + public generation = 0; + public stopId = 0n; + public paused = false; + private nextRequestId = 1n; + private sequences = [0n, 0n]; + private completedAcks = new Set(); + + public activate(pair: DebugPairV2) { + if (this.sessionId !== 0n && pair.sessionId !== this.sessionId) throw new Error("Debugger v2 session id changed during reconnect"); + if (this.generation !== 0 && pair.generation <= this.generation) throw new Error("Debugger v2 generation did not increase"); + this.sessionId = pair.sessionId; + this.generation = pair.generation; + this.sequences = [1n, 1n]; + } + + public nextEnvelope(channel: DebugChannelV2, type: string, payload: any[] = [], stopId = this.stopId): DebugEnvelopeV2 { + const requestId = this.nextRequestId++; + return this.envelope(channel, type, payload, requestId, stopId); + } + + public envelope(channel: DebugChannelV2, type: string, payload: any[], requestId: bigint, stopId: bigint): DebugEnvelopeV2 { + this.sequences[channel] += 1n; + return { sessionId: this.sessionId, generation: this.generation, channel, sequence: this.sequences[channel], requestId, stopId, type, payload }; + } + + public receive(message: DebugEnvelopeV2): "accepted" | "duplicate" { + if (message.sessionId !== this.sessionId || message.generation !== this.generation) throw new Error("Debugger v2 message belongs to a stale session"); + if (message.stopId !== 0n && message.stopId < this.stopId) throw new Error("Debugger v2 message belongs to a stale stop"); + if (message.type === "stopped") { + this.stopId = message.stopId; + this.paused = true; + } + if (message.type === "running") this.paused = false; + if (message.type === "ack") { + if (this.completedAcks.has(message.requestId)) return "duplicate"; + this.completedAcks.add(message.requestId); + } + return "accepted"; + } + + public validateExecution(command: ExecutionCommandV2) { + if (command !== "pause" && !this.paused) throw new Error(`Cannot ${command}: debugger is not paused`); + } +} diff --git a/src/debugger/godot4/debug_session_v2.ts b/src/debugger/godot4/debug_session_v2.ts new file mode 100644 index 000000000..dbdb3fde3 --- /dev/null +++ b/src/debugger/godot4/debug_session_v2.ts @@ -0,0 +1,204 @@ +import * as fs from "node:fs"; +import { Breakpoint, InitializedEvent, LoggingDebugSession, Source, StoppedEvent, TerminatedEvent, Thread } from "@vscode/debugadapter"; +import { DebugProtocol } from "@vscode/debugprotocol"; +import { Subject } from "await-notify"; +import { GodotDebugData, GodotVariable } from "../debug_runtime"; +import { AttachRequestArguments, LaunchRequestArguments } from "../debugger"; +import { InspectorProvider } from "../inspector_provider"; +import { stopPresentationV2 } from "./session_state_v2"; +import { SceneTreeProvider } from "../scene_tree_provider"; +import { ServerController } from "./server_controller_v2"; +import { RawObject } from "./variables/variants"; + +export class GodotDebugSessionV2 extends LoggingDebugSession { + public controller = new ServerController(this); + public debug_data = new GodotDebugData(this); + public sceneTree: SceneTreeProvider; + public inspector: InspectorProvider; + public inspect_callbacks = new Map void>(); + private configurationDone = new Subject(); + private mode: "launch" | "attach" | "" = ""; + private nextReference = 1; + private referenceToHandle = new Map(); + private handleToReference = new Map(); + private requestCancellations = new Map(); + + public constructor() { + super(); + this.setDebuggerLinesStartAt1(false); + this.setDebuggerColumnsStartAt1(false); + } + + public dispose() { this.controller.stop(); } + + protected initializeRequest(response: DebugProtocol.InitializeResponse) { + response.body = response.body || {}; + response.body.supportsConfigurationDoneRequest = true; + response.body.supportsTerminateRequest = true; + response.body.supportsEvaluateForHovers = false; + response.body.supportsStepBack = false; + response.body.supportsFunctionBreakpoints = false; + response.body.supportsConditionalBreakpoints = false; + response.body.supportsCancelRequest = true; + (response.body as any).supportsVariablePaging = true; + this.sendResponse(response); + this.sendEvent(new InitializedEvent()); + } + + protected async launchRequest(response: DebugProtocol.LaunchResponse, args: LaunchRequestArguments) { + await this.configurationDone.wait(1000); + this.mode = "launch"; + this.debug_data.projectPath = args.project; + try { await this.controller.launch(args); this.sendResponse(response); } catch (error) { this.fail(response, error); } + } + + protected async attachRequest(response: DebugProtocol.AttachResponse, args: AttachRequestArguments) { + await this.configurationDone.wait(1000); + this.mode = "attach"; + try { await this.controller.attach(args); this.sendResponse(response); } catch (error) { this.fail(response, error); } + } + + public configurationDoneRequest(response: DebugProtocol.ConfigurationDoneResponse) { + this.configurationDone.notify(); + this.sendResponse(response); + } + + public onStopped(_stopId: bigint, _canContinue: boolean, error: string, isError: boolean) { + this.nextReference = 1; + this.referenceToHandle.clear(); + this.handleToReference.clear(); + const presentation = stopPresentationV2(error, isError); + this.sendEvent(new StoppedEvent(presentation.reason, 0, presentation.text)); + } + + protected async continueRequest(response: DebugProtocol.ContinueResponse) { + response.body = { allThreadsContinued: true }; + await this.execution(response, () => this.controller.continue()); + } + protected async nextRequest(response: DebugProtocol.NextResponse) { await this.execution(response, () => this.controller.next()); } + protected async pauseRequest(response: DebugProtocol.PauseResponse) { await this.execution(response, () => this.controller.break()); } + protected async stepInRequest(response: DebugProtocol.StepInResponse) { await this.execution(response, () => this.controller.step()); } + protected async stepOutRequest(response: DebugProtocol.StepOutResponse) { await this.execution(response, () => this.controller.step_out()); } + + private async execution(response: DebugProtocol.Response, action: () => Promise) { + try { + const latency = await action(); + response.message = `Godot acknowledged in ${latency.toFixed(1)} ms`; + this.sendResponse(response); + } catch (error) { this.fail(response, error); } + } + + protected async stackTraceRequest(response: DebugProtocol.StackTraceResponse, args: DebugProtocol.StackTraceArguments) { + const cancellation = this.beginCancellable(response); + try { + const payload = await this.controller.request_stack_trace(args.startFrame ?? 0, args.levels ?? 100, cancellation.signal); + const frames = Array.isArray(payload[1]) ? payload[1] : []; + response.body = { + totalFrames: Number(payload[0] ?? 0), + stackFrames: frames.map((frame: any[]) => ({ + id: Number(frame[0]), name: String(frame[3]), line: Number(frame[2]), column: 1, + source: new Source(String(frame[1]), `${this.debug_data.projectPath}/${String(frame[1]).replace("res://", "")}`), + })), + }; + this.sendResponse(response); + } catch (error) { this.fail(response, error); } finally { this.requestCancellations.delete(response.request_seq); } + } + + protected async scopesRequest(response: DebugProtocol.ScopesResponse, args: DebugProtocol.ScopesArguments) { + const cancellation = this.beginCancellable(response); + try { + const payload = await this.controller.request_scopes(args.frameId, cancellation.signal); + const scopes = Array.isArray(payload[0]) ? payload[0] : []; + response.body = { scopes: scopes.map((scope: any[]) => ({ name: String(scope[0]), variablesReference: this.reference(BigInt(scope[1])), expensive: Boolean(scope[2]) })) }; + this.sendResponse(response); + } catch (error) { this.fail(response, error); } finally { this.requestCancellations.delete(response.request_seq); } + } + + protected async variablesRequest(response: DebugProtocol.VariablesResponse, args: DebugProtocol.VariablesArguments) { + const handle = this.referenceToHandle.get(args.variablesReference); + if (handle === undefined) { response.body = { variables: [] }; this.sendResponse(response); return; } + const cancellation = this.beginCancellable(response); + try { + const payload = await this.controller.request_variables(handle, args.start ?? 0, args.count ?? 100, cancellation.signal); + const items = Array.isArray(payload[2]) ? payload[2] : []; + response.body = { variables: items.map((item: any[]) => this.variable(item)) }; + this.sendResponse(response); + } catch (error) { this.fail(response, error); } finally { this.requestCancellations.delete(response.request_seq); } + } + + protected cancelRequest(response: DebugProtocol.CancelResponse, args: DebugProtocol.CancelArguments) { + if (args.requestId !== undefined) this.requestCancellations.get(args.requestId)?.abort(); + this.sendResponse(response); + } + + private beginCancellable(response: DebugProtocol.Response) { + const cancellation = new AbortController(); + this.requestCancellations.set(response.request_seq, cancellation); + return cancellation; + } + + private variable(item: any[]): DebugProtocol.Variable { + const handle = BigInt(item[3]); + const indexed = Number(item[4]); + const named = Number(item[5]); + return { + name: String(item[0]), type: String(item[1]), value: String(item[2]), + variablesReference: handle === 0n ? 0 : this.reference(handle), + indexedVariables: indexed || undefined, namedVariables: named || undefined, + }; + } + + private reference(handle: bigint) { + const existing = this.handleToReference.get(handle); + if (existing !== undefined) return existing; + const reference = this.nextReference++; + this.handleToReference.set(handle, reference); + this.referenceToHandle.set(reference, handle); + return reference; + } + + protected setBreakPointsRequest(response: DebugProtocol.SetBreakpointsResponse, args: DebugProtocol.SetBreakpointsArguments) { + const path = args.source.path?.replace(/\\/g, "/"); + if (!path || !fs.existsSync(path)) { response.body = { breakpoints: [] }; this.sendResponse(response); return; } + const requested = args.lines || []; + for (const breakpoint of this.debug_data.get_breakpoints(path)) if (!requested.includes(breakpoint.line)) this.debug_data.remove_breakpoint(path, breakpoint.line); + const existing = this.debug_data.get_breakpoints(path).map((breakpoint) => breakpoint.line); + for (const line of requested) { + const item = args.breakpoints?.find((breakpoint) => breakpoint.line === line); + if (!existing.includes(line) && !item?.condition) this.debug_data.set_breakpoint(path, line); + } + const breakpoints = this.debug_data.get_breakpoints(path).sort((a, b) => a.line - b.line); + response.body = { breakpoints: breakpoints.map((breakpoint) => new Breakpoint(true, breakpoint.line, 1, new Source(breakpoint.file.split("/").pop() || "", breakpoint.file))) }; + this.sendResponse(response); + } + + protected threadsRequest(response: DebugProtocol.ThreadsResponse) { response.body = { threads: [new Thread(0, "Main Thread")] }; this.sendResponse(response); } + protected evaluateRequest(response: DebugProtocol.EvaluateResponse) { + response.success = false; + response.message = "Evaluation is disabled in protocol v2; expand the required scope lazily."; + response.body = { result: "null", variablesReference: 0 }; + this.sendResponse(response); + } + protected terminateRequest(response: DebugProtocol.TerminateResponse) { + if (this.mode === "launch") { this.controller.stop(); this.sendEvent(new TerminatedEvent()); } + this.sendResponse(response); + } + + public acceptInspection(objectId: bigint, payload: any[]) { + const id = BigInt(payload[0] ?? objectId); + const className = String(payload[1] ?? "Object"); + const properties = Array.isArray(payload[2]) ? payload[2] : []; + const object = new RawObject(className); + for (const property of properties) if (Array.isArray(property) && property.length >= 6) object.set(property[0], property[5]); + const variable: GodotVariable = { name: "", value: object }; + variable.sub_values = [...object.entries()].map(([name, value]) => ({ name: String(name), value })); + this.inspect_callbacks.get(id)?.(className, variable); + this.inspect_callbacks.delete(id); + } + + private fail(response: DebugProtocol.Response, error: unknown) { + response.success = false; + response.message = error instanceof Error ? error.message : String(error); + this.sendResponse(response); + } +} diff --git a/src/debugger/godot4/protocol_v2.test.ts b/src/debugger/godot4/protocol_v2.test.ts new file mode 100644 index 000000000..e407e1668 --- /dev/null +++ b/src/debugger/godot4/protocol_v2.test.ts @@ -0,0 +1,23 @@ +import { strict as assert } from "node:assert"; +import { DebugChannelV2, DebugEnvelopeV2, DebugPacketStreamV2, decodeEnvelopeV2, encodeEnvelopeV2 } from "./protocol_v2"; + +suite("Godot 4 debugger protocol v2", () => { + const message: DebugEnvelopeV2 = { sessionId: 9_007_199_254_740_993n, generation: 2, channel: DebugChannelV2.Control, sequence: 7n, requestId: 5n, stopId: 3n, type: "next", payload: [true, "ok"] }; + + test("round trips an envelope without losing 64-bit ids", () => { + const stream = new DebugPacketStreamV2(); + assert.deepEqual(stream.push(encodeEnvelopeV2(message)), [message]); + }); + + test("handles partial and coalesced TCP buffers", () => { + const first = encodeEnvelopeV2(message); + const second = encodeEnvelopeV2({ ...message, sequence: 8n, requestId: 6n }); + const stream = new DebugPacketStreamV2(); + assert.deepEqual(stream.push(first.subarray(0, 3)), []); + assert.deepEqual(stream.push(Buffer.concat([first.subarray(3), second])).map((item) => item.requestId), [5n, 6n]); + }); + + test("rejects incompatible versions", () => { + assert.throws(() => decodeEnvelopeV2(["GDDBG2", 3, 0, 1, 1, 0, 1, 1, 0, "hello", []]), /incompatible/); + }); +}); diff --git a/src/debugger/godot4/protocol_v2.ts b/src/debugger/godot4/protocol_v2.ts new file mode 100644 index 000000000..a7fcf7203 --- /dev/null +++ b/src/debugger/godot4/protocol_v2.ts @@ -0,0 +1,106 @@ +import { VariantDecoder } from "./variables/variant_decoder"; +import { VariantEncoder } from "./variables/variant_encoder"; + +export const DEBUG_PROTOCOL_V2_MAGIC = "GDDBG2"; +export const DEBUG_PROTOCOL_V2_MAJOR = 2; +export const DEBUG_PROTOCOL_V2_MINOR = 0; +export const DEBUG_PROTOCOL_V2_FIELDS = 11; + +export enum DebugChannelV2 { + Control = 0, + Data = 1, +} + +export interface DebugEnvelopeV2 { + sessionId: bigint; + generation: number; + channel: DebugChannelV2; + sequence: bigint; + requestId: bigint; + stopId: bigint; + type: string; + payload: any[]; +} + +function integer(value: unknown, field: string): bigint { + if (typeof value === "bigint") return value; + if (typeof value === "number" && Number.isSafeInteger(value)) return BigInt(value); + throw new Error(`Debugger v2 ${field} must be an integer`); +} + +export function decodeEnvelopeV2(packet: unknown): DebugEnvelopeV2 { + if (!Array.isArray(packet) || packet.length !== DEBUG_PROTOCOL_V2_FIELDS) { + throw new Error("Debugger v2 envelope has an invalid field count"); + } + if (packet[0] !== DEBUG_PROTOCOL_V2_MAGIC) throw new Error("Debugger v2 envelope has invalid magic"); + if (integer(packet[1], "major") !== BigInt(DEBUG_PROTOCOL_V2_MAJOR) || integer(packet[2], "minor") !== BigInt(DEBUG_PROTOCOL_V2_MINOR)) { + throw new Error("Debugger v2 envelope has an incompatible protocol version"); + } + const channel = Number(integer(packet[5], "channel")); + if (channel !== DebugChannelV2.Control && channel !== DebugChannelV2.Data) { + throw new Error("Debugger v2 envelope has an invalid channel"); + } + if (typeof packet[9] !== "string" || !Array.isArray(packet[10])) { + throw new Error("Debugger v2 envelope has an invalid body"); + } + return { + sessionId: integer(packet[3], "session id"), + generation: Number(integer(packet[4], "generation")), + channel, + sequence: integer(packet[6], "sequence"), + requestId: integer(packet[7], "request id"), + stopId: integer(packet[8], "stop id"), + type: packet[9], + payload: packet[10], + }; +} + +export function encodeEnvelopeV2(message: DebugEnvelopeV2): Buffer { + const packet = [ + DEBUG_PROTOCOL_V2_MAGIC, + DEBUG_PROTOCOL_V2_MAJOR, + DEBUG_PROTOCOL_V2_MINOR, + message.sessionId, + message.generation, + message.channel, + message.sequence, + message.requestId, + message.stopId, + message.type, + message.payload, + ]; + return new VariantEncoder().encode_variant(packet); +} + +/** Incrementally decodes PacketPeerStream frames, including split and coalesced TCP chunks. */ +export class DebugPacketStreamV2 { + private buffer: Buffer = Buffer.alloc(0); + private readonly decoder = new VariantDecoder(); + + public constructor(private readonly maxPacketBytes = 16 * 1024 * 1024) {} + + public push(chunk: Buffer): DebugEnvelopeV2[] { + if (chunk.length === 0) return []; + this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]); + const messages: DebugEnvelopeV2[] = []; + while (this.buffer.length >= 4) { + const bodyLength = this.buffer.readUInt32LE(0); + if (bodyLength <= 0 || bodyLength > this.maxPacketBytes) { + this.buffer = Buffer.alloc(0); + throw new Error(`Debugger v2 packet length ${bodyLength} is invalid`); + } + const packetLength = bodyLength + 4; + if (this.buffer.length < packetLength) break; + const packet = this.buffer.subarray(0, packetLength); + this.buffer = this.buffer.subarray(packetLength); + const dataset = this.decoder.get_dataset(packet); + if (!dataset || dataset.length !== 2) throw new Error("Debugger v2 packet must contain exactly one Variant"); + messages.push(decodeEnvelopeV2(dataset[1])); + } + return messages; + } + + public reset() { + this.buffer = Buffer.alloc(0); + } +} diff --git a/src/debugger/godot4/server_controller_v2.ts b/src/debugger/godot4/server_controller_v2.ts new file mode 100644 index 000000000..9131436c7 --- /dev/null +++ b/src/debugger/godot4/server_controller_v2.ts @@ -0,0 +1,384 @@ +import { StoppedEvent, TerminatedEvent } from "@vscode/debugadapter"; +import { DebugProtocol } from "@vscode/debugprotocol"; +import * as fs from "node:fs"; +import * as net from "node:net"; +import { debug, window } from "vscode"; +import BBCodeToAnsi from "bbcode-to-ansi"; + +import { ansi, convert_resource_path_to_uri, createLogger, get_configuration, get_free_port, get_project_version, verify_godot_version, VERIFY_RESULT } from "../../utils"; +import { prompt_for_godot_executable } from "../../utils/prompts"; +import { killSubProcesses, subProcess } from "../../utils/subspawn"; +import { LaunchRequestArguments, AttachRequestArguments, pinnedScene } from "../debugger"; +import { parse_next_scene_node } from "./helpers"; +import { DebugChannelV2, DebugEnvelopeV2, DebugPacketStreamV2, encodeEnvelopeV2 } from "./protocol_v2"; +import { DebugChannelPairerV2, DebugSessionStateV2, ExecutionCommandV2 } from "./session_state_v2"; +import type { GodotDebugSessionV2 } from "./debug_session_v2"; + +const log = createLogger("debugger.controller.v2", { output: "Godot Debugger" }); +const socketLog = createLogger("debugger.socket.v2"); +const bbcodeParser = new BBCodeToAnsi("\u001b[38;2;211;211;211m"); +const traceEnabled = process.env.GODOT_V2_TRACE === "1"; + +interface SocketContextV2 { + socket: net.Socket; + stream: DebugPacketStreamV2; + hello?: DebugEnvelopeV2; +} + +interface PendingControlV2 { + command: ExecutionCommandV2; + started: number; + resolve: (latencyMs: number) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; +} + +interface PendingDataV2 { + resolve: (message: DebugEnvelopeV2) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; + cleanup: () => void; +} + +export class ServerController { + private server?: net.Server; + private controlSocket?: net.Socket; + private dataSocket?: net.Socket; + private contexts = new Map(); + private pairer = new DebugChannelPairerV2(); + private state = new DebugSessionStateV2(); + private pendingControl = new Map(); + private pendingData = new Map(); + private heartbeat?: NodeJS.Timeout; + private stopping = false; + + public constructor(public session: GodotDebugSessionV2) {} + + public get currentStopId() { return this.state.stopId; } + public get isPaused() { return this.state.paused; } + + public break() { return this.execute("pause"); } + public continue() { return this.execute("continue"); } + public next() { return this.execute("next"); } + public step() { return this.execute("step"); } + public step_out() { return this.execute("step_out"); } + + public set_breakpoint(path: string, line: number) { + void this.requestData("breakpoint", [path, line, true], 0n).catch((error) => log.warn(error.message)); + } + + public remove_breakpoint(path: string, line: number) { + this.session.debug_data.remove_breakpoint(path, line); + void this.requestData("breakpoint", [path, line, false], 0n).catch((error) => log.warn(error.message)); + } + + public async request_stack_trace(start = 0, count = 100, signal?: AbortSignal) { + return (await this.requestData("stack_trace", [start, count], this.state.stopId, 5000, signal)).payload; + } + + public async request_scopes(frameId: number, signal?: AbortSignal) { + return (await this.requestData("scopes", [frameId], this.state.stopId, 5000, signal)).payload; + } + + public async request_variables(handle: bigint, start = 0, count = 100, signal?: AbortSignal) { + return (await this.requestData("variables", [handle, start, count], this.state.stopId, 5000, signal)).payload; + } + + public request_inspect_object(objectId: bigint) { + void this.requestData("inspect_object_id", [objectId], this.state.paused ? this.state.stopId : 0n).then((message) => { + this.session.acceptInspection(objectId, message.payload); + }).catch((error) => log.warn(error.message)); + } + + public request_scene_tree() { + void this.requestData("scene_snapshot", [], this.state.paused ? this.state.stopId : 0n).then((message) => { + const treeData = message.payload[1]; + if (Array.isArray(treeData)) this.session.sceneTree.fill_tree(parse_next_scene_node(treeData)); + }).catch((error) => log.warn(error.message)); + } + + public set_object_property(objectId: bigint, label: string, value: any) { + void this.requestData("set_object_property", [objectId, label, value], this.state.paused ? this.state.stopId : 0n).catch((error) => log.warn(error.message)); + } + + public async launch(args: LaunchRequestArguments) { + log.info("Starting Godot 4 debugger protocol v2 in launch mode"); + await this.listen(args); + await this.startGame(args); + } + + public async attach(args: AttachRequestArguments) { + log.info("Starting Godot 4 debugger protocol v2 in attach mode"); + await this.listen(args); + } + + private async listen(args: AttachRequestArguments) { + if (args.port === -1) args.port = await get_free_port(); + this.server = net.createServer((socket) => this.acceptSocket(socket)); + await new Promise((resolve, reject) => { + this.server?.once("error", reject); + this.server?.listen(args.port, args.address, () => { + this.server?.off("error", reject); + resolve(); + }); + }); + } + + private acceptSocket(socket: net.Socket) { + if (traceEnabled) console.log("Godot 4 v2 socket accepted"); + socket.setNoDelay(true); + const context: SocketContextV2 = { socket, stream: new DebugPacketStreamV2() }; + this.contexts.set(socket, context); + socket.on("data", (chunk) => { + if (traceEnabled) console.log(`Godot 4 v2 received ${chunk.length} bytes: ${chunk.subarray(0, 24).toString("hex")}`); + try { + for (const message of context.stream.push(chunk)) this.receive(context, message); + } catch (error) { + if (traceEnabled) console.error("Godot 4 v2 decode failed:", error); + log.error(error as Error); + socket.destroy(); + } + }); + socket.on("close", () => this.socketClosed(context)); + socket.on("error", (error) => log.warn(`Debugger v2 socket error: ${error.message}`)); + } + + private receive(context: SocketContextV2, message: DebugEnvelopeV2) { + if (!context.hello) { + if (traceEnabled) console.log(`Godot 4 v2 hello: ${message.type}`); + if (message.type !== "control_hello" && message.type !== "data_hello") throw new Error("Debugger v2 channel did not begin with hello"); + context.hello = message; + const pair = this.pairer.accept(message, context.socket); + const ack: DebugEnvelopeV2 = { sessionId: message.sessionId, generation: message.generation, channel: message.channel, sequence: 1n, requestId: 1n, stopId: 0n, type: "hello_ack", payload: [250, 1000, 100] }; + context.socket.write(encodeEnvelopeV2(ack)); + if (pair) { + this.state.activate(pair); + this.controlSocket = pair.control as net.Socket; + this.dataSocket = pair.data as net.Socket; + this.startHeartbeat(); + log.info(`Debugger v2 paired session ${pair.sessionId}, generation ${pair.generation}`); + } + return; + } + + if (context.socket !== this.controlSocket && context.socket !== this.dataSocket) return; + if (this.state.receive(message) === "duplicate") return; + socketLog.debug("rx:", [message.type, message.requestId.toString(), message.stopId.toString()]); + if (message.channel === DebugChannelV2.Control) this.receiveControl(message); + else this.receiveData(message); + } + + private receiveControl(message: DebugEnvelopeV2) { + if (message.type === "ack") { + const pending = this.pendingControl.get(message.requestId); + if (!pending) return; + this.pendingControl.delete(message.requestId); + clearTimeout(pending.timer); + const accepted = Boolean(message.payload[0]); + const reason = String(message.payload[2] ?? "rejected"); + if (!accepted) pending.reject(new Error(`Godot rejected ${pending.command}: ${reason}`)); + else { + if (pending.command !== "pause") this.state.paused = false; + pending.resolve(performance.now() - pending.started); + } + return; + } + if (message.type === "stopped") { + if (traceEnabled) console.log(`Godot 4 v2 stopped: ${message.stopId}`); + this.session.onStopped(message.stopId, Boolean(message.payload[0]), String(message.payload[1] ?? ""), Boolean(message.payload[2])); + return; + } + if (message.type === "diagnostic") this.stderr(`[debugger] ${message.payload.join(" ")}\n`); + } + + private receiveData(message: DebugEnvelopeV2) { + if (traceEnabled) console.log(`Godot 4 v2 data ${message.type}: request=${message.requestId}, fields=${message.payload.length}`); + if (message.requestId !== 0n) { + const pending = this.pendingData.get(message.requestId); + if (pending) { + this.pendingData.delete(message.requestId); + clearTimeout(pending.timer); + pending.cleanup(); + if (message.type === "request_error") pending.reject(new Error(`${message.payload[0]}: ${message.payload[1]}`)); + else pending.resolve(message); + return; + } + } + if (message.type === "output") { + const output = Array.isArray(message.payload[0]) ? message.payload[0][0] : message.payload[0]; + for (const line of String(output ?? "").split("\n")) debug.activeDebugConsole.appendLine(bbcodeParser.parse(line)); + } else if (message.type === "error") { + void this.handleError(message.payload); + } else if (message.type === "data_drop_summary") { + this.stderr(`[debugger] dropped ${message.payload[0]} data messages (${message.payload[1]} bytes); queue=${message.payload[2]} bytes\n`); + } else if (message.type === "noncontinuable_error") { + const top = Array.isArray(message.payload[1]) && message.payload[1].length ? ` at ${message.payload[1][0][0]}:${message.payload[1][0][1]}` : ""; + this.stderr(`[debugger] non-continuable script error (execution was not left paused): ${message.payload[0]}${top}\n`); + } else if (message.type === "kill_me") { + this.abort(); + } + } + + private execute(command: ExecutionCommandV2): Promise { + try { this.state.validateExecution(command); } catch (error) { return Promise.reject(error); } + if (!this.controlSocket) return Promise.reject(new Error("Debugger v2 control channel is not connected")); + const message = this.state.nextEnvelope(DebugChannelV2.Control, command, [], command === "pause" ? 0n : this.state.stopId); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingControl.delete(message.requestId); + reject(new Error(`Timed out waiting for Godot ${command} acknowledgement`)); + }, 1250); + this.pendingControl.set(message.requestId, { command, started: performance.now(), resolve, reject, timer }); + this.write(this.controlSocket as net.Socket, message); + }); + } + + private requestData(type: string, payload: any[], stopId = this.state.stopId, timeoutMs = 5000, signal?: AbortSignal): Promise { + if (!this.dataSocket) return Promise.reject(new Error("Debugger v2 data channel is not connected")); + if (signal?.aborted) return Promise.reject(new Error(`Godot ${type} request was cancelled`)); + const message = this.state.nextEnvelope(DebugChannelV2.Data, type, payload, stopId); + return new Promise((resolve, reject) => { + const cancel = () => { + const pending = this.pendingData.get(message.requestId); + if (!pending) return; + this.pendingData.delete(message.requestId); + clearTimeout(pending.timer); + pending.cleanup(); + reject(new Error(`Godot ${type} request was cancelled`)); + }; + const cleanup = () => signal?.removeEventListener("abort", cancel); + const timer = setTimeout(() => { + this.pendingData.delete(message.requestId); + cleanup(); + reject(new Error(`Timed out waiting for Godot ${type} response`)); + }, timeoutMs); + this.pendingData.set(message.requestId, { resolve, reject, timer, cleanup }); + signal?.addEventListener("abort", cancel, { once: true }); + this.write(this.dataSocket as net.Socket, message); + }); + } + + private write(socket: net.Socket, message: DebugEnvelopeV2) { + socketLog.debug("tx:", [message.type, message.requestId.toString(), message.stopId.toString()]); + socket.write(encodeEnvelopeV2(message)); + } + + private startHeartbeat() { + if (this.heartbeat) clearInterval(this.heartbeat); + this.heartbeat = setInterval(() => { + if (!this.controlSocket || this.controlSocket.destroyed) return; + const message = this.state.envelope(DebugChannelV2.Control, "heartbeat", [], 0n, this.state.stopId); + this.write(this.controlSocket, message); + }, 250); + } + + private socketClosed(context: SocketContextV2) { + this.contexts.delete(context.socket); + this.pairer.discard(context.socket); + if (context.socket === this.controlSocket) this.controlSocket = undefined; + if (context.socket === this.dataSocket) this.dataSocket = undefined; + if (!this.controlSocket || !this.dataSocket) { + if (this.heartbeat) clearInterval(this.heartbeat); + this.heartbeat = undefined; + this.rejectPending(new Error("Debugger v2 channel disconnected; waiting for engine reconnect")); + } + } + + private rejectPending(error: Error) { + for (const pending of this.pendingControl.values()) { clearTimeout(pending.timer); pending.reject(error); } + for (const pending of this.pendingData.values()) { clearTimeout(pending.timer); pending.cleanup(); pending.reject(error); } + this.pendingControl.clear(); + this.pendingData.clear(); + } + + private async startGame(args: LaunchRequestArguments) { + const { godotPath } = await this.resolveGodot(args); + let command = `"${godotPath}" --path "${args.project}"`; + command += ` --remote-debug "debugv2://${args.address.replace("tcp://", "")}:${args.port}"`; + if (args.profiling) command += " --profiling"; + if (args.single_threaded_scene) command += " --single-threaded-scene"; + if (args.debug_collisions) command += " --debug-collisions"; + if (args.debug_paths) command += " --debug-paths"; + if (args.debug_navigation) command += " --debug-navigation"; + if (args.debug_avoidance) command += " --debug-avoidance"; + if (args.debug_stringnames) command += " --debug-stringnames"; + if (args.disable_vsync) command += " --disable-vsync"; + if (args.frame_delay) command += ` --frame-delay ${args.frame_delay}`; + if (args.time_scale) command += ` --time-scale ${args.time_scale}`; + if (args.fixed_fps) command += ` --fixed-fps ${args.fixed_fps}`; + if (args.scene && args.scene !== "main") command += ` "${this.resolveScene(args.scene)}"`; + command += this.session.debug_data.get_breakpoint_string(); + if (args.additional_options) command += ` ${args.additional_options}`; + log.info(`Launching game process using command: '${command}'`); + if (traceEnabled) console.log(`Godot 4 v2 launch: ${command}`); + const child = subProcess("debug", command, { shell: true, detached: true }); + child.stdout.on("data", (data) => { if (traceEnabled) console.log(`Godot 4 v2 stdout: ${data}`); }); + child.stderr.on("data", (data) => { if (traceEnabled) console.error(`Godot 4 v2 stderr: ${data}`); }); + child.on("close", (code) => { if (traceEnabled) console.log(`Godot 4 v2 exited: ${code}`); if (!this.stopping) this.abort(); }); + } + + private async resolveGodot(args: LaunchRequestArguments): Promise<{ godotPath: string; result: VERIFY_RESULT }> { + const settingName = "editorPath.godot4"; + const requested = args.editor_path || get_configuration(settingName); + const result = verify_godot_version(requested, "4"); + if (result.status === "WRONG_VERSION") { + const projectVersion = await get_project_version(); + const message = `Cannot launch debug session: project uses Godot v${projectVersion}, executable is v${result.version}`; + if (args.editor_path) window.showErrorMessage(message, "Ok"); else prompt_for_godot_executable(message, settingName); + throw new Error(message); + } + if (result.status === "INVALID_EXE") { + const message = `Cannot launch debug session: '${result.godotPath}' is not a valid Godot executable`; + if (args.editor_path) window.showErrorMessage(message, "Ok"); else prompt_for_godot_executable(message, settingName); + throw new Error(message); + } + return { godotPath: result.godotPath, result }; + } + + private resolveScene(scene: string) { + if (scene === "pinned") { + if (!pinnedScene) throw new Error("No pinned scene found"); + return pinnedScene.fsPath.endsWith(".gd") ? pinnedScene.fsPath.replace(/\.gd$/, ".tscn") : pinnedScene.fsPath; + } + if (scene === "current") { + const current = window.activeTextEditor?.document.fileName; + if (!current) throw new Error("No active editor. Open a file to launch it."); + const resolved = current.endsWith(".gd") ? current.replace(/\.gd$/, ".tscn") : current; + if (!fs.existsSync(resolved)) throw new Error(`Can't find associated scene file for ${resolved}`); + return resolved; + } + return scene; + } + + private async handleError(payload: any[]) { + const params = payload[0] ?? []; + const warning = Boolean(params[9]); + const time = `${params[0]}:${params[1]}:${params[2]}.${params[3]}`; + const location = `${params[4]}:${params[6]} @ ${params[5]}()`; + const uri = await convert_resource_path_to_uri(params[4]); + const extras = { source: { name: uri?.toString() ?? "" }, line: params[6], group: "startCollapsed" }; + this.stderr(`${warning ? ansi.yellow : ansi.red}${time} | ${params[8] || params[7]}\n`, extras); + this.stderr(`${ansi.dim.white} ${ansi.white}${location}\n`, { group: "end" }); + } + + private stderr(output = "", extra = {}) { + this.session.sendEvent({ event: "output", body: { category: "stderr", output: output + ansi.reset, ...extra } } as DebugProtocol.OutputEvent); + } + + public abort() { + if (this.stopping) return; + this.session.sendEvent(new TerminatedEvent()); + this.stop(); + } + + public stop() { + this.stopping = true; + if (this.heartbeat) clearInterval(this.heartbeat); + this.rejectPending(new Error("Debugger stopped")); + killSubProcesses("debug"); + for (const context of this.contexts.values()) context.socket.destroy(); + this.contexts.clear(); + this.server?.close(); + this.server = undefined; + } +} diff --git a/src/debugger/godot4/session_state_v2.test.ts b/src/debugger/godot4/session_state_v2.test.ts new file mode 100644 index 000000000..5eace9c59 --- /dev/null +++ b/src/debugger/godot4/session_state_v2.test.ts @@ -0,0 +1,51 @@ +import { strict as assert } from "node:assert"; +import { DebugChannelV2, DebugEnvelopeV2 } from "./protocol_v2"; +import { DebugChannelPairerV2, DebugSessionStateV2, stopPresentationV2 } from "./session_state_v2"; + +function hello(channel: DebugChannelV2, generation = 1): DebugEnvelopeV2 { + return { sessionId: 42n, generation, channel, sequence: 1n, requestId: 1n, stopId: 0n, type: channel === DebugChannelV2.Control ? "control_hello" : "data_hello", payload: [] }; +} + +suite("Godot 4 debugger session v2", () => { + test("pairs only matching control and data channels", () => { + const pairer = new DebugChannelPairerV2(); + assert.equal(pairer.accept(hello(DebugChannelV2.Control), "control"), undefined); + assert.deepEqual(pairer.accept(hello(DebugChannelV2.Data), "data"), { sessionId: 42n, generation: 1, control: "control", data: "data" }); + }); + + test("rejects duplicate channels", () => { + const pairer = new DebugChannelPairerV2(); + pairer.accept(hello(DebugChannelV2.Control), "a"); + assert.throws(() => pairer.accept(hello(DebugChannelV2.Control), "b"), /Duplicate/); + }); + + test("deduplicates acknowledgements and rejects stale stops", () => { + const state = new DebugSessionStateV2(); + state.activate({ sessionId: 42n, generation: 1, control: {}, data: {} }); + const stopped = { ...hello(DebugChannelV2.Control), type: "stopped", requestId: 0n, stopId: 4n }; + assert.equal(state.receive(stopped), "accepted"); + const ack = { ...stopped, type: "ack", requestId: 8n }; + assert.equal(state.receive(ack), "accepted"); + assert.equal(state.receive(ack), "duplicate"); + assert.throws(() => state.receive({ ...stopped, type: "running", stopId: 3n }), /stale stop/); + }); + + test("requires a newer generation when reconnecting", () => { + const state = new DebugSessionStateV2(); + state.activate({ sessionId: 42n, generation: 1, control: {}, data: {} }); + assert.throws(() => state.activate({ sessionId: 42n, generation: 1, control: {}, data: {} }), /generation/); + state.activate({ sessionId: 42n, generation: 2, control: {}, data: {} }); + }); + + test("presents ordinary stops without exception text", () => { + assert.deepEqual(stopPresentationV2("Breakpoint", false), { reason: "breakpoint" }); + assert.deepEqual(stopPresentationV2("Invalid get index", true), { reason: "exception", text: "Invalid get index" }); + }); + + test("accepts step out while paused", () => { + const state = new DebugSessionStateV2(); + state.activate({ sessionId: 42n, generation: 1, control: {}, data: {} }); + state.receive({ ...hello(DebugChannelV2.Control), type: "stopped", requestId: 0n, stopId: 4n }); + assert.doesNotThrow(() => state.validateExecution("step_out")); + }); +}); diff --git a/src/debugger/godot4/session_state_v2.ts b/src/debugger/godot4/session_state_v2.ts new file mode 100644 index 000000000..4031860a0 --- /dev/null +++ b/src/debugger/godot4/session_state_v2.ts @@ -0,0 +1,96 @@ +import { DebugChannelV2, DebugEnvelopeV2 } from "./protocol_v2"; + +export interface DebugPairV2 { + sessionId: bigint; + generation: number; + control: unknown; + data: unknown; +} + +interface CandidatePairV2 { + control?: unknown; + data?: unknown; +} + +export class DebugChannelPairerV2 { + private candidates = new Map(); + + public accept(hello: DebugEnvelopeV2, connection: unknown): DebugPairV2 | undefined { + const expected = hello.channel === DebugChannelV2.Control ? "control_hello" : "data_hello"; + if (hello.type !== expected) throw new Error(`Expected ${expected}, received ${hello.type}`); + const key = `${hello.sessionId}:${hello.generation}`; + const candidate = this.candidates.get(key) ?? {}; + const role = hello.channel === DebugChannelV2.Control ? "control" : "data"; + if (candidate[role]) throw new Error(`Duplicate debugger v2 ${role} channel`); + candidate[role] = connection; + this.candidates.set(key, candidate); + if (!candidate.control || !candidate.data) return undefined; + this.candidates.delete(key); + return { sessionId: hello.sessionId, generation: hello.generation, control: candidate.control, data: candidate.data }; + } + + public discard(connection: unknown) { + for (const [key, candidate] of this.candidates) { + if (candidate.control === connection || candidate.data === connection) this.candidates.delete(key); + } + } +} + +export type ExecutionCommandV2 = "pause" | "continue" | "step" | "next" | "step_out"; + +export interface StopPresentationV2 { + reason: "breakpoint" | "exception"; + text?: string; +} + +export function stopPresentationV2(error: string, isError: boolean): StopPresentationV2 { + if (!isError) return { reason: "breakpoint" }; + return error ? { reason: "exception", text: error } : { reason: "exception" }; +} + +export class DebugSessionStateV2 { + public sessionId = 0n; + public generation = 0; + public stopId = 0n; + public paused = false; + private nextRequestId = 1n; + private sequences = [0n, 0n]; + private completedAcks = new Set(); + + public activate(pair: DebugPairV2) { + if (this.sessionId !== 0n && pair.sessionId !== this.sessionId) throw new Error("Debugger v2 session id changed during reconnect"); + if (this.generation !== 0 && pair.generation <= this.generation) throw new Error("Debugger v2 generation did not increase"); + this.sessionId = pair.sessionId; + this.generation = pair.generation; + this.sequences = [1n, 1n]; + } + + public nextEnvelope(channel: DebugChannelV2, type: string, payload: any[] = [], stopId = this.stopId): DebugEnvelopeV2 { + const requestId = this.nextRequestId++; + return this.envelope(channel, type, payload, requestId, stopId); + } + + public envelope(channel: DebugChannelV2, type: string, payload: any[], requestId: bigint, stopId: bigint): DebugEnvelopeV2 { + this.sequences[channel] += 1n; + return { sessionId: this.sessionId, generation: this.generation, channel, sequence: this.sequences[channel], requestId, stopId, type, payload }; + } + + public receive(message: DebugEnvelopeV2): "accepted" | "duplicate" { + if (message.sessionId !== this.sessionId || message.generation !== this.generation) throw new Error("Debugger v2 message belongs to a stale session"); + if (message.stopId !== 0n && message.stopId < this.stopId) throw new Error("Debugger v2 message belongs to a stale stop"); + if (message.type === "stopped") { + this.stopId = message.stopId; + this.paused = true; + } + if (message.type === "running") this.paused = false; + if (message.type === "ack") { + if (this.completedAcks.has(message.requestId)) return "duplicate"; + this.completedAcks.add(message.requestId); + } + return "accepted"; + } + + public validateExecution(command: ExecutionCommandV2) { + if (command !== "pause" && !this.paused) throw new Error(`Cannot ${command}: debugger is not paused`); + } +} diff --git a/src/test/debugger_v2.integration.ts b/src/test/debugger_v2.integration.ts new file mode 100644 index 000000000..0de70819c --- /dev/null +++ b/src/test/debugger_v2.integration.ts @@ -0,0 +1,136 @@ +import { ok } from "node:assert"; +import * as vscode from "vscode"; + +function percentile95(values: number[]) { + const ordered = [...values].sort((a, b) => a - b); + return ordered[Math.max(0, Math.ceil(ordered.length * 0.95) - 1)]; +} + +function percentile50(values: number[]) { + const ordered = [...values].sort((a, b) => a - b); + return ordered[Math.floor(ordered.length / 2)]; +} + +async function retry(operation: () => Thenable, timeoutMs = 5000): Promise { + const deadline = performance.now() + timeoutMs; + let lastError: unknown; + while (performance.now() < deadline) { + try { return await operation(); } catch (error) { lastError = error; } + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw lastError ?? new Error("Timed out waiting for debugger state"); +} + +async function waitForTopFrame(session: vscode.DebugSession, name: string, line: number, timeoutMs = 2000) { + return retry(async () => { + const stack = await session.customRequest("stackTrace", { threadId: 0, startFrame: 0, levels: 20 }); + const top = stack.stackFrames?.[0]; + if (!top || top.name !== name || top.line !== line) { + throw new Error(`Expected ${name}:${line}, got ${top ? `${top.name}:${top.line}` : "no frame"}`); + } + return stack.stackFrames; + }, timeoutMs); +} + +suite("Godot 3 debugger protocol v2 DAP", () => { + test("steps 100 times with acknowledged control and lazy variables", async () => { + const extension = vscode.extensions.getExtension("geequlim.godot-tools"); + ok(extension); + await extension.activate(); + const folder = vscode.workspace.workspaceFolders?.[0]; + ok(folder); + const godotPath = process.env.GODOT3_V2_PATH; + ok(godotPath, "GODOT3_V2_PATH is required"); + const started = await vscode.debug.startDebugging(folder, { + name: "Godot 3 protocol v2 integration", + type: "godot", + request: "launch", + debug_protocol_v2: true, + project: folder.uri.fsPath, + editor_path: godotPath, + address: "127.0.0.1", + port: 6137, + additional_options: "--no-window --audio-driver Dummy --script main.gd", + }); + ok(started); + const session = await retry(async () => { + const active = vscode.debug.activeDebugSession; + if (!active) throw new Error("Debug session is not active"); + const stack = await active.customRequest("stackTrace", { threadId: 0, startFrame: 0, levels: 100 }); + if (!stack.stackFrames?.length) throw new Error("Debugger has not stopped yet"); + return active; + }, 15_000); + + const stack = await session.customRequest("stackTrace", { threadId: 0, startFrame: 0, levels: 100 }); + const scopes = await session.customRequest("scopes", { frameId: stack.stackFrames[0].id }); + const locals = scopes.scopes.find((scope: any) => scope.name === "Locals"); + ok(locals); + const localPage = await session.customRequest("variables", { variablesReference: locals.variablesReference, start: 0, count: 100 }); + ok(localPage.variables.length <= 100); + const hugeArray = localPage.variables.find((variable: any) => variable.name === "huge_array"); + ok(hugeArray?.variablesReference > 0); + const arrayPage = await session.customRequest("variables", { variablesReference: hugeArray.variablesReference, start: 100, count: 100 }); + ok(arrayPage.variables.length === 100); + + const ackLatencies: number[] = []; + const stopLatencies: number[] = []; + for (let index = 0; index < 100; index++) { + const began = performance.now(); + await session.customRequest("next", { threadId: 0 }); + ackLatencies.push(performance.now() - began); + await retry(async () => { + const nextStack = await session.customRequest("stackTrace", { threadId: 0, startFrame: 0, levels: 1 }); + if (!nextStack.stackFrames?.length) throw new Error("No next stop yet"); + return nextStack; + }, 2000); + stopLatencies.push(performance.now() - began); + } + const ackP95 = percentile95(ackLatencies); + const stopP95 = percentile95(stopLatencies); + console.log(`Godot 3 v2 DAP: ack_p50=${percentile50(ackLatencies).toFixed(2)}ms ack_p95=${ackP95.toFixed(2)}ms ack_max=${Math.max(...ackLatencies).toFixed(2)}ms stop_p50=${percentile50(stopLatencies).toFixed(2)}ms stop_p95=${stopP95.toFixed(2)}ms stop_max=${Math.max(...stopLatencies).toFixed(2)}ms`); + ok(ackP95 <= 100, `ACK P95 ${ackP95.toFixed(2)}ms exceeds 100ms`); + ok(stopP95 <= 300, `next stop P95 ${stopP95.toFixed(2)}ms exceeds 300ms`); + await vscode.debug.stopDebugging(session); + await retry(async () => { + if (vscode.debug.activeDebugSession === session) throw new Error("Debug session is still active"); + return true; + }); + }); + + test("steps into each same-line call in evaluation order", async () => { + const folder = vscode.workspace.workspaceFolders?.[0]; + ok(folder); + const godotPath = process.env.GODOT3_V2_PATH; + ok(godotPath, "GODOT3_V2_PATH is required"); + const started = await vscode.debug.startDebugging(folder, { + name: "Godot 3 call stepping integration", + type: "godot", + request: "launch", + debug_protocol_v2: true, + project: folder.uri.fsPath, + editor_path: godotPath, + address: "127.0.0.1", + port: 6137, + additional_options: "--no-window --audio-driver Dummy --script call_stepping.gd", + }); + ok(started); + const session = await retry(async () => { + const active = vscode.debug.activeDebugSession; + if (!active) throw new Error("Debug session is not active"); + await waitForTopFrame(active, "_exercise", 22, 250); + return active; + }, 15_000); + + for (const [name, line] of [["first", 6], ["second", 10], ["combine", 14]] as const) { + await session.customRequest("stepIn", { threadId: 0 }); + const calleeFrames = await waitForTopFrame(session, name, line); + ok(calleeFrames[1]?.name === "_exercise" && calleeFrames[1]?.line === 22, `${name} lost its caller frame`); + await session.customRequest("stepOut", { threadId: 0 }); + await waitForTopFrame(session, "_exercise", 22); + } + + await session.customRequest("next", { threadId: 0 }); + await waitForTopFrame(session, "_exercise", 23); + await vscode.debug.stopDebugging(session); + }); +}); diff --git a/src/test/debugger_v2_suite.ts b/src/test/debugger_v2_suite.ts new file mode 100644 index 000000000..28904a65b --- /dev/null +++ b/src/test/debugger_v2_suite.ts @@ -0,0 +1,8 @@ +import * as path from "node:path"; +import Mocha from "mocha"; + +export function run(): Promise { + const mocha = new Mocha({ ui: "tdd", color: true, timeout: 120_000 }); + mocha.addFile(path.resolve(__dirname, "debugger_v2.integration.js")); + return new Promise((resolve, reject) => mocha.run((failures) => failures ? reject(new Error(`${failures} debugger v2 integration test(s) failed.`)) : resolve())); +} diff --git a/src/test/godot4_debugger_suite.ts b/src/test/godot4_debugger_suite.ts new file mode 100644 index 000000000..700cb0c08 --- /dev/null +++ b/src/test/godot4_debugger_suite.ts @@ -0,0 +1,8 @@ +import * as path from "node:path"; +import Mocha from "mocha"; + +export function run(): Promise { + const mocha = new Mocha({ ui: "tdd", color: true, timeout: 120_000 }); + mocha.addFile(path.resolve(__dirname, "..", "debugger", "godot4", "variables", "debugger_variables.test.js")); + return new Promise((resolve, reject) => mocha.run((failures) => failures ? reject(new Error(`${failures} Godot 4 DAP integration test(s) failed.`)) : resolve())); +} diff --git a/src/test/godot4_v2.integration.ts b/src/test/godot4_v2.integration.ts new file mode 100644 index 000000000..7e0d1aa85 --- /dev/null +++ b/src/test/godot4_v2.integration.ts @@ -0,0 +1,78 @@ +import { ok, strictEqual } from "node:assert"; +import * as vscode from "vscode"; + +async function retry(operation: () => Promise, timeoutMs = 15_000): Promise { + const deadline = performance.now() + timeoutMs; + let lastError: unknown; + while (performance.now() < deadline) { + try { return await operation(); } catch (error) { lastError = error; } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw lastError ?? new Error("Timed out waiting for Godot debugger"); +} + +suite("Godot 4.6.2 debugger protocol v2 DAP", () => { + test("pairs channels, pages variables, and acknowledges 20 steps", async () => { + const extension = vscode.extensions.getExtension("geequlim.godot-tools"); + ok(extension); + await extension.activate(); + const folder = vscode.workspace.workspaceFolders?.[0]; + ok(folder); + const executable = process.env.GODOT4_V2_PATH; + ok(executable, "GODOT4_V2_PATH is required"); + const started = await vscode.debug.startDebugging(folder, { + name: "Godot 4 protocol v2 integration", + type: "godot", + request: "launch", + project: folder.uri.fsPath, + editor_path: executable, + debug_protocol_v2: true, + address: "127.0.0.1", + port: -1, + additional_options: "--headless --script main.gd", + }); + ok(started); + const session = await retry(async () => { + const active = vscode.debug.activeDebugSession; + if (!active) throw new Error("Debug session did not start"); + const stack = await active.customRequest("stackTrace", { threadId: 0, startFrame: 0, levels: 20 }); + if (!stack.stackFrames?.length) throw new Error("No stop yet"); + return active; + }, 30_000); + try { + const stack = await session.customRequest("stackTrace", { threadId: 0, startFrame: 0, levels: 20 }); + ok(stack.stackFrames[0].source.path.endsWith("main.gd")); + const scopes = await session.customRequest("scopes", { frameId: stack.stackFrames[0].id }); + const locals = scopes.scopes.find((scope: any) => scope.name === "Locals"); + ok(locals); + const page = await session.customRequest("variables", { variablesReference: locals.variablesReference, start: 0, count: 100 }); + const array = page.variables.find((variable: any) => variable.name === "huge_array"); + ok(array?.variablesReference > 0); + const arrayPage = await session.customRequest("variables", { variablesReference: array.variablesReference, start: 100, count: 100 }); + strictEqual(arrayPage.variables.length, 100); + strictEqual(arrayPage.variables[0].value, "100"); + + for (let index = 0; index < 20; index++) { + await retry(async () => session.customRequest("next", { threadId: 0 }), 5000); + await retry(async () => { + const nextStack = await session.customRequest("stackTrace", { threadId: 0, startFrame: 0, levels: 1 }); + if (!nextStack.stackFrames?.length) throw new Error("No stop after step"); + return nextStack; + }, 5000); + } + const breakpoints = await session.customRequest("setBreakpoints", { + source: { path: stack.stackFrames[0].source.path }, + lines: [12], + breakpoints: [{ line: 12 }], + }); + ok(breakpoints.breakpoints?.[0]?.verified); + await session.customRequest("continue", { threadId: 0 }); + await retry(async () => { + const atBreakpoint = await session.customRequest("stackTrace", { threadId: 0, startFrame: 0, levels: 1 }); + strictEqual(atBreakpoint.stackFrames?.[0]?.line, 12); + }, 5000); + } finally { + await vscode.debug.stopDebugging(session); + } + }); +}); diff --git a/src/test/godot4_v2_suite.ts b/src/test/godot4_v2_suite.ts new file mode 100644 index 000000000..966dbd6fc --- /dev/null +++ b/src/test/godot4_v2_suite.ts @@ -0,0 +1,8 @@ +import * as path from "node:path"; +import Mocha from "mocha"; + +export function run(): Promise { + const mocha = new Mocha({ ui: "tdd", color: true, timeout: 120_000 }); + mocha.addFile(path.resolve(__dirname, "godot4_v2.integration.js")); + return new Promise((resolve, reject) => mocha.run((failures) => failures ? reject(new Error(`${failures} Godot 4 v2 integration test(s) failed.`)) : resolve())); +} diff --git a/test_projects/test-dap-project-godot3-v2/call_stepping.gd b/test_projects/test-dap-project-godot3-v2/call_stepping.gd new file mode 100644 index 000000000..0dcca4f84 --- /dev/null +++ b/test_projects/test-dap-project-godot3-v2/call_stepping.gd @@ -0,0 +1,24 @@ +extends SceneTree + +var trace = [] + +func first(): + trace.append("first") + return 1 + +func second(): + trace.append("second") + return 2 + +func combine(a, b): + trace.append("combine") + return a + b + +func _init(): + call_deferred("_exercise") + +func _exercise(): + breakpoint + var result = combine(first(), second()) + print(result) + quit() diff --git a/test_projects/test-dap-project-godot3-v2/main.gd b/test_projects/test-dap-project-godot3-v2/main.gd new file mode 100644 index 000000000..62ca1b9be --- /dev/null +++ b/test_projects/test-dap-project-godot3-v2/main.gd @@ -0,0 +1,17 @@ +extends SceneTree + +func _init(): + call_deferred("_exercise") + +func _exercise(): + var huge_array = [] + var huge_dictionary = {} + for index in range(10000): + huge_array.append(index) + for index in range(500): + huge_dictionary["key_%s" % index] = index + for cycle in range(110): + var local_cycle = cycle + breakpoint + local_cycle += huge_array[0] + huge_dictionary["key_0"] + quit() diff --git a/test_projects/test-dap-project-godot3-v2/project.godot b/test_projects/test-dap-project-godot3-v2/project.godot new file mode 100644 index 000000000..bf59aadb0 --- /dev/null +++ b/test_projects/test-dap-project-godot3-v2/project.godot @@ -0,0 +1,5 @@ +[application] +config/name="Godot 3 Debug Protocol v2 DAP" + +[rendering] +driver/driver_name="GLES2" diff --git a/test_projects/test-dap-project-godot4-v2/main.gd b/test_projects/test-dap-project-godot4-v2/main.gd new file mode 100644 index 000000000..f44b204e2 --- /dev/null +++ b/test_projects/test-dap-project-godot4-v2/main.gd @@ -0,0 +1,12 @@ +extends SceneTree + +func _initialize() -> void: + var huge_array: Array[int] = [] + for index in range(1000): + huge_array.append(index) + breakpoint + for index in range(100): + var current: int = huge_array[index] + if current < 0: + print("unreachable") + quit() diff --git a/test_projects/test-dap-project-godot4-v2/project.godot b/test_projects/test-dap-project-godot4-v2/project.godot new file mode 100644 index 000000000..0eaa266a0 --- /dev/null +++ b/test_projects/test-dap-project-godot4-v2/project.godot @@ -0,0 +1,9 @@ +config_version=5 + +[application] +config/name="Godot 4 debugger protocol v2 fixture" +run/main_scene="" +config/features=PackedStringArray("4.6", "GL Compatibility") + +[rendering] +renderer/rendering_method="gl_compatibility" diff --git a/tools/run_debugger_v2_tests.ts b/tools/run_debugger_v2_tests.ts new file mode 100644 index 000000000..d88b94d28 --- /dev/null +++ b/tools/run_debugger_v2_tests.ts @@ -0,0 +1,15 @@ +import * as path from "node:path"; +import { runTests } from "@vscode/test-electron"; + +async function main() { + const root = path.resolve(__dirname, ".."); + if (!process.env.GODOT3_V2_PATH) throw new Error("Set GODOT3_V2_PATH to a Godot 3.6 debug-protocol-v2 executable"); + await runTests({ + vscodeExecutablePath: process.env.VSCODE_EXECUTABLE_PATH, + extensionDevelopmentPath: root, + extensionTestsPath: path.join(root, "out", "test", "debugger_v2_suite.js"), + launchArgs: [path.join(root, "test_projects", "test-dap-project-godot3-v2"), "--disable-extensions"], + }); +} + +main().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/tools/run_godot4_debugger_tests.ts b/tools/run_godot4_debugger_tests.ts new file mode 100644 index 000000000..dbc9793d1 --- /dev/null +++ b/tools/run_godot4_debugger_tests.ts @@ -0,0 +1,21 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { runTests } from "@vscode/test-electron"; + +async function main() { + const root = path.resolve(__dirname, ".."); + const project = path.join(root, "test_projects", "test-dap-project-godot4"); + const godot = process.env.GODOT4_TEST_PATH; + if (!godot) throw new Error("Set GODOT4_TEST_PATH to a stock Godot 4 executable"); + const settings = path.join(project, ".vscode", "settings.json"); + fs.mkdirSync(path.dirname(settings), { recursive: true }); + fs.writeFileSync(settings, `${JSON.stringify({ "godotTools.editorPath.godot4": godot }, null, 2)}\n`, "utf8"); + await runTests({ + vscodeExecutablePath: process.env.VSCODE_EXECUTABLE_PATH, + extensionDevelopmentPath: root, + extensionTestsPath: path.join(root, "out", "test", "godot4_debugger_suite.js"), + launchArgs: [project, "--disable-extensions"], + }); +} + +main().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/tools/run_godot4_v2_tests.ts b/tools/run_godot4_v2_tests.ts new file mode 100644 index 000000000..5a155f0a1 --- /dev/null +++ b/tools/run_godot4_v2_tests.ts @@ -0,0 +1,16 @@ +import * as path from "node:path"; +import { runTests } from "@vscode/test-electron"; + +async function main() { + const root = path.resolve(__dirname, ".."); + const project = path.join(root, "test_projects", "test-dap-project-godot4-v2"); + if (!process.env.GODOT4_V2_PATH) throw new Error("Set GODOT4_V2_PATH to a Godot 4.6 debug-protocol-v2 executable"); + await runTests({ + vscodeExecutablePath: process.env.VSCODE_EXECUTABLE_PATH, + extensionDevelopmentPath: root, + extensionTestsPath: path.join(root, "out", "test", "godot4_v2_suite.js"), + launchArgs: [project, "--disable-extensions"], + }); +} + +main().catch((error) => { console.error(error); process.exitCode = 1; });