Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port>` and set the same port in the attach configuration. The custom Godot 3 engine uses its normal `--remote-debug 127.0.0.1:<port>` 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
Expand Down
16 changes: 15 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down
18 changes: 13 additions & 5 deletions src/debugger/debugger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,18 @@ 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";

const log = createLogger("debugger", { output: "Godot Debugger" });

export interface LaunchRequestArguments extends DebugProtocol.LaunchRequestArguments {
debug_protocol_v2?: boolean;
address: string;
port: number;
project: string;
Expand All @@ -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;
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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);

Expand Down
18 changes: 18 additions & 0 deletions src/debugger/debugger_mode.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
7 changes: 7 additions & 0 deletions src/debugger/debugger_mode.ts
Original file line number Diff line number Diff line change
@@ -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"}`;
}
205 changes: 205 additions & 0 deletions src/debugger/godot3/debug_session_v2.ts
Original file line number Diff line number Diff line change
@@ -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<bigint, (className: string, variable: GodotVariable) => void>();
private configurationDone = new Subject();
private mode: "launch" | "attach" | "" = "";
private nextReference = 1;
private referenceToHandle = new Map<number, bigint>();
private handleToReference = new Map<bigint, number>();
private requestCancellations = new Map<number, AbortController>();

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<number>) {
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);
}
}
23 changes: 23 additions & 0 deletions src/debugger/godot3/protocol_v2.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading