This document describes the design and implementation of the SourcePawn debugger for VS Code. It is written for C++/TypeScript engineers who maintain or extend the codebase.
Audience: Maintainers and contributors (deep systems knowledge required)
- Overview
- Component Architecture
- Threading Model and Synchronization
- DAP Protocol Translation
- Breakpoint System
- Symbol Resolution and Variable Rendering
- Handle Registry (Scope Encoding)
- Frame Navigation (The Hack)
- Variable Evaluation
- Data Flow Examples
The debugger is a distributed system with two parts:
-
C++ Extension (
sp-console-debugger/) — Runs on the game server (SourceMod extension). Listens on a TCP socket for DAP commands. HooksOnDebugBreakcallback from the SourcePawn VM to intercept execution at each debuggable line. -
VS Code Extension (
vscode/) — Runs on the developer's machine (TypeScript, Node). Connects to the C++ extension via TCP. Translates VS Code's DAP requests into the protocol and manages the session lifecycle.
The two components communicate via the Debug Adapter Protocol (DAP) over TCP with Content-Length framing (RFC 3156 headers with UTF-8 JSON bodies).
- The C++ extension must run in-process on the main game thread to hook
OnDebugBreak(the only way to intercept SourcePawn execution) - The VS Code extension must run on the developer's machine to integrate with the editor UI
- DAP is the standard protocol that VS Code expects from debuggers, so we just implement it
The extension only enables VM line-debugging when loaded at server startup (non-late mode). Using sm exts load or sm exts reload loads it late, after plugins are running, which skips the critical EnableDebugBreak() call. Already-loaded plugins will then report "not debuggable," and every launch will fail with "Failed to start debugging the specified plugin" with success=false.
The only correct deployment is: Copy the new .so files, then do a full game-server process restart (stop + start, or _restart). sm exts reload is not sufficient.
Also required:
addons/sourcemod/configs/core.cfgmust have"EnableLineDebugging" "yes"- The target plugin must actually be loaded (its dependencies satisfied; appears in
sm plugins list)
Extension (extension.cpp)
├── TcpServer (tcp-server.cpp)
│ ├── Listener thread (accepts connections)
│ ├── ClientConnection (per-client socket + session state)
│ └── ClientThread (per-client TCP read/write thread)
├── DAPHandlers (dap-handlers.cpp)
│ └── Command dispatch (initialize, setBreakpoints, continue, etc.)
├── SessionManager (session-manager.cpp)
│ └── Maps DAP clients to debug sessions
├── Debugger (debugger.cpp) — one per plugin context
│ ├── BreakpointManager
│ ├── SymbolManager
│ └── RunMode / WaitForDAPCommand
└── OnDebugBreak callback (hooked via smutils)
└── Called by VM thread whenever a debuggable line executes
Key Classes:
| Class | File | Purpose |
|---|---|---|
ConsoleDebugger |
extension.h/cpp | Global singleton; SDK_OnLoad/OnUnload; host object for DAPHandlers |
TcpServer |
tcp-server.h/cpp | Socket listener; creates ClientConnection per connecting client |
ClientConnection |
tcp-server.h | Per-client socket, receive buffer, session pointer, pending events |
ClientThread |
tcp-server.h | Worker thread that reads TCP messages and calls DAPHandlers |
DebugSession |
tcp-server.h | Coroutine-free state machine (bound client + active plugin context) |
DAPHandlers |
dap-handlers.h/cpp | Stateless dispatcher; dispatches DAP commands to implementations |
Debugger |
debugger.h/cpp | Per-plugin-context state; breakpoints, symbols, runmode, frame selection |
BreakpointManager |
breakpoints.h/cpp | Breakpoint storage, hit count, conditional evaluation, logpoints |
SymbolManager |
symbols.h/cpp | Symbol lookup, variable rendering, watch list |
VariableHandleRegistry |
variable-handles.h | Maps DAP variable references to symbol names |
Extension (extension.ts)
├── SourcePawnDebugSession (debugAdapter.ts)
│ └── LoggingDebugSession (from @vscode/debugadapter)
├── DebugSessionManager (adapters/DebugSessionManager.ts)
│ ├── ConnectionManager (connections/ConnectionManager.ts)
│ │ ├── TcpConnection (connections/TcpConnection.ts)
│ │ ├── RequestRegistry (connections/RequestRegistry.ts)
│ │ ├── KeepaliveManager (connections/KeepaliveManager.ts)
│ │ └── ReconnectionManager (connections/ReconnectionManager.ts)
│ └── PathMapper (utils/PathMapper.ts)
└── DAPProtocol (protocols/DAPProtocol.ts)
└── Message format definitions
Key Classes:
| Class | File | Purpose |
|---|---|---|
SourcePawnDebugSession |
debugAdapter.ts | DAP adapter; translates VS Code requests to protocol calls |
DebugSessionManager |
adapters/DebugSessionManager.ts | Session lifecycle; routes DAP requests to handlers |
ConnectionManager |
connections/ConnectionManager.ts | Manages TcpConnection, RequestRegistry, keepalive, reconnect |
TcpConnection |
connections/TcpConnection.ts | Socket client; byte-accurate framing; emits diagnostic events |
RequestRegistry |
connections/RequestRegistry.ts | Correlates requests by messageSeq; resolves promises |
KeepaliveManager |
connections/KeepaliveManager.ts | Sends ping while paused to detect network failures |
ReconnectionManager |
connections/ReconnectionManager.ts | Exponential backoff reconnection on socket close |
PathMapper |
utils/PathMapper.ts | Remaps compiler file paths to local workspace paths |
This is the critical architectural constraint:
- SourcePawn VM: Single-threaded, NOT thread-safe. Runs on the game server's main thread. When a breakpoint fires,
OnDebugBreakis called on the main thread, and the thread blocks (parks) in a condition variable until DAP commands resume it. - TCP Thread: A separate thread that reads messages from the socket and dispatches them to
DAPHandlers. Sends responses and events back to the DAP client.
Thread ownership matters here. The accept thread and every client thread are
owned and joined by TcpServer::Stop(), which runs on the GAME thread whenever
the extension is unloaded (sm exts unload/reload, or SourceMod shutting down
on a server restart) -- and the moment it returns, the .so can be gone. Two
rules follow, both learned from crashes:
- The listening socket is non-blocking and the accept loop polls it. Closing
a socket does not reliably wake a thread parked in
accept(), so joining one that was blocked there hung the game thread and froze the whole server. - Client threads are joined, never detached. A detached thread still inside
ClientThreadwhen the library is unloaded runs code that no longer exists, which segfaulted the server on restart with an editor attached.
src/__tests__/integration/extension-lifecycle.test.ts covers both.
Main Thread (VM) TCP Thread (DAP)
─────────────────────────────────────────────────────────────
Plugin line 363 executes
OnDebugBreak(cip, frm) called
(on main thread)
│
├─ CheckBreakpoint()
│ └─ Is this line breakpointed?
│
├─ (if logpoint): format message, emit output event, return
│ [No freeze]
│
├─ (if normal breakpoint):
│ └─ Emit stopped event (on main thread, via TcpServer)
│ └─ write_mutex_ guards socket writes
│
└─ Park in WaitForDAPCommand()
(main thread blocks here)
│
└─ Loop until dap_should_continue_ is true
(condition variable)
← Client reads "stopped" event
← Client sends continue request
← ClientThread reads continue
← HandleContinue() called
├─ SetRunmode(RUNNING)
├─ Emit continued event
└─ Signal dap_should_continue_
← Wake up from condition variable
← Return from WaitForDAPCommand()
Resume execution on line 364
Key synchronization primitives:
dap_should_continue_(atomic) — Shared state between main and TCP threadsdap_condition_(condition_variable) — Main thread waits here; TCP thread signalswrite_mutex_(mutex) — Guards socket writes (events from main thread, responses from TCP thread)
Pausing at a breakpoint blocks the main thread. While the main thread is blocked:
- The game loop cannot advance
- Physics doesn't update
- Network packets aren't processed
- Players appear frozen
This is not a bug. It's inherent to running a debugger in-process on the main thread. The only non-freezing alternative is the snapshot logpoint, which captures state and continues immediately.
Owner Token: A debug session is owned by the DAP client that initiated it. Multiple clients can connect (one per debug session), but only the owning client can send commands. On disconnect, cleanup only happens if the disconnecting client was the owner.
// In Debugger::
const void* owner_token_; // opaque pointer to ClientConnection
void SetOwnerToken(const void* token) { owner_token_ = token; }
const void* OwnerToken() const { return owner_token_; }
// In OnDisconnect:
if (client->this_pointer == debugger->OwnerToken()) {
debugger->Deactivate(); // Clear breakpoints, resume execution
}This prevents a stale disconnect (from a crashed previous client) from killing a newer session.
All messages are Content-Length framed JSON:
Content-Length: 123\r\n
\r\n
{"seq":1,"type":"request","command":"initialize","arguments":{...}}
On the C++ side: TcpServer::ReceiveMessage() parses the header, reads exactly N bytes from the socket, and parses the JSON body. SendData() builds the header and writes atomically (mutex-guarded).
On the TypeScript side: TcpConnection uses a Buffer (byte array) to accumulate incoming data. It parses the header as latin1 (ASCII-safe), reads the byte count from Content-Length, slices exactly N bytes, and decodes to UTF-8.
DAP uses seq (sequence numbers) to correlate responses to requests:
Request (seq=5):
{"seq":5,"type":"request","command":"continue"}
Response (request_seq=5):
{"seq":6,"type":"response","request_seq":5,"command":"continue","success":true}
On the C++ side: DAPHandlers reads request_seq from the request and echoes it in the response.
On the TypeScript side: RequestRegistry maps seq → Promise. When a response arrives with request_seq=5, the promise for request 5 is resolved.
Events are sent unsolicited (e.g., when execution stops or output is logged):
Event (no seq):
{"type":"event","event":"stopped","body":{"reason":"breakpoint","threadId":0}}
Events have no seq or request_seq. They are forwarded directly to the DAP client (VS Code).
BreakpointManager stores breakpoints in a map keyed by code instruction pointer (cip), which is the VM bytecode address:
// breakpoints.h
class BreakpointManager {
ke::HashMap<cell_t, Breakpoint*> breakpoints_; // keyed by cip
Breakpoint* AddBreakpoint(const std::string& file, cell_t line);
Breakpoint* CheckBreakpoint(cell_t cip); // Called from OnDebugBreak
};When a DAP client calls setBreakpoints, the C++ extension must resolve each line number to a code address (cip). This uses SourcePawn's debug symbols:
// In BreakpointManager::AddBreakpoint(file, line)
SourcePawn::IDebugInfo* info = debugger->GetDebugInfo();
while (info->GetLineAddress(line, &cip) == SP_ERROR_NONE) {
// Found a cip for this line; create breakpoint
auto bp = std::make_unique<Breakpoint>(this, cip, ...);
breakpoints_[cip] = bp.get();
}Gotchas:
- A single line can have multiple addresses (e.g., a loop unrolled into multiple instructions)
- Empty lines (whitespace only) are snapped to the next executable line
- Function prologues are skipped, so locals are in scope
A Breakpoint can have a condition expression:
struct Breakpoint {
std::string condition_expr; // "x > 5"
bool EvaluateCondition(const Debugger* debugger);
};Evaluation is lazy—only when the breakpoint hits. The condition is evaluated in the context of the current frame (cip, frm) using EvaluateExpression(), which looks up the variable in the symbol table and compares it.
Supported operators: ==, !=, <, <=, >, >= (single comparison; no && or ||).
A Breakpoint tracks hit_count:
struct Breakpoint {
int hit_count = 0;
int hit_condition = 0; // plain integer (e.g., 3 = trigger from 3rd hit onward)
};
bool CheckHitCount() {
hit_count++;
// Only support a plain positive integer: break from Nth hit onward
if (hit_condition > 0 && hit_count < hit_condition)
return false; // Not hit yet
return true; // Hit at or past the condition
}Supported format: Plain positive integer only (e.g., 3, 10). The server validates with std::isdigit() and rejects operator forms like == 10, >= 5, % 3. If the hit condition is N, execution pauses starting from the Nth hit and every hit thereafter.
A breakpoint can have a log message:
struct Breakpoint {
std::string log_message; // "Player {player_id} took {damage} damage"
};When the breakpoint hits, FormatLogMessage() interpolates {expr} tokens and emits an output event, then continues execution immediately (does NOT pause). Logpoints never freeze the server.
Special log messages {@all}, {@locals}, {@args} trigger snapshot mode:
bool FormatLogMessage(std::string& out) {
if (log_message == "{@all}") {
// Capture snapshot: all locals + arguments (NOT globals)
FormatScopeSnapshot(cip, frm, true, true);
return false; // Don't pause
}
// ... normal logpoint processing
return false; // Logpoints NEVER pause
}Key difference: All logpoints (including regular ones with {expr}) return false from CheckBreakpoint(), so OnDebugBreak returns immediately without calling SetRunmode(). No pause, no freeze. The {@all} snapshot captures locals and arguments only (not globals, which a plugin can have 50+). To log a specific global, name it explicitly: {g_iValidBhops[client]}.
SymbolManager wraps SourcePawn's IDebugSymbol and ISymbolType APIs and implements value rendering for the DAP variables view.
class SymbolManager {
void LookupSymbol(const std::string& name, int frame_id, SymbolWrapper& out);
std::string GetSymbolString(const IDebugSymbol* sym); // LocalToStringNULL
std::vector<ChildVariable> GetSymbolChildren(const IDebugSymbol* sym, int index);
};A scalar cell (int, float, bool, char) is rendered according to its SourcePawn type:
std::string FormatScalar(const ISymbolType* type, cell_t value) {
if (type->GetTag() == pc_tag_float) {
return FormatFloat(*(float*)&value); // 0x41200000 → 10.0
}
if (type->GetTag() == pc_tag_bool) {
return value ? "true" : "false";
}
if (type->GetTag() == pc_tag_char) {
return FormatChar(value); // 65 → 'A'
}
return std::to_string(value); // int
}Arrays are expanded on demand. Each array element becomes a child variable:
std::vector<ChildVariable> GetArrayChildren(const IDebugSymbol* sym) {
std::vector<ChildVariable> children;
for (int i = 0; i < array_size; i++) {
ChildVariable child;
child.name = "[" + std::to_string(i) + "]";
child.value = GetSymbolValue(sym, i);
child.type = GetElementType(sym);
children.push_back(child);
}
return children;
}The DAP client renders these as children in the Variables panel.
SourcePawn's IDebugSymbol::GetCodeStart() and GetCodeEnd() define the range of code addresses where the symbol is in scope. The debugger checks this when rendering variables:
bool IsInScope(const IDebugSymbol* sym, cell_t cip) {
if (sym->GetScope() == SCOPE_LOCAL || sym->GetScope() == SCOPE_ARGUMENT) {
return cip >= sym->GetCodeStart() && cip < sym->GetCodeEnd();
}
// Globals/statics are always in scope
return true;
}Important: This check is only applied to locals and arguments. Globals and statics don't have meaningful code ranges, so they're always visible.
DAP uses integer "references" to represent variables for expansion (e.g., "expand this array"). The debugger uses two encoding schemes:
For simple variables in a specific scope (Locals, Arguments, Globals), the reference is computed from frame ID and scope type:
int EncodeVariableReference(int frame_id, int scope_type) {
// scope_type: 0=Locals, 1=Arguments, 2=Globals
return (frame_id + 1) * 1000 + scope_type;
}
void DecodeVariableReference(int ref, int& frame_id, int& scope_type) {
frame_id = (ref / 1000) - 1;
scope_type = ref % 1000;
}A reference < 100000 is a scope encoding.
For expandable variables (arrays, structs), a handle is allocated by VariableHandleRegistry:
// In variable-handles.h
constexpr int kVariableHandleBase = 100000;
class VariableHandleRegistry {
std::unordered_map<int, VariableHandle> handles_;
int next_handle_ = kVariableHandleBase;
int Allocate(int frame_id, int scope_type, const std::string& symbolName) {
int handle = next_handle_++;
handles_[handle] = {frame_id, scope_type, symbolName};
return handle;
}
};A reference >= 100000 is a handle. It maps to a VariableHandle struct, which stores the original symbol name so it can be re-resolved when expanding.
Why re-resolve? IDebugSymbol pointers are owned by the debug-info iterator and may be invalidated between requests. We can't cache them. Instead, we store the symbol name and look it up again (just like the console watch list does).
SourcePawn's public debug API doesn't expose frame iteration. The frame command (and now DAP frame selection) uses a fragile hack in frame-utils.h to reach into the VM's internal FrameIteratorHack class:
// frame-utils.h
namespace sp {
class FrameIteratorHack {
public:
virtual void somefunc() = 0;
void* ivk_;
void* runtime_;
intptr_t* next_exit_fp_;
std::unique_ptr<InlineFrameIterator> frame_cursor_;
};
inline cell_t ReadContextFramePointer(SourcePawn::IPluginContext* ctx) {
return *(cell_t*)(uintptr_t(ctx) + sizeof(void*) * 10 + sizeof(bool) * 4 +
sizeof(uint32_t) * 2 + sizeof(cell_t) * 3);
}
}How it works:
IPluginContextcontains aFrameIteratorHackpointer- We compute the offset into
IPluginContext(magic offsets based on the SDK version) - We cast to
FrameIteratorHackand access the frame cursor - We iterate frames using the non-public
InlineFrameIteratorAPI
Fragility: The offsets depend on the SourcePawn VM version. If the SDK changes (new fields added to IPluginContext), these offsets become invalid and frame selection breaks.
Workaround: The offsets are kept in one place (frame-utils.h). When VM updates break frame selection, adjust the offsets there.
Better solution: Submit a PR to SourceMod to expose frame iteration as a public API.
Users can type hovers like:
static float health[10]
or
coords[0]; // comment
EvaluateExpression() canonicalizes messy input to extract the last identifier:
std::string CanonicalizeExpression(const std::string& expr) {
// Strip declaration keywords (static, const, etc.)
// Strip type names (float, int, etc.)
// Strip trailing comments (after //)
// Strip trailing whitespace and semicolons
// Recursively strip `[index]` suffixes
// Return the last identifier
return "health" or "coords"
}Once canonicalized, the symbol is looked up in the current frame:
bool LookupSymbol(const std::string& name, int frame_id, SymbolWrapper& out) {
// frame_id selects which stack frame to search in
// Search locals, arguments, then globals (in order)
// Use IDebugSymbol::GetSymbol() to find the variable
// Return SymbolWrapper if found
}Users can evaluate coords[0] or g_PlayerData[5].kills. The DAP adapter:
- Extracts the root symbol (
coords,g_PlayerData) - Extracts the index/key path (
[0],[5].kills) - Calls
GetArrayElement()to fetch the element value
For single-level arrays:
bool GetArrayElement(const IDebugSymbol* sym, cell_t index, cell_t& out) {
cell_t addr = 0;
if (!sym->GetEffectiveAddress(0, &addr)) return false;
ISymbolType* elem_type = sym->GetType()->GetArrayType();
int elem_size = elem_type->GetSize();
cell_t elem_addr = addr + (index * elem_size);
return ReadMemory(elem_addr, &out, sizeof(cell_t));
}DAP Client C++ Extension
─────────────────────────────────────────────────────
setBreakpoints request
(file: "antibhop.sp", lines: [363, 400])
│
├─→ [TCP] Content-Length: ...\r\n\r\n{...}
│
│ [Read on TCP thread]
│ │
│ ├─ Parse Content-Length
│ ├─ Read N bytes
│ ├─ Parse JSON
│ ├─ DAPHandlers::HandleSetBreakpoints()
│ │ ├─ BreakpointManager::AddBreakpoint()
│ │ │ ├─ GetDebugInfo()
│ │ │ ├─ FindFileByPartialName("antibhop.sp")
│ │ │ ├─ GetLineAddress(363, &cip)
│ │ │ └─ Create Breakpoint(cip=0x1a2b)
│ │ │
│ │ └─ Build response JSON
│ │ {verified: true, line: 363, ...}
│ │
│ ←─ [TCP] Content-Length: ...\r\n\r\n{...}
│ (response_seq: 1, success: true, body: {breakpoints: [...]})
│
└─ Update Variables panel
Show "Breakpoint at antibhop.sp:363"
Game Server (Main Thread) DAP Client (TCP Thread)
─────────────────────────────────────────────────────────────
Plugin executes line 363 (cip=0x1a2b)
│
├─ OnDebugBreak callback invoked
│ (on main thread)
│
├─ BreakpointManager::CheckBreakpoint(0x1a2b)
│ └─ Returns Breakpoint struct
│
├─ Is it a logpoint with {@all}?
│ └─ No, normal breakpoint
│
├─ Emit stopped event
│ {type:"event", event:"stopped", body:{reason:"breakpoint", threadId:0}}
│ ├─ TcpServer::SendEvent() [main thread]
│ │ └─ Acquire write_mutex_
│ │ └─ Write to socket
│ │ └─ Release write_mutex_
│ │
│ └─ [TCP] Sent to client
│ │
│ └─→ [DAP Client reads event]
│ ├─ Handle "stopped" event
│ ├─ Update state: debuggerState = "stopped"
│ ├─ Emit StoppedEvent to VS Code
│ └─ VS Code calls getStackTrace, getScopes, variables, ...
│
├─ Park main thread in WaitForDAPCommand()
│ dap_condition_.wait(lock)
│
│ ← [DAP Client] User clicks "Continue" button
│ ← [DAP Client] Sends continue request
│ ← [DAP Client] [TCP] Content-Length: ...\r\n\r\n{...}
│ ← [TCP Thread reads continue]
│ ← DAPHandlers::HandleContinue()
│ │ ├─ SetRunmode(RUNNING)
│ │ ├─ dap_should_continue_ = true
│ │ ├─ dap_condition_.notify_one()
│ │ └─ Build response (success: true)
│ │
│ └─ [TCP] Sent response to client
│
└─ Wake up from dap_condition_.wait()
Return from WaitForDAPCommand()
Resume execution on line 364
DAP Client C++ Extension
─────────────────────────────────────────────────────
[Paused at breakpoint 363]
│
User hovers over `health` in editor
│
├─ VS Code sends hover request (implicit evaluation)
│ (or user types in Watch: `health`)
│
├─→ [TCP] evaluate request
│ {command: "evaluate", arguments: {expression: "health", frameId: 0}}
│
│ [TCP thread]
│ │
│ ├─ DAPHandlers::HandleEvaluate()
│ │ ├─ CanonicalizeExpression("health")
│ │ ├─ SymbolManager::LookupSymbol("health", frameId=0)
│ │ │ ├─ SelectFrame(0)
│ │ │ ├─ GetDebugInfo()->FindLocalByName("health")
│ │ │ └─ SymbolWrapper(symbol)
│ │ │
│ │ ├─ Read value from memory
│ │ │ health_addr = frame_base + 12
│ │ │ health_value = *(int32*)health_addr = 78
│ │ │
│ │ ├─ Format value
│ │ │ type = int
│ │ │ value_str = "78"
│ │ │
│ │ └─ Build response
│ │ {success: true, result: "78", type: "int"}
│
│ ←─ [TCP] evaluate response
│ (body: {result: "78", type: "int"})
│
└─ VS Code displays tooltip: "health: 78 (int)"
That's the core architecture. The key insights:
- Main thread parks in a condition variable when a breakpoint fires; TCP thread signals to wake it
- Socket writes are mutex-guarded (events from main thread, responses from TCP thread)
- Scope references are encoded by frame+scope; aggregate variables use a handle registry
- Frame iteration is a hack that depends on VM internals
- DAP is the standard protocol — we just implement it faithfully