7.2 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 (Unreal's Python formatters for TArray, TMap, FName, FString, and now our TObjectPtr/TSharedPtr/TWeakPtr). 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 (without intervention) runs text through the
/se(simple) or/nat(Clang) evaluator./natfails for most Unreal paths;/setranspiles to Python and walks the SBValue tree, but — as originally shipped — fails on pointer auto-deref and can't find base classes by type name.
When you right-click in Variables and Add to Watch, CodeLLDB sends
a path like Top.Widget (built itself for synthetic children, uniform
., no pointer awareness). Without the fix below, that path fails in
Watch even though it identifies a real value Variables can show.
What We Did
All changes live in tools/UEDataFormatter.py, loaded via initCommands
in every launch config in Integration.code-workspace.tpl.json.
1. Patched codelldb.value.Value.__getattr__
At module load, we monkey-patch CodeLLDB's Value class — the Python
wrapper it uses inside /se and /py evaluation — to:
- Auto-deref pointers before descending into a named child.
- Fall back to iterating children by
GetName()whenGetChildMemberWithNamereturns invalid. This catches base classes (which appear as named children when iterated but can't be looked up by name).
Result: plain "Add to Watch" from the Variables pane produces a path
like Top.Widget.SomeField and Watch evaluates it correctly — same
expandable tree you'd see in Variables. No /py fv(...) wrapper, no
VS Code extension, no manual prefix typing.
Blast radius: every /se and /py expression that goes through
Value.__getattr__. The change is strictly more permissive (paths that
used to fail now succeed), but it is a global behavior change to
CodeLLDB internals. Breaks if CodeLLDB renames Value or the
__sbvalue slot; re-applied automatically on extension reload since
our patch runs on command script import.
2. Synth + Summary Providers for Smart Pointers
UE's engine formatter covers containers and TWeakObjectPtr, but not the smart pointer family. We added providers for:
TObjectPtr<T>— showsnullptr/unresolved/ wrapped object's summary. Expanding flattens straight to the target's members (no intermediate*DebugPtrclick).TSharedPtr<T>andTSharedRef<T>— showsnullptr/ target summary; expands straight to target's members.TWeakPtr<T>— checksWeakReferenceCount.ReferenceController.SharedReferenceCountbefore dereferencing. Expired weak refs showexpiredrather than garbage from a dangling pointer.
All registration regexes are anchored with ^ — otherwise the greedy
.+ matches nested occurrences (a TArray<TObjectPtr<X>, ...> would
get dispatched to the TObjectPtr provider instead of TArray).
3. Dynamic Type Resolution
Every launch config now sets:
settings set target.prefer-dynamic-value no-run-target
lldb reads the vtable at each polymorphic value and shows the runtime
type's members. A UObject* that actually points to a UUserWidget
expands to the full UUserWidget subtree, not just UObject.
no-run-target avoids running code in the debuggee, which is important
during synthesis.
4. Universal SIGTRAP Handling
process handle SIGTRAP --notify false --pass false --stop false is now
in every launch config. Unreal raises SIGTRAP internally in a number of
places (soft asserts, ensure-style checks); without this, the debugger
stops constantly.
Reloading Without a Session Restart
If you edit tools/UEDataFormatter.py, reload in the Debug Console:
script import importlib; importlib.reload(UEDataFormatter); UEDataFormatter.__lldb_init_module(lldb.debugger, {})
The reload re-executes module code (updating the patch and the provider
classes). The explicit __lldb_init_module call re-runs the provider
registrations — lldb only fires __lldb_init_module on initial import,
not on reload.
Summary of Workflow
| Action | Where | How |
|---|---|---|
| Explore a value | Variables pane | Click disclosure triangles |
| Track a value across steps | Watch pane | Right-click variable → Add to Watch (just works) |
| One-shot inspection | Debug Console | v Widget.Object->SCompoundWidget.SWidget (v = frame variable; use -> for pointers explicitly) |
| Reload formatter edits | Debug Console | script import importlib; importlib.reload(UEDataFormatter); UEDataFormatter.__lldb_init_module(lldb.debugger, {}) |
Notes on the Design
Why this works
CodeLLDB's /se evaluator transpiles user expressions into Python that
operates on Value objects. Value.__getattr__ drives every .field
access. By making that method auto-deref and iterate for base classes,
every downstream mechanism (Watch, Debug Console, hover, conditional
breakpoints) inherits the fix.
Why fv is no longer needed
Earlier we had an fv(path) helper plus a plan for a companion VS Code
extension to wrap "Add to Watch" results in /py fv(...). The
Value.__getattr__ patch makes the default path work, so that whole
layer is obsolete.
Why not patch SBValue instead
Tempting, but much larger blast radius — affects every tool, every
adapter, every Python script using lldb. Patching Value confines the
change to CodeLLDB's expression pipeline.
Ideas for Further Improvement
Propose /fv mode to CodeLLDB upstream
Prefix dispatch is in CodeLLDB's Rust binary, so we can't add a new
prefix from Python. A clean feature request would be to add /fv →
SBFrame::GetValueForVariablePath(code) as a native evaluator. That
would give direct access to lldb's own frame-variable walker without
the Python/Value indirection — though it has its own limitations
(no arithmetic, no casts).
More synth providers
TOptional<T>— hide the storage bytes andbIsSet; expose the contained value (orunset) directly.TVariant<...>— expose the currently-held alternative as the single child.FText— show the resolved localized string as the summary.FSoftObjectPtr/FSoftClassPtr— show the asset path.
Enrich TObjectPtr summary
When resolved, show both class and name (UUserWidget 'W_HUD_0')
instead of just the name. The class is reachable via
ClassPrivate->NamePrivate.
A fdump helper
A Debug Console helper that prints an entire subtree as indented text — useful for grabbing a snapshot of complex state into a log or comment.
Get Copy as Expression to emit -> for pointers
The path CodeLLDB builds for synthetic children uses . uniformly,
regardless of whether intermediate values are pointers. That's why
v Top.Widget fails but v Top->Widget works. A feature request to
have CodeLLDB emit -> when traversing a pointer would make paths
frame variable-compatible out of the box.