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.