Files
integration/Docs/Better-Debugging-With-LLDB.md
2026-04-19 05:03:11 -04:00

6.1 KiB

Better Debugging With LLDB (in VS Code + CodeLLDB)

The Problem

When debugging Unreal with VS Code + CodeLLDB, the Variables pane and the Watch pane use two completely different evaluation paths:

  • Variables pane walks a tree built by lldb's synthetic children providers (including Unreal's Python formatters for TArray, TMap, FName, FString, TSharedRef, etc.). Values are looked up by offset/type — no compilation. Base classes appear as named children ("SCompoundWidget", "UWidget"), smart-pointer inners get unwrapped, container elements get indexed.

  • Watch pane (and p/expression) runs your text through Clang to compile a real C++ expression against the program's full type system, then applies formatters to the result. For Unreal, this is slow and often fails outright — synthetic children don't exist as C++ members.

The practical consequence: a path you see in the Variables pane — like Widget.Object.SCompoundWidget.SWidget.bCanSupportFocus — cannot be typed into the Watch pane, because the intermediate labels (SCompoundWidget, SWidget) aren't real C++ members. Even "Copy as Expression" in the Variables pane gives you that broken synthetic path.

Hand-writing the equivalent real C++ expression (with explicit -> for pointers, static_casts through base classes, formatter-internal array indexing math, etc.) is impractical.

The Fix: A Python Helper That Walks Synthetic Children

We added a function fv(path) to tools/UEDataFormatter.py. It walks a dotted path through the synthetic tree using the same APIs the Variables pane uses (GetChildMemberWithName, then fallback to iterating children by name so base classes match). Pointers are auto-dereferenced.

CodeLLDB's /py expression mode evaluates Python in the Watch pane and renders returned SBValues through the full formatter pipeline — same expandable tree as Variables. So:

/py fv("Widget.Object.SCompoundWidget.SWidget")

works in Watch the way you'd originally expect Widget.Object.SCompoundWidget.SWidget to work.

fv is also injected into Python builtins, so from the Debug Console you can also just:

script fv("Widget.Object.SCompoundWidget.SWidget")

How fv is loaded

The launch configurations in Integration.code-workspace.tpl.json run:

command script import /home/jyelon/integration/tools/UEDataFormatter.py

This both (a) loads all the Unreal data formatters and (b) defines fv and injects it into builtins.

Reloading without restarting the debug session

If you edit tools/UEDataFormatter.py, reload in the Debug Console:

script import importlib; importlib.reload(UEDataFormatter)

The Remaining Gap: "Add to Watch"

VS Code's "Add to Watch" context menu copies the variable's evaluateName as reported by the debug adapter. CodeLLDB sends the synthetic path — so "Add to Watch" produces exactly the unusable expression that started this whole problem.

The clean solution is a small VS Code extension that registers a new command Add to Watch (fv) on the Variables pane context menu. When invoked, it reads the variable's evaluateName and adds /py fv("<evaluateName>") to the watch expressions.

Scaffolding the extension

Create a folder, e.g. tools/vscode-fv-watch/:

tools/vscode-fv-watch/
  package.json
  src/extension.ts
  tsconfig.json

package.json declares the contribution points — the command, and its appearance in the Variables pane context menu:

{
  "name": "fv-watch",
  "version": "0.0.1",
  "engines": { "vscode": "^1.80.0" },
  "activationEvents": ["onDebug"],
  "main": "./out/extension.js",
  "contributes": {
    "commands": [{
      "command": "fv.addToWatch",
      "title": "Add to Watch (fv)"
    }],
    "menus": {
      "debug/variables/context": [{
        "command": "fv.addToWatch",
        "group": "3_modification@1"
      }]
    }
  },
  "devDependencies": {
    "@types/vscode": "^1.80.0",
    "typescript": "^5.0.0"
  }
}

src/extension.ts implements the command. The variable reference is passed as the command argument; we pull its evaluateName and call the built-in debug.addToWatchExpressions with the wrapped form:

import * as vscode from 'vscode';

export function activate(ctx: vscode.ExtensionContext) {
  ctx.subscriptions.push(vscode.commands.registerCommand(
    'fv.addToWatch',
    async (variable: any) => {
      const name = variable?.evaluateName ?? variable?.name;
      if (!name) return;
      const expr = `/py fv(${JSON.stringify(name)})`;
      await vscode.commands.executeCommand(
        'debug.addToWatchExpressions',
        { variable: { evaluateName: expr } }
      );
    }));
}

export function deactivate() {}

tsconfig.json is standard:

{
  "compilerOptions": {
    "module": "commonjs",
    "target": "ES2020",
    "outDir": "out",
    "strict": true,
    "esModuleInterop": true
  },
  "include": ["src"]
}

Building and installing

cd tools/vscode-fv-watch
npm install
npx tsc
ln -s "$PWD" ~/.vscode/extensions/fv-watch

Restart VS Code. Right-click any entry in the Variables pane and Add to Watch (fv) appears alongside the built-in Add to Watch.

Summary of Workflow

Action Where How
Explore a value Variables pane Click disclosure triangles
Track a value across steps Watch pane "Add to Watch (fv)" from Variables context menu, or type /py fv("...") manually
One-shot inspection Debug Console script fv("path")
Reload formatter edits Debug Console script import importlib; importlib.reload(UEDataFormatter)

Why Not Make It Automatic?

The cleanest solution would be to change CodeLLDB's default Watch evaluator to route through frame variable / synthetic children instead of Clang. That's not exposed as a setting — CodeLLDB's "expressions" option only chooses between its own parser, lldb's expr, or raw Python. None of those hit the synthetic-children walk.

A feature request against CodeLLDB to add a "native frame-variable" expression mode would address this at the source. In the meantime, the fv helper + extension combo reproduces the missing behavior.