Overview
How difficult is it to achieve Unreal Engine-style procedural context debugging?
Turns out, it might be easier than expected.
In this dev log, we won't implement all the fancy animations or full-featured GUI yet, but we will derive a practical, functional debugger GUI. This interface allows users to step through a procedural program and observe its execution flow in real time.
The Debugging Problem
Procedural contexts are hard - not necessarily to implement (they're actually simpler than dataflow contexts in interpretative runtimes, where you just step over nodes), but hard for users.
The main issue is statefulness: once there's state, there's the potential for mistakes, side effects, and confusion.
From an implementation standpoint, the hard part is debugging. Without visibility into execution, it's difficult to guess what's happening. That's why programming languages without IDEs often fall back on Print
statements to inspect runtime states. This approach quickly breaks down with larger programs or those involving intricate state transitions.
Even Python - often used without an IDE - includes a built-in debugger in IDLE (see tutorial). It's essential when working with complex logic.
Approach
Let's start by clarifying the setup: the current implementation of the Divooka graph editor (Neo) is done in WPF, using an interpretative runtime.
For drawing the GUI, we can use standard XAML data bindings and a few button states:
<StackPanel> <!--Start--> <Button Style="{StaticResource IconButtonStyle}" Click="ProceduralContextRunGraphWithoutDebugging_Clicked" ToolTip="Run and wait for the process to finish." Visibility="{Binding EvaluatingGraph, Converter={converters:BooleanToVisibilityConverter Negate=True}}"/> <!--Continue--> <Button Style="{StaticResource DebugButtonStyle}" Content="Continue" Click="ProceduralContextContinueWithoutStepping_Clicked" ToolTip="Skip debugging and continue the process to completion." Visibility="{Binding IsProceduralStepping, Converter={converters:BooleanToVisibilityConverter}}"/> <!--Step--> <StackPanel Visibility="{Binding EvaluatingGraph, Converter={converters:BooleanToVisibilityConverter Negate=True}}"> <Button Style="{StaticResource DebugButtonStyle}" Content="Step" Click="ProceduralContextStartDebuggingGraph_Clicked" ToolTip="Step into the process and observe each execution step." Visibility="{Binding IsProceduralStepping, Converter={converters:BooleanToVisibilityConverter Negate=True}}"/> </StackPanel> <!--Next Step--> <Button Style="{StaticResource DebugButtonStyle}" Content="Next" Click="ProceduralContextContinueThroughGraph_Clicked" ToolTip="Execute (step over) the next step in the process." Visibility="{Binding IsProceduralStepping, Converter={converters:BooleanToVisibilityConverter}}" IsEnabled="{Binding IsProceduralPaused}"/> <Button Style="{StaticResource DebugButtonStyle}" Content="Finish" Click="ProceduralContextFinishDebuggingGraph_Clicked" ToolTip="Stop debugging the current process." Visibility="{Binding IsProceduralStepping, Converter={converters:BooleanToVisibilityConverter}}"/> <Button Style="{StaticResource DebugButtonStyle}" Content="Pause" Click="ProceduralContextPauseExecution_Clicked" ToolTip="Pause execution of the current process." Visibility="{Binding EvaluatingGraph, Converter={converters:BooleanToVisibilityConverter}}" IsEnabled="{Binding IsProceduralPaused, Converter={StaticResource InvertBooleanConverter}}"/> </StackPanel>
Stepping Through Execution
Initially, I thought we needed a dedicated execution runtime for stepping, due to the complexity of node interpretation. However, it turns out we can simply inject "breakpoints" during interpretation. The simplest way to achieve this in C# with multithreading is by using a ManualResetEventSlim
:
// ── MainWindow.xaml.cs (WPF) ── public partial class MainWindow : Window { // starts signaled (i.e. not paused) private readonly ManualResetEventSlim _pauseEvent = new ManualResetEventSlim(true); private readonly Executer _executer; public MainWindow() { InitializeComponent(); _executer = new Executer(_pauseEvent); } private async void StartButton_Click(object sender, RoutedEventArgs e) { // Run on thread-pool, but because we 'await', continuation // (Finish…) runs back on the UI thread. await Task.Run(() => _executer.ExecuteGraph()); FinishProceduralContextDebuggingSession(); } private void PauseButton_Click(object sender, RoutedEventArgs e) { // next time the worker hits Wait(), it will block _pauseEvent.Reset(); } private void ResumeButton_Click(object sender, RoutedEventArgs e) { // unblocks the worker _pauseEvent.Set(); } } // ── Executer.cs ── public class Executer { private readonly ManualResetEventSlim _pauseEvent; private readonly Random _random = new Random(); public Executer(ManualResetEventSlim pauseEvent) { _pauseEvent = pauseEvent; } public void ExecuteGraph() { while (/* your long-running condition */) { DoWorkStep(); // simulate a random pause point if (_random.NextDouble() < 0.05) { Debug.WriteLine("…pausing now"); // put event into non-signaled (i.e. paused) _pauseEvent.Reset(); _pauseEvent.Wait(); // block until resumed Debug.WriteLine("…resumed!"); } } } private void DoWorkStep() { Thread.Sleep(200); } }
How It Works:
-
_pauseEvent
starts in the signaled state, so.Wait()
returns immediately. - Calling
_pauseEvent.Reset()
switches it to non-signaled, so.Wait()
blocks the worker thread. - When the UI calls
_pauseEvent.Set()
, the blocked thread resumes execution.
Real-Time Visual Updates
A small challenge is real-time canvas updates - specifically, drawing a highlight box around the active node:
We can use an adorner for this:
/// <summary> /// Show a red highlight around the specified FrameworkElement for a set duration. /// </summary> public static async void ShowSimpleHighlight(FrameworkElement target, double margin = 5, int duration = 3) { if (target == null) return; // Get the adorner layer for that target AdornerLayer adornerLayer = AdornerLayer.GetAdornerLayer(target); if (adornerLayer == null) return; // Create and add adorner to the layer HighlightAdorner highlightAdorner = new(target, margin); adornerLayer.Add(highlightAdorner); // Remove it after a few seconds await Task.Delay(TimeSpan.FromSeconds(duration)); adornerLayer.Remove(highlightAdorner); } public class HighlightAdorner : Adorner { private readonly Pen _pen; private readonly double _margin; public HighlightAdorner(UIElement adornedElement, double margin = 5) : base(adornedElement) { _pen = new Pen(Brushes.Red, 2); _pen.Freeze(); _margin = margin; IsHitTestVisible = false; } protected override void OnRender(DrawingContext drawingContext) { Rect adornedElementRect = new(AdornedElement.RenderSize); adornedElementRect.Inflate(_margin, _margin); drawingContext.DrawRectangle(null, _pen, adornedElementRect); } }
What's Next?
All that's left is a proper debug window!
Summary
A good framework truly goes a long way. I had confidence in bringing procedural debugging to Divooka from the moment procedural context was introduced, because it naturally fits into an interpretative execution model. All that's required is a bit of focused engineering.
The more challenging part is the GUI - but WPF offers solid isolation via MVVM, data binding, and adorners.
We're not dealing with setting actual breakpoints yet, but the current setup is flexible enough to support that when the time comes. Ultimately, a debug window will act as our lens into the runtime: inspecting node states, variable values, and control flow.
References
- Procedural vs Dataflow context in Divooka on the Methodox Wiki.
- Blueprint debugging in Unreal
- Procedural debugging documentation on Methodox Wiki.
Top comments (0)