.NET Debugger Integration Research
Overview
This document researches how to integrate .NET debugging capabilities into the KeenEyes editor, focusing on SharpDbg - a fully managed .NET debugger implementing the Debug Adapter Protocol (DAP).
SharpDbg Architecture
SharpDbg is an open-source, cross-platform .NET debugger written entirely in C#. It implements the VS Code Debug Adapter Protocol, making it compatible with any editor that supports DAP.
Three-Layer Architecture
┌─────────────────────────────────────────────────────────┐
│ SharpDbg.Cli │
│ Entry point, CLI argument parsing, DAP client init │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ SharpDbg.Application │
│ DAP protocol implementation, message handling, │
│ breakpoint events, execution state management │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ SharpDbg.Infrastructure │
│ ManagedDebugger (core engine), ClrDebug wrapper, │
│ expression evaluator (compiler + interpreter) │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ ClrDebug │
│ Managed wrappers around ICorDebug, IMetaData, │
│ ICorProfiler, ISym APIs │
└─────────────────────────────────────────────────────────┘
Key Dependencies
| Package | Purpose |
|---|---|
| ClrDebug | Managed wrappers around .NET debugging APIs (ICorDebug, IMetaData, etc.) |
| .NET 10 SDK | Build requirement |
Feature Comparison with netcoredbg
| Feature | SharpDbg | netcoredbg |
|---|---|---|
| Expression Evaluation | ✅ | ✅ |
| DebuggerDisplay Attribute | ✅ | ❌ |
| DebuggerTypeProxy Attribute | ✅ | ❌ |
| DebuggerBrowsable | ✅ | ✅ |
| Async Method Stepping | 🚧 WIP | ✅ |
| Source Link | 🚧 WIP | ✅ |
| Auto Decompilation | 🚧 WIP | ❌ |
| Pure C# Implementation | ✅ | ❌ (C++) |
Debug Adapter Protocol (DAP)
DAP standardizes communication between development tools and debuggers through a JSON-based protocol.
Communication Flow
┌─────────────┐ DAP Messages ┌─────────────────┐
│ Editor │ ◄──────────────────────────► │ Debug Adapter │
│ (Client) │ stdin/stdout or TCP │ (Server) │
└─────────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│ Debugger/ │
│ Runtime │
└─────────────────┘
Message Types
- Requests: Client-initiated commands expecting responses
- Responses: Replies to requests with success/error status
- Events: Adapter-initiated notifications (stopped, output, etc.)
Core DAP Requests
| Category | Requests |
|---|---|
| Lifecycle | initialize, launch, attach, disconnect, terminate |
| Execution | continue, next, stepIn, stepOut, pause, restart |
| Breakpoints | setBreakpoints, setFunctionBreakpoints, setExceptionBreakpoints |
| Inspection | threads, stackTrace, scopes, variables, evaluate |
| Configuration | configurationDone, setVariable, setExpression |
Launch Sequence
Client Adapter
│ │
│──── initialize ─────────────────────►│
│◄─── initialize response ─────────────│
│ │
│──── launch/attach ──────────────────►│
│◄─── launch/attach response ──────────│
│ │
│──── setBreakpoints ─────────────────►│
│◄─── setBreakpoints response ─────────│
│ │
│──── configurationDone ──────────────►│
│◄─── configurationDone response ──────│
│ │
│◄──── initialized event ──────────────│
│◄──── stopped event (entry point) ────│
│ │
Integration Approaches
Approach 1: External Process (Recommended)
Launch SharpDbg as an external process and communicate via stdin/stdout.
Advantages:
- Clean separation between editor and debugger
- Debugger crashes don't affect editor
- Matches how VS Code and other editors work
- Easy to swap debug adapters (sharpdbg, netcoredbg, etc.)
Implementation:
public class DebugAdapterClient : IDisposable
{
private Process debuggerProcess;
private DebugProtocolHost protocolHost;
public async Task AttachAsync(int processId, string debuggerPath)
{
// Launch debug adapter process
debuggerProcess = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = debuggerPath,
Arguments = "--interpreter=vscode",
RedirectStandardInput = true,
RedirectStandardOutput = true,
UseShellExecute = false
}
};
debuggerProcess.Start();
// Create DAP protocol host over stdin/stdout
protocolHost = new DebugProtocolHost(
debuggerProcess.StandardInput.BaseStream,
debuggerProcess.StandardOutput.BaseStream);
// Register event handlers
protocolHost.EventReceived += OnEventReceived;
protocolHost.Run();
// Initialize and attach
await protocolHost.SendRequestAsync(new InitializeRequest { ... });
await protocolHost.SendRequestAsync(new AttachRequest { ProcessId = processId });
}
}
Approach 2: In-Process / Embedded (Recommended for KeenEyes)
Embed SharpDbg.Infrastructure directly for custom ECS debugging views.
Advantages:
- Direct access to
ICorDebugValuefor custom type visualization - ECS-specific views: entity inspector, component values, world state
- Lower latency for high-frequency inspection
- Full control over debugging UX
Disadvantages:
- Tighter coupling to SharpDbg internals
- Debugger issues could affect editor stability
- More complex implementation
Why Embedded for KeenEyes:
- Custom visualization of
World,Entity, component structs - Real-time entity/component inspection during pause
- Integration with editor's entity hierarchy view
- Query result visualization during debugging
SharpDbg.Infrastructure Components
The Infrastructure layer provides everything needed for embedding:
SharpDbg.Infrastructure/
├── Debugger/
│ ├── ManagedDebugger.cs # Core debugger engine
│ ├── ManagedDebugger_VariableInfo.cs # Variable metadata
│ ├── ManagedDebugger_VariableValues.cs # Value retrieval
│ ├── BreakpointManager.cs # Breakpoint handling
│ ├── VariableManager.cs # Variable tracking
│ ├── SymbolReader.cs # PDB symbol loading
│ ├── ModuleInfo.cs # Assembly metadata
│ └── ExpressionEvaluator/ # Expression evaluation
├── ClrDebugExtensions.cs # Helper extensions
└── DbgShimResolver.cs # Runtime shim resolution
Embedded Integration Pattern
using ClrDebug;
using SharpDbg.Infrastructure.Debugger;
public class EmbeddedDebugger : IDisposable
{
private ManagedDebugger debugger;
private BreakpointManager breakpoints;
private VariableManager variables;
public async Task AttachAsync(int processId)
{
// Initialize ClrDebug wrapper
var corDebug = new CorDebug();
var callback = new DebuggerCallback(this);
corDebug.SetManagedHandler(callback);
// Attach to running process
var process = corDebug.DebugActiveProcess(processId, win32Attach: false);
// Initialize SharpDbg managers
debugger = new ManagedDebugger(process);
breakpoints = new BreakpointManager(debugger);
variables = new VariableManager(debugger);
}
// Custom ECS inspection - direct access to ICorDebugValue
public WorldSnapshot InspectWorld(CorDebugValue worldValue)
{
// Read World fields directly via ICorDebugObjectValue
var objectValue = worldValue.As<CorDebugObjectValue>();
// Get entity count
var entityCountField = objectValue.GetFieldValue("entityCount");
int entityCount = entityCountField.As<CorDebugGenericValue>().GetValue<int>();
// Enumerate entities via archetype storage
var archetypeManager = objectValue.GetFieldValue("archetypeManager");
// ... custom traversal of ECS data structures
return new WorldSnapshot { EntityCount = entityCount, /* ... */ };
}
}
ICorDebugValue Hierarchy for Type Inspection
Understanding the value hierarchy is key for custom visualizers:
ICorDebugValue (base)
├── ICorDebugGenericValue # Primitives (int, float, bool)
├── ICorDebugReferenceValue # Object references
├── ICorDebugObjectValue # Object instances (fields, properties)
├── ICorDebugBoxValue # Boxed value types
├── ICorDebugStringValue # String values
├── ICorDebugArrayValue # Arrays
└── ICorDebugHeapValue # Heap-allocated objects
ClrDebug wraps these as CorDebugValue, CorDebugObjectValue, etc. with proper inheritance.
ECS-Specific Visualizers
public class EntityVisualizer
{
public EntityView Visualize(CorDebugObjectValue entityValue, CorDebugObjectValue worldValue)
{
// Read Entity struct fields
var id = entityValue.GetFieldValue("Id").As<CorDebugGenericValue>().GetValue<int>();
var version = entityValue.GetFieldValue("Version").As<CorDebugGenericValue>().GetValue<int>();
// Look up components from World's archetype storage
var components = GetEntityComponents(worldValue, id);
return new EntityView
{
Id = id,
Version = version,
Components = components,
Children = GetEntityChildren(worldValue, id),
Parent = GetEntityParent(worldValue, id)
};
}
private List<ComponentView> GetEntityComponents(CorDebugObjectValue world, int entityId)
{
// Navigate: World -> ArchetypeManager -> find archetype for entity -> read components
var archetypeManager = world.GetFieldValue("archetypeManager").As<CorDebugObjectValue>();
// ... traverse archetype storage to find and read component data
}
}
public class ComponentVisualizer
{
public ComponentView Visualize(CorDebugObjectValue componentValue, Type componentType)
{
var view = new ComponentView { TypeName = componentType.Name };
// Read all fields of the component struct
foreach (var field in componentType.GetFields())
{
var fieldValue = componentValue.GetFieldValue(field.Name);
view.Fields.Add(new FieldView
{
Name = field.Name,
Value = FormatValue(fieldValue),
CanEdit = IsPrimitiveOrSimple(field.FieldType)
});
}
return view;
}
}
Hooking into Gameplay Functionality
To integrate with the running game, we need to hook into key ECS lifecycle points:
1. System Update Interception
Set breakpoints at system entry/exit to track execution flow:
public class SystemExecutionHook
{
private readonly BreakpointManager breakpoints;
private readonly Dictionary<string, SystemExecutionInfo> systemStats = new();
public void HookSystemUpdates(CorDebugModule coreModule)
{
// Find SystemManager.ExecuteSystem method via metadata
var metadata = coreModule.GetMetaDataInterface<MetaDataImport>();
var systemManagerType = metadata.FindTypeDefByName("KeenEyes.Core.Systems.SystemManager");
var executeMethod = metadata.EnumMethods(systemManagerType)
.First(m => metadata.GetMethodProps(m).szMethod == "ExecuteSystem");
// Set breakpoint at method entry
var bp = breakpoints.SetMethodBreakpoint(coreModule, executeMethod);
bp.OnHit += (sender, args) =>
{
// Read the ISystem parameter to get system type
var systemArg = args.Frame.GetArgument(0);
var systemType = GetTypeName(systemArg);
// Track timing
systemStats[systemType] = new SystemExecutionInfo
{
StartTime = Stopwatch.GetTimestamp(),
ThreadId = args.Thread.Id
};
// Continue execution (don't pause)
args.Continue = true;
};
}
}
2. Entity Lifecycle Hooks
Trap entity spawn/despawn to update the entity browser in real-time:
public class EntityLifecycleHook
{
public event Action<int, EntityOperation> OnEntityChanged;
public void HookEntityLifecycle(CorDebugModule coreModule)
{
// Hook World.Spawn() completion
SetMethodExitBreakpoint("KeenEyes.Core.World", "Spawn", args =>
{
var entity = args.ReturnValue.As<CorDebugObjectValue>();
var id = entity.GetFieldValue("Id").As<CorDebugGenericValue>().GetValue<int>();
OnEntityChanged?.Invoke(id, EntityOperation.Spawned);
});
// Hook World.Despawn()
SetMethodEntryBreakpoint("KeenEyes.Core.World", "Despawn", args =>
{
var entityArg = args.Frame.GetArgument(1); // 'this' is arg0
var id = entityArg.As<CorDebugObjectValue>()
.GetFieldValue("Id").As<CorDebugGenericValue>().GetValue<int>();
OnEntityChanged?.Invoke(id, EntityOperation.Despawning);
});
}
}
3. Component Change Tracking
Use data breakpoints (hardware watchpoints) for component field changes:
public class ComponentWatchpoint
{
public void WatchComponent<T>(int entityId, string fieldName) where T : struct
{
// Find component storage address for this entity
var componentAddress = FindComponentAddress(entityId, typeof(T));
var fieldOffset = GetFieldOffset(typeof(T), fieldName);
// Set hardware data breakpoint (x86/x64 debug registers)
var watchpoint = breakpoints.SetDataBreakpoint(
componentAddress + fieldOffset,
size: Marshal.SizeOf(typeof(T).GetField(fieldName).FieldType),
accessType: DataBreakpointAccessType.Write
);
watchpoint.OnHit += (sender, args) =>
{
var oldValue = /* read from snapshot */;
var newValue = ReadFieldValue(componentAddress, fieldOffset);
OnComponentChanged?.Invoke(entityId, typeof(T), fieldName, oldValue, newValue);
};
}
}
4. Game Loop Integration
Hook the main update loop to enable frame-by-frame stepping:
public class GameLoopDebugger
{
private bool stepOneFrame = false;
private CorDebugBreakpoint frameBreakpoint;
public void EnableFrameStepping(CorDebugModule gameModule)
{
// Find the game's Update/Tick method
// Common patterns: Game.Update(), GameLoop.Tick(), App.OnUpdate()
var updateMethod = FindUpdateMethod(gameModule);
frameBreakpoint = breakpoints.SetMethodBreakpoint(gameModule, updateMethod);
frameBreakpoint.OnHit += (sender, args) =>
{
if (stepOneFrame)
{
stepOneFrame = false;
OnFrameStart?.Invoke(args.Frame);
args.Continue = false; // Pause at frame start
}
else
{
args.Continue = true; // Keep running
}
};
}
public void StepOneFrame()
{
stepOneFrame = true;
debugger.Continue(); // Resume until next frame
}
}
5. Expression Evaluation for Runtime Interaction
Use ICorDebugEval to call methods in the debuggee process:
public class RuntimeInteraction
{
private readonly CorDebugEval eval;
// Call methods in the debuggee to query ECS state
public int GetEntityCount(CorDebugValue worldValue)
{
// Find World.EntityCount property getter
var getter = FindMethod(worldValue.ExactType, "get_EntityCount");
// Call the method in the debuggee
eval.CallFunction(getter, [worldValue]);
eval.WaitForResult();
return eval.Result.As<CorDebugGenericValue>().GetValue<int>();
}
// Spawn an entity from the debugger
public CorDebugValue SpawnEntity(CorDebugValue worldValue)
{
var spawnMethod = FindMethod(worldValue.ExactType, "Spawn");
eval.CallFunction(spawnMethod, [worldValue]);
eval.WaitForResult();
var builder = eval.Result; // EntityBuilder
var buildMethod = FindMethod(builder.ExactType, "Build");
eval.CallFunction(buildMethod, [builder]);
eval.WaitForResult();
return eval.Result; // Entity
}
// Add component to entity at runtime
public void AddComponent<T>(CorDebugValue worldValue, CorDebugValue entityValue, T component)
{
// Allocate component struct in debuggee heap
var componentValue = AllocateStruct<T>(component);
// Call World.Add<T>(entity, component)
var addMethod = FindGenericMethod(worldValue.ExactType, "Add", typeof(T));
eval.CallFunction(addMethod, [worldValue, entityValue, componentValue]);
eval.WaitForResult();
}
}
6. Query Execution in Debugger
Execute ECS queries to find entities matching criteria:
public class QueryDebugger
{
public List<EntityView> ExecuteQuery(CorDebugValue worldValue, string queryExpression)
{
// Parse query like "Query<Position, Velocity>().Without<Frozen>()"
var queryAst = ParseQueryExpression(queryExpression);
// Build and execute query in debuggee
var queryBuilderMethod = FindMethod(worldValue.ExactType, "Query");
eval.CallFunction(queryBuilderMethod, [worldValue], queryAst.ComponentTypes);
var queryBuilder = eval.Result;
// Apply filters
foreach (var without in queryAst.WithoutTypes)
{
var withoutMethod = FindGenericMethod(queryBuilder.ExactType, "Without", without);
eval.CallFunction(withoutMethod, [queryBuilder]);
queryBuilder = eval.Result;
}
// Enumerate results
var entities = new List<EntityView>();
var enumerator = GetEnumerator(queryBuilder);
while (MoveNext(enumerator))
{
var entity = GetCurrent(enumerator);
entities.Add(VisualizeEntity(entity, worldValue));
}
return entities;
}
}
7. Breakpoint Conditions with ECS Context
Create smart breakpoints that trigger on ECS conditions:
public class EcsConditionalBreakpoint
{
public void SetComponentBreakpoint(
string sourceFile, int line,
Func<CorDebugFrame, bool> condition)
{
var bp = breakpoints.SetBreakpoint(sourceFile, line);
bp.OnHit += (sender, args) =>
{
// Evaluate ECS condition
if (condition(args.Frame))
{
args.Continue = false; // Break
}
else
{
args.Continue = true; // Skip
}
};
}
// Example: Break only when entity has specific component
public void BreakWhenEntityHas<T>(string file, int line, string entityVarName)
{
SetComponentBreakpoint(file, line, frame =>
{
var entity = frame.GetLocalVariable(entityVarName);
var world = FindWorldInScope(frame);
return HasComponent<T>(world, entity);
});
}
// Example: Break when component field exceeds threshold
public void BreakWhenFieldExceeds<T>(string file, int line, string field, float threshold)
{
SetComponentBreakpoint(file, line, frame =>
{
var entity = frame.GetLocalVariable("entity");
var component = GetComponent<T>(entity);
var value = component.GetFieldValue(field).As<CorDebugGenericValue>().GetValue<float>();
return value > threshold;
});
}
}
Breakpoints Without a Code Editor
Since KeenEyes Editor focuses on scene/entity editing rather than code editing, we need alternative ways to set breakpoints:
1. ECS-Level Breakpoints (No Source Code Needed)
Break on ECS operations rather than source lines:
public class EcsBreakpointPanel : EditorPanel
{
private EcsDebugger debugger;
// UI: Dropdown of registered systems → "Break on Entry/Exit"
public void BreakOnSystem(Type systemType, BreakTiming timing)
{
var methodName = timing == BreakTiming.Entry ? "Update" : "Update";
debugger.SetMethodBreakpoint(systemType.FullName, methodName, timing);
}
// UI: Entity selected in hierarchy → "Break when accessed"
public void BreakOnEntityAccess(int entityId)
{
// Break when World.Get<T>(entity) is called for this entity
debugger.SetConditionalBreakpoint(
"KeenEyes.Core.World", "Get",
frame => GetEntityIdFromArg(frame, 1) == entityId
);
}
// UI: Component type selected → "Break on Add/Remove/Modify"
public void BreakOnComponentChange(Type componentType, ChangeType change)
{
var method = change switch
{
ChangeType.Add => "Add",
ChangeType.Remove => "Remove",
_ => throw new NotSupportedException()
};
debugger.SetMethodBreakpoint("KeenEyes.Core.World", method, componentType);
}
// UI: "Break on spawn" / "Break on despawn"
public void BreakOnEntityLifecycle(LifecycleEvent evt)
{
var method = evt == LifecycleEvent.Spawn ? "Spawn" : "Despawn";
debugger.SetMethodBreakpoint("KeenEyes.Core.World", method);
}
}
UI Panel Concept:
┌─────────────────────────────────────────────────────┐
│ ECS Breakpoints [+ Add]│
├─────────────────────────────────────────────────────┤
│ ○ System: MovementSystem [Entry] │
│ ○ System: PhysicsSystem [Exit] │
│ ○ Entity #42 accessed [Any] │
│ ○ Component: Health modified [Write] │
│ ○ Entity spawned [All] │
│ ○ Query: <Position, Velocity> [Match] │
└─────────────────────────────────────────────────────┘
2. Method Breakpoints (Type + Method Name)
Allow users to specify breakpoints by class/method name without viewing source:
public class MethodBreakpointDialog
{
// UI: Searchable list of types from loaded assemblies
public void ShowTypeSearch()
{
var types = debugger.GetLoadedTypes()
.Where(t => t.Namespace?.StartsWith("MyGame") == true)
.OrderBy(t => t.Name);
typeListView.ItemsSource = types;
}
// UI: After type selected, show methods
public void OnTypeSelected(Type type)
{
var methods = debugger.GetMethods(type)
.Where(m => !m.IsSpecialName) // Skip property getters/setters
.OrderBy(m => m.Name);
methodListView.ItemsSource = methods;
}
// UI: Set breakpoint on selected method
public void OnMethodSelected(MethodInfo method)
{
debugger.SetMethodBreakpoint(method.DeclaringType.FullName, method.Name);
}
}
3. External IDE Sync (Recommended for Source-Level Debugging)
Integrate with VS Code/Rider for code-level breakpoints while editor shows ECS state:
public class ExternalIdeSync
{
// Read breakpoints from .vscode/launch.json or .idea Run Configurations
public List<SourceBreakpoint> LoadExternalBreakpoints()
{
var vscodeConfig = Path.Combine(projectRoot, ".vscode", "launch.json");
if (File.Exists(vscodeConfig))
{
// Parse VS Code breakpoints from workspace state
return ParseVsCodeBreakpoints(vscodeConfig);
}
// Or sync via DAP - connect to running VS Code debug session
return SyncFromDapSession();
}
// Alternative: Watch for .kebreakpoints file that external tools can write
public void WatchBreakpointFile()
{
var bpFile = Path.Combine(projectRoot, ".kebreakpoints");
fileWatcher.Watch(bpFile, () =>
{
var breakpoints = JsonSerializer.Deserialize<BreakpointList>(File.ReadAllText(bpFile));
foreach (var bp in breakpoints.Items)
{
debugger.SetBreakpoint(bp.File, bp.Line, bp.Condition);
}
});
}
}
.kebreakpoints file format:
{
"breakpoints": [
{ "file": "src/Systems/MovementSystem.cs", "line": 42 },
{ "file": "src/Systems/PhysicsSystem.cs", "line": 18, "condition": "entity.Id == 5" }
]
}
4. Minimal Source Browser (Lightweight)
If we want some source visibility without a full editor:
public class SourceBreakpointPanel : EditorPanel
{
// File tree showing .cs files in project
private TreeView fileTree;
// Read-only source view with line numbers
private SourceViewer sourceViewer;
// Breakpoint gutter (click to toggle)
private BreakpointGutter gutter;
public void OnFileSelected(string filePath)
{
var source = File.ReadAllText(filePath);
sourceViewer.SetText(source, readOnly: true);
var existingBps = debugger.GetBreakpoints(filePath);
gutter.ShowBreakpoints(existingBps);
}
public void OnGutterClicked(int lineNumber)
{
var filePath = fileTree.SelectedFile;
if (debugger.HasBreakpoint(filePath, lineNumber))
{
debugger.RemoveBreakpoint(filePath, lineNumber);
}
else
{
debugger.SetBreakpoint(filePath, lineNumber);
}
gutter.Refresh();
}
}
5. Exception Breakpoints
Always available without source:
public class ExceptionBreakpointPanel : EditorPanel
{
public void BreakOnAllExceptions()
{
debugger.SetExceptionBreakpoint(ExceptionBreakMode.Always);
}
public void BreakOnUnhandledOnly()
{
debugger.SetExceptionBreakpoint(ExceptionBreakMode.Unhandled);
}
public void BreakOnSpecificException(Type exceptionType)
{
debugger.SetExceptionBreakpoint(exceptionType);
}
}
Recommended Approach for KeenEyes:
| Use Case | Solution |
|---|---|
| Debug ECS logic | ECS-level breakpoints (system/entity/component) |
| Debug specific code | Method breakpoints by type/method name |
| Full source debugging | External IDE (VS Code/Rider) + sync |
| Quick source check | Minimal read-only source browser |
| Crash investigation | Exception breakpoints |
The editor's strength is ECS-aware debugging - seeing entity state, component values, query results. Leave source-level debugging to IDEs that do it well, and focus on what the editor uniquely provides.
Built-in Code Editor Consideration
Should KeenEyes include a basic code editor like Godot? Here's the analysis:
Godot's Approach:
- Built-in GDScript editor with syntax highlighting, autocomplete, debugging
- Optional C# support opens external IDE (VS/Rider/VS Code)
- Script editor is tightly integrated with node inspector
- Breakpoints set in same window as scene tree
Unity's Approach:
- No built-in code editor
- Double-click script → opens external IDE
- Relies on IDE integration (VS, Rider) for debugging
- Focus on visual tools (Inspector, Animator, etc.)
Options for KeenEyes:
| Option | Effort | Experience | Maintenance |
|---|---|---|---|
| 1. No editor (Unity-style) | Low | Context switching | Low |
| 2. Read-only viewer + breakpoints | Low | View code, set BPs | Low |
| 3. Basic editor (notepad++) | Medium | Edit + BPs, no intellisense | Medium |
| 4. LSP-integrated editor | High | Full IDE features | High |
| 5. Embedded Monaco/AvalonEdit | Medium-High | Good editing, some features | Medium |
Option 2: Read-Only Viewer with Breakpoints (Minimum Viable)
public class CodeViewerPanel : EditorPanel
{
private SyntaxHighlightedTextView textView; // Read-only
private BreakpointGutter gutter;
private FileTreeView fileTree;
// View source, click gutter to set breakpoints
// Double-click line → opens in external IDE at that line
public void OpenInExternalIde(string file, int line)
{
// Launch configured IDE at specific line
var ide = Settings.PreferredIde; // "code", "rider", "vs"
Process.Start(ide, $"--goto \"{file}:{line}\"");
}
}
Option 3: Basic Editor (Godot-lite)
Provide editing without full IDE features:
public class BasicCodeEditor : EditorPanel
{
private AvalonEdit.TextEditor editor;
private BreakpointGutter gutter;
public BasicCodeEditor()
{
editor = new TextEditor
{
SyntaxHighlighting = LoadCSharpHighlighting(),
ShowLineNumbers = true,
FontFamily = new FontFamily("Consolas")
};
// Basic features only
editor.TextArea.TextEntering += OnTextEntering;
editor.TextArea.TextEntered += OnTextEntered;
}
// Simple bracket matching, no intellisense
private void OnTextEntered(object sender, TextCompositionEventArgs e)
{
if (e.Text == "{") InsertText("}");
if (e.Text == "(") InsertText(")");
}
}
Option 5: LSP Integration (Full Featured)
Use Language Server Protocol for C# features:
public class LspCodeEditor : EditorPanel
{
private ILanguageClient lspClient;
private TextEditor editor;
public async Task InitializeAsync()
{
// Connect to OmniSharp or csharp-ls
lspClient = new LanguageClient();
await lspClient.InitializeAsync(new InitializeParams
{
RootUri = projectRoot,
Capabilities = new ClientCapabilities
{
TextDocument = new TextDocumentClientCapabilities
{
Completion = new CompletionCapability { /* ... */ },
Hover = new HoverCapability { /* ... */ },
Definition = new DefinitionCapability { /* ... */ }
}
}
});
}
// Autocomplete via LSP
private async void OnCompletionRequested(int line, int column)
{
var completions = await lspClient.RequestCompletionAsync(
currentFile, new Position(line, column));
ShowCompletionPopup(completions);
}
}
Recommendation:
For KeenEyes, consider a phased approach:
| Phase | Feature | Rationale |
|---|---|---|
| MVP | Read-only viewer + breakpoints | Minimal effort, enables ECS debugging |
| v1.1 | Basic editing (AvalonEdit) | Indie devs want all-in-one |
| v2.0 | LSP integration | Compete with Godot's experience |
ECS-Specific Editor Enhancements:
Even a basic editor can have ECS-aware features:
public class EcsAwareEditor : BasicCodeEditor
{
// Hover over component type → show current values from debugger
public void OnHover(int line, int column)
{
var symbol = GetSymbolAtPosition(line, column);
if (IsComponentType(symbol))
{
// Show component data from paused game
var tooltip = debugger.GetComponentTooltip(symbol.Type);
ShowTooltip(tooltip);
}
}
// Right-click entity variable → "Inspect in Entity Browser"
public void OnContextMenu(int line, int column)
{
var symbol = GetSymbolAtPosition(line, column);
if (symbol.Type == typeof(Entity))
{
contextMenu.Add("Inspect Entity", () =>
{
var entityValue = debugger.EvaluateExpression(symbol.Name);
entityBrowser.Select(entityValue);
});
}
}
// Autocomplete for Query<...>
public void OnQueryCompletion()
{
// Show list of known component types
var components = project.GetComponentTypes();
ShowCompletionPopup(components.Select(c => c.Name));
}
}
Godot-Style Integration Example:
┌─────────────────────────────────────────────────────────────────────┐
│ KeenEyes Editor │
├──────────────┬──────────────────────────────────────────────────────┤
│ Scene Tree │ MovementSystem.cs [x] │
│ │ ─────────────────────────────────────────────────────│
│ ▼ World │ 1 │ public class MovementSystem : SystemBase │
│ ▼ Player │ 2 │ { │
│ Position │ 3 │ public override void Update(float dt) │
│ Velocity │ ●4 │ { │← Breakpoint
│ Sprite │ 5 │ foreach (var e in Query<Pos, Vel>()) │
│ ▼ Enemy │ 6 │ { │
│ Position │ 7 │ ref var pos = ref Get<Pos>(e); │← Current line
│ Health │ 8 │ ref var vel = ref Get<Vel>(e); │
│ │ 9 │ pos.X += vel.X * dt; ← pos.X=42.5 │← Inline value
│──────────────│ 10 │ } │
│ Inspector │ 11 │ } │
│──────────────│ 12 │ } │
│ Position │─────────────────────────────────────────────────────│
│ X: 42.5 │ Locals │ Call Stack │
│ Y: 100.0 │ e: Entity(42) │ MovementSystem.Update() line 7 │
│ Velocity │ pos: {X=42.5} │ SystemManager.Execute() line 89 │
│ X: 5.0 │ vel: {X=5.0} │ World.Update() line 156 │
│ Y: 0.0 │ dt: 0.016 │ │
└──────────────┴─────────────────┴───────────────────────────────────┘
Decision Factors:
| Factor | Lean Toward Built-in | Lean Toward External |
|---|---|---|
| Target audience | Indie/hobbyist | Professional |
| Language complexity | Simple (GDScript-like) | C# (complex) |
| Team size | Small team wants all-in-one | Large team has IDE preferences |
| Debugging focus | Code debugging | ECS visualization |
| Development resources | Have time for editor | Focus on core engine |
Recommendation for KeenEyes:
Start with Option 2 (read-only + breakpoints) and gauge demand. The unique value is ECS-aware debugging, not competing with VS Code. If users strongly request it, add basic editing (Option 3) later.
Approach 3: Hybrid (Recommended Architecture)
Use SharpDbg.Infrastructure for core debugging + custom ECS layer on top.
┌─────────────────────────────────────────────────────────────────┐
│ KeenEyes Editor │
├─────────────────────────────────────────────────────────────────┤
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Debug Toolbar │ │ Entity Inspector│ │ World Snapshot │ │
│ │ (Step/Continue) │ │ (Live View) │ │ (Pause View) │ │
│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │ │
│ ┌────────▼────────────────────▼────────────────────▼────────┐ │
│ │ ECS Debug Integration Layer │ │
│ │ - EntityVisualizer, ComponentVisualizer, QueryVisualizer │ │
│ │ - WorldSnapshot, ArchetypeInspector │ │
│ └────────────────────────────┬──────────────────────────────┘ │
│ │ │
│ ┌────────────────────────────▼──────────────────────────────┐ │
│ │ SharpDbg.Infrastructure │ │
│ │ - ManagedDebugger, BreakpointManager, VariableManager │ │
│ │ - ExpressionEvaluator, SymbolReader │ │
│ └────────────────────────────┬──────────────────────────────┘ │
│ │ │
│ ┌────────────────────────────▼──────────────────────────────┐ │
│ │ ClrDebug │ │
│ │ - ICorDebug wrappers, IMetaData, Symbol APIs │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────┐
│ Debuggee Game │
│ (KeenEyes App) │
└─────────────────────┘
NuGet Packages for DAP
For Hosting Debug Adapters (Client Side)
<!-- Microsoft's official DAP client/host library -->
<PackageReference Include="Microsoft.VisualStudio.Shared.VSCodeDebugProtocol" Version="18.0.10427.1" />
This package provides:
DebugProtocolHostfor communicating with adapters- Request/response/event types for all DAP messages
- Stream-based communication helpers
For Implementing Debug Adapters (Server Side)
<!-- ClrDebug for ICorDebug access -->
<PackageReference Include="ClrDebug" Version="0.3.4" />
SharpIDE Reference Implementation
Matt Parker's SharpIDE provides a working reference for debugger integration.
Key Files
| File | Purpose |
|---|---|
Features/Debugging/Debugger.cs |
Facade wrapping debugging operations |
Features/Debugging/DebuggingService.cs |
DAP client implementation |
Features/Debugging/Breakpoint.cs |
Breakpoint model |
Features/Debugging/ExecutionStopInfo.cs |
Execution pause state |
Features/Debugging/ThreadsStackTraceModel.cs |
Thread/stack representation |
DebuggingService Pattern
public class DebuggingService
{
private DebugProtocolHost protocolHost;
private Process debuggerProcess;
public async Task AttachAsync(int processId, string debuggerPath)
{
// 1. Start debugger process
debuggerProcess = StartDebugger(debuggerPath, "--interpreter=vscode");
// 2. Create protocol host
protocolHost = new DebugProtocolHost(
debuggerProcess.StandardInput.BaseStream,
debuggerProcess.StandardOutput.BaseStream);
// 3. Register event handlers
protocolHost.EventReceived += (sender, args) =>
{
switch (args.Event)
{
case StoppedEvent stopped:
OnExecutionStopped(stopped);
break;
case OutputEvent output:
OnDebugOutput(output);
break;
// ... other events
}
};
// 4. Start protocol host
protocolHost.Run();
// 5. Initialize
await protocolHost.SendRequestAsync(new InitializeRequest
{
ClientID = "keeneyes-editor",
AdapterID = "coreclr",
LinesStartAt1 = true,
ColumnsStartAt1 = true,
PathFormat = "path"
});
// 6. Attach to process
await protocolHost.SendRequestAsync(new AttachRequest
{
// ProcessId is a custom argument handled by the adapter
__ConfigurationProperties = new Dictionary<string, JToken>
{
["processId"] = processId
}
});
// 7. Set breakpoints
await SetBreakpointsAsync(initialBreakpoints);
// 8. Signal configuration complete
await protocolHost.SendRequestAsync(new ConfigurationDoneRequest());
}
public async Task<StackTraceResponse> GetStackTraceAsync(int threadId)
{
return await protocolHost.SendRequestAsync(new StackTraceRequest
{
ThreadId = threadId
});
}
public async Task StepOverAsync(int threadId)
{
await protocolHost.SendRequestAsync(new NextRequest
{
ThreadId = threadId
});
}
}
KeenEyes Editor Integration Plan
Phase 1: Core Debugger Integration
- Add SharpDbg.Infrastructure as a project reference or submodule
- Add ClrDebug NuGet package
- Create
EcsDebuggerservice wrapping ManagedDebugger - Implement attach/detach workflow for game processes
- Handle debugger callbacks (breakpoint hit, step complete, exception)
- Basic breakpoint support (line-based)
Phase 2: Standard Debug UI
- Debug toolbar (continue, step over, step in, step out, pause, stop)
- Breakpoint gutter markers in code editor
- Call stack panel with frame navigation
- Locals/Watch panel using VariableManager
- Debug console for application output
- Threads panel for multi-threaded debugging
Phase 3: ECS-Specific Visualizers
Custom views leveraging direct ICorDebugValue access:
| Visualizer | Description |
|---|---|
| World Inspector | Shows all worlds, entity counts, registered systems |
| Entity Browser | Hierarchical entity tree with parent/child relationships |
| Component Inspector | Struct field editor with live values during pause |
| Archetype Viewer | Visualize archetype composition and entity distribution |
| Query Debugger | Show which entities match a query, with component data |
| System Profiler | Execution time per system, integrated with debug stepping |
// Example: Entity browser integration
public class EntityBrowserPanel : EditorPanel
{
private EcsDebugger debugger;
public void OnDebuggerPaused(PausedEventArgs args)
{
// Find World instance in current scope
var worldValue = debugger.FindLocalVariable("world");
if (worldValue == null)
worldValue = debugger.FindStaticField("Game.Instance.World");
if (worldValue != null)
{
var snapshot = debugger.InspectWorld(worldValue);
RefreshEntityTree(snapshot);
}
}
private void OnEntitySelected(int entityId)
{
var components = debugger.GetEntityComponents(entityId);
componentInspector.Show(components);
}
}
Phase 4: Live Debugging Features
- Component Edit: Modify component values during pause, apply on continue
- Entity Spawn/Despawn: Create/destroy entities from debug UI
- Query Filtering: Filter entity browser by component query
- Breakpoint on Entity: Break when specific entity is accessed
- System Step: Step through systems one at a time in update loop
Phase 5: Advanced Integration
- Conditional breakpoints with ECS expressions (
entity.Has<Position>()) - Data breakpoints on component field changes
- Exception breakpoints with component context
- Hot reload integration (recompile systems, keep world state)
- Replay integration: Debug from replay checkpoint
- Remote debugging: Debug game running on another machine
Building SharpDbg
git clone https://github.com/MattParkerDev/sharpdbg.git
cd sharpdbg
dotnet build
# Output: artifacts/bin/SharpDbg.Cli/Debug/net10.0/SharpDbg.Cli.exe
The built executable is functionally equivalent to netcoredbg.exe and can be used with the --interpreter=vscode argument for DAP mode.
Key Considerations
Cross-Platform Support
- SharpDbg and ClrDebug support Windows, Linux, and macOS
- ICorDebug APIs are available on all .NET platforms
- DAP is platform-agnostic
Native AOT Compatibility
- SharpDbg itself uses reflection for the expression evaluator
- The DAP client (our editor code) can be AOT-compatible
- ClrDebug uses COM interop which works with AOT
Error Handling
The debug adapter may crash or become unresponsive. Handle:
- Process exit events
- Communication timeouts
- Malformed DAP messages
- Adapter initialization failures
Debugger Selection
Consider supporting multiple debuggers:
- SharpDbg (primary, pure C#)
- netcoredbg (fallback, more mature)
- Custom adapters for specific scenarios
See Also
- Scene Editor Architecture - Overall editor design
- Framework Editor - Editor plugin architecture
- Editor Plugin Architecture - ADR for plugin extensions
- Dynamic Plugin Loading - ADR for runtime plugin loading