For any AI coding agent that needs to debug a Delphi Win32 or Win64 target end-to-end through the wire — including driving the target's UI to reproduce a bug. The guide covers the wire shape, recipes, and pause-loop semantics that work with any MCP-capable client. Pick the matching daemon by target bitness (rdbgd64 for Win64, rdbgd32 for Win32); the wire protocol is identical.
rdbgd64.exe) and Win32 (rdbgd32.exe) ship in parallel; the bridge auto-switches per launch based on the target's PE machine type.events tool: paused, breakpoint_hit, exception, debug_output, exit_process, create_process, load_dll. Every drain response includes idle_since_last_event_ms so you can tell "still chewing" from "wire is silent because something broke" without sending a probe RPC.status + bind_session let an agent adopt a session left by another client; release_session hands one back. interrupt mid-runs a target into a paused, reason:"interrupt" event for inspection without killing it; detach leaves the target running.list_windows, post_message, send_keys, set_text_field (cross-process WM_SETTEXT, no focus-loss races), click_at, send_input (raw HID), capture_window, screenshot_desktop (auto-downscales for 4K) — so an agent can drive a VCL UI without a human at the keyboard. VCL discovery: get_main_form, find_vcl_for_hwnd, enumerate_vcl_tree.spy action:"start" turns on a low-level recorder that emits an {"event":"input", ...} for every mouse/keyboard event the user performs against the debuggee, tagged with the VCL component path (Form1.pnlBottom.btnOK). A human reproduces a bug once; the agent persists the JSONL and replays it on every iteration. See R5c — Bug-reproduction handoff.get_registers frame_idx=N walks via the .pdata/.xdata unwinder on Win64 (FPO_DATA on Win32) to expose the up-stack frame's CONTEXT, with a valid_registers bitmask listing which slots are real (volatiles are never preserved). Step Over pre-arms a temp BP for direct/indirect/tail-call shapes, surfaced as prearmed_kind on the response.kernel32, ntdll, etc. resolve to module!Symbol+offset via Microsoft public PDBs auto-fetched on first use; set_breakpoint accepts the same module!symbol form (e.g. kernelbase!RaiseException).lsp_* tools backed by Embarcadero's DelphiLSP.exe. Go-to-definition / hover / completion / workspace-symbol-search / diagnostics — capability-gated per Delphi version. Useful before launching the debugger, or to find the symbol you want to break on without grepping.This is enough for an agent to find the suspected handler with LSP, set a BP there, reproduce a bug by driving the UI, and inspect the in-process Delphi state at the moment things go wrong — without ever rebuilding the target.
The daemon (rdbgd64.exe for Win64 targets, rdbgd32.exe for Win32) runs on the Windows host where your debuggee lives. The agent talks to it from anywhere over TCP — locally, across an SSH tunnel, or via the bundled MCP bridge.
# On the Windows host, start the daemon listening.
rdbgd64.exe --listen 127.0.0.1:5902
# From the agent's host: open a JSON-pipe REPL. Each line of stdout
# is one wire response or event; each line of stdin is one request.
# EOF on stdin sends `shutdown` and closes the daemon cleanly.
rdbgc.exe repl 127.0.0.1:5902
For scripted use, pipe a sequence of requests through with a bounded timeout — don't trust a daemon to be honest about deadlocks. Pattern: python3 -c "subprocess... timeout=N".
Every interaction the agent has is mediated by pause state:
RUNNING ── exception/BP/launch ──▶ PAUSED ── continue/step ──▶ RUNNING
│
other commands here
While paused, the agent can call any inspection method and the continue / step_* / run_to / detach methods.
While running, the wire only returns event messages until the next pause. Sending an inspection method while running gets you either error: target_running or — depending on the method — silence until the next event.
An agent's algorithm is always event-driven. After continue, drain events until something interesting comes back, then act.
Async daemon notifications (create_process, paused, breakpoint_hit, exception, debug_output, exit_process, load_dll, ...) are queued by the bridge and exposed via the events tool, which has no daemon round-trip — it just returns whatever's in the bridge's local buffer. Pattern:
// Pause-blocking wait: poll until something interesting arrives.
{"method":"events","params":{"timeout_ms":5000,"filter":["paused","exception","exit_process"]}}
// Non-blocking peek: timeout_ms:0 returns immediately with whatever's queued.
{"method":"events","params":{"timeout_ms":0}}
Every response carries idle_since_last_event_ms — how long the bridge has been receiving nothing from the daemon. This is the difference between "target is genuinely idle, waiting on a UI action you should drive" (large value) and "wire is silent because something broke" (also large, but you can probe with hello to disambiguate). Without this, agents would have to issue probe RPCs to tell the two states apart.
The fastest way to confirm liveness without consuming an event: send {"method":"hello"}. It's a no-op that confirms the daemon is alive and reports current pause state.
Since v0.19.33 the bridge also exposes a static-analysis surface backed by Embarcadero's DelphiLSP.exe: 13 lsp_* tools (lsp_definition, lsp_hover, lsp_completion, lsp_workspace_symbol, ...). These are paused-state independent — they query the source tree, not the running target — so an agent can navigate code before launching the debugger or while the target is in any state. Use them to find the symbol you want to break on (lsp_workspace_symbol → pick → lsp_definition → set_breakpoint source+line) instead of grepping. Capability-gated per Delphi version — absent capabilities surface as a structured error_reason on the first call.
The full reference is in docs/protocol.md; this is the agent-relevant slice.
| Group | Method | When | Notes |
|---|---|---|---|
| Lifecycle | hello | always | handshake; reports protocol + active sessions; no side effects |
| Lifecycle | launch | always | spawn target paused at loader BP. Bridge auto-switches daemon (rdbgd64 / rdbgd32) per target's PE machine type |
| Lifecycle | attach | always | by pid or name (substring); kernel synthesizes loader BP, lands paused |
| Lifecycle | list_processes | always | enumerate running PIDs + .map / .dpr discovery; name_match, has_map_only filters |
| Lifecycle | shutdown_daemon | always | terminates daemon; use sparingly |
| Lifecycle | bridge_config | always | introspect bridge: host, ports, daemon paths, both daemons' liveness, current connected daemon, LSP state. No daemon round-trip |
| Sessions | status / list_sessions | always | active + orphaned debug sessions; pause state per row; surfaces zombies left by prior clients |
| Sessions | bind_session | always | adopt an orphaned session by session_id; returns last_pause so you know where the target is stopped |
| Sessions | release_session | paused | orphan the current session voluntarily; target stays paused for next client |
| Control | interrupt | running | mid-run pause via DebugBreakProcess; surfaces as paused, reason:"interrupt" |
| Control | detach | paused | clear all BPs, set EFlags.RF, DebugActiveProcessStop; target keeps running |
| Control | kill_session | any | TerminateProcess on the target. Confirm with the user first |
| Stepping | continue | paused | resume. Drain events next to see where it stops |
| Stepping | step_over | paused | line-aware, skips CALL. Pre-armed temp BP for direct/indirect/tail-call shapes; response carries prearmed_kind: direct_call | indirect_call | tail_jump_direct | tail_jump_indirect when the new path fires |
| Stepping | step_into | paused | single-instruction including into callees |
| Stepping | run_to | paused | one-shot temp BP at address or source+line |
| Stepping | set_rip | paused | move execution point (e.g. skip past AV); accepts address or source+line |
| BPs | set_breakpoint | always | kind: source | symbol | address | hw. module!sym form (e.g. kernelbase!RaiseException) auto-fetches the matching public PDB |
| BPs | clear_breakpoint | always | by address OR bp_id |
| BPs | list_breakpoints | any | enumerate active BPs |
| Inspection | get_registers | paused | full GPR set + EFLAGS. frame_idx walks via .pdata/.xdata (Win64) or FPO_DATA (Win32) unwinder; response includes valid_registers bitmask listing slots actually restored from the caller frame, and frame_source: "current" | "unwind" | "fpo_data" | "fp_chain" |
| Inspection | read_param | paused | read calling-convention argument register without TD32 info. index 0-based; x64 maps RCX/RDX/R8/R9 then stack; x86 maps EAX/EDX/ECX. kind: string | memory | dyn_array for typed decode |
| Inspection | read | paused | unified read; kind: memory | string | dyn_array. Routes to read_memory/read_string/read_dyn_array per kind. string_kind: unicode | ansi | wide | short |
| Inspection | get_self | paused | resolve Self at the current frame; ABI-clean across x64/x86; returns pointer + decoded class chain |
| Inspection | inspect_object | paused | full RTTI walk: class chain + fields + props + methods. eval_method_props:true cross-process-calls each property getter (TForm.Caption, TStrings.Count, ...). include_methods, methods_from_leaf_only trim noise |
| Inspection | evaluate | paused | Pascal-style path expression: Self.Caption, Form.Edit1.Text, 0x<hex>.ComponentCount. Returns inspect_object-shape per leaf. v1: dot-paths only (no indexing / casts / method calls with args) |
| Inspection | inspect_locals | paused | params + stack-locals from TD32/TDS PE section (target must be built with -V). v1: frame_idx=0 only; type_name always empty pending v2 |
| Inspection | list_threads | paused | tids + last-known RIP per thread; is_paused_thread flags the one other methods default to |
| VCL | get_main_form | paused | resolve Application.MainForm: address + class + HWND |
| VCL | find_vcl_for_hwnd | paused | HWND → TWinControl* via VCL ControlOfs atom |
| VCL | enumerate_vcl_tree | paused | walk Components/Controls recursively from a root form. Returns class, name, caption, hwnd, event-handlers per node |
| UI | list_windows | paused | enumerate target HWNDs with class, title, rect |
| UI | capture_window | paused | BitBlt → PNG → base64. max_dimension default 1024 (cap 4096); client_only trims chrome |
| UI | screenshot_desktop | paused | full virtual desktop; auto-downscales via StretchBlt+HALFTONE on 4K displays. max_dimension default 800; oversized payloads spill to disk path |
| UI | post_message | paused | low-level WM_* poke; named or numeric msg; async, fire-and-forget |
| UI | send_keys | paused | UTF-16 → WM_KEYDOWN/CHAR/UP triplets. Or pass events:[] for explicit sequences |
| UI | set_text_field | paused | cross-process WM_SETTEXT. Instant, no focus-loss races. Preferred over send_keys for fuzzing |
| UI | click_at | paused | WM_MOUSEMOVE/LBUTTONDOWN/UP at client-area coord. Defaults to window center |
| UI | send_input | paused | raw HID injection (kernel input queue via SendInput). Last resort when send_keys/click_at aren't routed by the target |
| Recording | spy | any | action: "start" | "stop" | "status". Records every mouse/keyboard event the user performs against the debuggee, tagged with the VCL component path. Daemon needs --enable-spy |
| Exceptions | set_exception_filter | always | filter pushed exception events. classes:[...] substring match; first_chance:true / false |
| Events | events | always | drain the bridge's local event queue. timeout_ms:0 = peek; filter:["paused","exception",...] picks classes. Response includes idle_since_last_event_ms — tells you "still chewing" vs "wire is silent because something broke" |
| LSP | lsp_open_project | any | bootstrap a DelphiLSP child for a .dproj. Autogen .delphilsp.json if no sibling exists |
| LSP | lsp_definition | any | go-to-definition at file + 1-based line/column |
| LSP | lsp_declaration | any | declaration site (often same as definition for Delphi) |
| LSP | lsp_implementation | any | jump to interface implementation body |
| LSP | lsp_hover | any | type info + signature for symbol at cursor |
| LSP | lsp_references | any | find-references; capability-gated (Delphi 13.1+) |
| LSP | lsp_completion | any | completion items at cursor |
| LSP | lsp_signature_help | any | parameter info at call site |
| LSP | lsp_document_symbols | any | outline for one file |
| LSP | lsp_workspace_symbol | any | fuzzy-search symbols across project. Falls back to per-file aggregation when capability absent |
| LSP | lsp_diagnostics | any | last published diagnostics for a file |
| LSP | lsp_explain | any | aggregator: hover + definition snippet for one symbol |
| LSP | lsp_outline_workspace | any | aggregator: per-file document_symbols across the project |
UI methods accept either params.hwnd (raw hex) or params.target (caption / class name lookup). They post into the target's window queue while the daemon is paused; messages drain on the next continue. LSP methods are paused-state independent — the LSP child operates on source files, not the running target, so static-analysis queries work whether or not a debug session is live.
Operational patterns from least to most agent-driven. Each one is self-contained — mix and match for your scenario.
When the agent doesn't own the launch — the target was already running, or it's a long-lived service the user wants live inspection on:
// What's running that looks debuggable?
{"method":"list_processes","params":{"has_map_only":true}}
// response carries [{pid, name, exe_path, map_path?,
// source_dir?, dpr_path?}]
// Attach by name (first match)
{"method":"attach","params":{"name":"my-service.exe"}}
// or by exact PID
{"method":"attach","params":{"pid":1234}}
After attach the daemon synthesizes the standard event stream — create_process, a load_dll per loaded module, create_thread per existing thread, then a synthesized breakpoint (DbgUiRemoteBreakin) that lands paused. From there it behaves exactly like R1/R2.
The daemon checks <exe>.map next to the binary, and walks 2 parent directories looking for <name>.dpr (also under source/ and src/ subdirs at each level). Empty fields just mean nothing was found there — the target may still be debuggable; the agent loses source-level BPs but gets address-level access.
The minimal "is it broken in an obvious way" probe.
{"method":"hello"}
{"method":"launch","params":{"exe":"C:/builds/.../app.exe"}}
{"method":"continue"}
Response stream contains create_process, then loader-BP paused, then your continue ack, then whatever the target produces: debug_output events, eventually exit_process with an exit code. If you see an exception event, you've reproduced something without lifting a finger.
The bread-and-butter agent workflow: pick a likely handler from source, set a BP, run the target until it lands.
{"method":"launch","params":{"exe":"..."}}
{"method":"set_breakpoint",
"params":{"source":"MainForm.pas","line":248}}
{"method":"continue"}
Then read the stream. When breakpoint_hit arrives, the target is paused at the BP's RIP. From here:
{"method":"get_self"} // capture Self ptr
{"method":"inspect_object",
"params":{"address":"<self-from-above>","max_depth":2}}
{"method":"get_registers"}
get_self returns {"address":"0x...","class":"TForm1"} if RCX on entry to a method holds a Self pointer; falls back to not_object:true for non-method frames.
If the target was built with -V (Project > Options > Linking > "Place debug information in EXE"), inspect_locals beats poking RCX by hand: it returns named parameters and stack-locals for the procedure containing the paused RIP.
{"method":"inspect_locals"}
→ {"ok":true,"frame_idx":0,"function_symbol":"MainForm.TForm1.btnSaveClick",
"auto_walked_frames":0,"version_warning":null,
"locals":[
{"name":"Self", "location":"reg", "reg_id":0, ...},
{"name":"Sender","location":"reg", "reg_id":1, ...}
]}
auto_walked_frames is the number of frames the daemon walked up to find user code (positive = via RBP chain, negative = via RSP-scan fallback used at DebugBreak(); zero = current RIP was already in user code). When the EXE wasn't built with -V, the response carries error.message:"no_debug_section" with a fallback_hint — surface that and pivot to get_self + inspect_object for what you can still inspect.
{"method":"launch","params":{"exe":"..."}}
{"method":"continue"} // past loader BP into the message loop
// Find the form HWND.
{"method":"list_windows"}
// Response carries [{hwnd, class, title, rect, parent_hwnd}, ...].
// Pick the form by class:"TFormMain" or title:"My App".
// Set a BP at the click handler.
{"method":"set_breakpoint",
"params":{"source":"MainForm.pas","line":248}}
// Drive the UI. PostMessage queues even when target is paused -
// fine here, target is running.
{"method":"send_keys",
"params":{"hwnd":"<edit-hwnd>","text":"42"}}
{"method":"click_at",
"params":{"hwnd":"<button-hwnd>","x":50,"y":15,
"target":"OK"}}
Read the stream until breakpoint_hit arrives. The TEdit's text will already be "42" by the time the click handler runs, because WM_CHAR processed first. Inspect Self / locals as in R2.
Real workflow: target shows a "Save changes?" prompt and freezes your test until someone clicks. Three options, in order of preference:
// (a) Find and close the dialog.
{"method":"list_windows"}
// Match class:"TMessageForm" or title:"Confirm".
{"method":"post_message",
"params":{"hwnd":"<dialog-hwnd>","msg":16,"wparam":0,"lparam":0}}
// 16 = WM_CLOSE. Cleanest, runs the dialog's normal close path.
// (b) Press Esc inside the dialog (cancels via VCL default-cancel).
{"method":"send_keys",
"params":{"hwnd":"<dialog-hwnd>","text":""}}
// (c) Press Enter (default-affirm).
{"method":"send_keys",
"params":{"hwnd":"<dialog-hwnd>","text":"\n"}}
Multi-step: fill, click Next, fill, click Next, click Finish.
# pseudocode the agent runs around the wire
hwnd_form = list_windows() | grep "TFormWizard"
for step in [
{"field": "name", "value": "Test User", "next_button": "Next"},
{"field": "email", "value": "a@b.c", "next_button": "Next"},
{"field": "tier", "value": "premium", "next_button": "Finish"},
]:
hwnd_field = list_windows() | filter title==step.field # or by tab order
send_keys(hwnd_field, step.value)
sleep(0.2) # let the message pump catch up
click_at(target=step.next_button)
wait_for_event("breakpoint_hit" or "paused")
inspect_self()
Even with PostMessage, the target needs a few message-pump cycles to update its internal state (OnChange handlers, etc.) before the next click. 200ms is a safe lower bound for VCL.
When you've driven a UI to some state and want to see what the user would see — visual regressions, layout bugs, "is the dialog actually visible/correct" — capture_window returns a PNG of any HWND in the target.
# After R3 has driven inputtest to "hello\nclick OK",
# agent paused at the BP in the click handler.
resp = repl.send({"method":"capture_window",
"params":{"target":"TFormInputtest"}})
# resp.result has bytes_base64 (a PNG of the form pixels).
import base64, pathlib
png = base64.b64decode(resp["result"]["bytes_base64"]
.replace("\\/", "/")) # un-escape JSON
pathlib.Path("/tmp/inputtest-after-click.png").write_bytes(png)
Then read the file with the agent's vision capability — any modern AI agent with image-reading and a Read tool can describe the captured PNG. Particularly useful when:
The daemon must run on a host with an interactive desktop. Headless servers (Session 0, no display device) return blank PNGs — the kernel has no compositor surface to copy. Direct2D / Direct3D / Chromium-embedded controls bypass the GDI compositor and capture as black even with a display (affects games, embedded browsers; VCL native controls capture correctly). Never-painted windows (just-created HWND, no WM_PAINT yet) capture blank — VCL forms paint on first Show.
The spy methods solve a recurring agent problem: the user describes a UI bug in prose ("click Export, the dialog opens, type test in the path field, click Run, app crashes"), and you have to translate that into the right sequence of click_at / send_keys calls against components whose names you don't know yet. Just ask the human to record it.
--enable-spy (off by default; the low-level hooks see every input event on the daemon's desktop, so it's an explicit opt-in).rdbgc launch <host:port> <path-to-exe> --spy > repro.jsonl
They reproduce the bug once, then Ctrl-C. The JSONL contains the full event stream — debug events plus {"event":"input", ...} records for every click and keypress.event == "input"). Each input event has:
vcl_path — the dotted Owner.Component path you can match against list_windows output (Form1.PageControl1.tabExport.btnRunExport).vcl_class + class — Delphi class name + Win32 class.screen.x/y and client.x/y — coordinates for replay.hwnd — the HWND that received the input at recording time; do NOT use this directly for replay (the HWND is process-specific and won't survive a relaunch).vcl_path is set: call list_windows on your fresh launch, find the HWND whose component path matches, then send click_at (with the recorded client.x/y) or send_key_sequence (with the recorded vk / char).vcl_path is missing (non-VCL window — DirectDraw surface, hosted browser, etc.): fall back to send_input with the recorded screen.x/y. Less robust against window movement but works on anything.repro.jsonl against a fresh launch — no human in the loop after step 2.You can also call spy_start mid-session (interrupt the target, then {"method":"spy_start"} from a repl) to capture a new sequence — useful for confirming a regression hasn't reintroduced a bad input path, or for adding the user's "and then this also fails" follow-up to an existing repro.jsonl.
No wire method auto-arms it; even with --enable-spy on the daemon, the client must pass --spy at launch or send spy_start from a repl. So it's safe to assume an arbitrary session has no spy active unless you opted in for it.
The agent's superpower: run a launch+drive cycle in a tight loop until the bug fires, then drop into inspect mode automatically.
for attempt in range(50):
daemon = spawn_daemon()
repl = open_repl(daemon)
repl.send({"method":"launch","params":{"exe":"..."}})
repl.send({"method":"continue"})
# Drive the suspected reproducer.
repl.send(*recipe_R3_steps)
while event := repl.read_event(timeout=10):
if event.kind == "exception":
# Crash reproduced. Stop the loop, save state.
inspect_chain = repl.send({"method":"inspect_object",
"params":{"address":event["exception_object"],"max_depth":3}})
save_artifact(attempt, event, inspect_chain)
return
if event.kind == "exit_process":
# Clean exit this round; restart.
break
The daemon since v0.19.19 keeps paused sessions alive after a client disconnects (multi-conn). If your agent reconnects mid-flight — or another tool disconnected on you — you can re-attach instead of relaunching.
{"method":"status"}
// → {"sessions":[{"session_id":3,"pid":1234,"owner":"none",
// "state":"paused","last_pause":{...}}, ...]}
{"method":"bind_session","params":{"session_id":3}}
// → returns last_pause snapshot so you know reason / pid / tid /
// the source line the target stopped at; the session is now
// yours and inspect / step / continue all work normally.
Inverse: release_session orphans the current session voluntarily — useful for handing a long-paused target between tools (e.g. agent reproduces, hands off to GUI for human inspection).
Cheaper than list_processes + grep + guess. Works without a running target.
{"method":"lsp_open_project","params":{"project":"C:/myapp/MyApp.dproj"}}
// → autogen .delphilsp.json if needed, spawn DelphiLSP, advertise caps
{"method":"lsp_workspace_symbol","params":{"query":"SubmitClick"}}
// → [{name:"TFormMain.SubmitClick", file:"MainForm.pas", line:142, ...}]
{"method":"lsp_definition","params":{"file":"MainForm.pas","line":142,"column":10}}
// → confirms the body's source location (vs. forward declaration)
{"method":"set_breakpoint","params":{"source":"MainForm.pas","line":144}}
// → BP set; combine with R3 (drive UI) to fire it
The LSP child caches per-project (idle reap after 5 min); subsequent calls re-use the warm process. lsp_outline_workspace grabs every file's outline in one round-trip if you'd rather scan all symbol names locally.
list_windows returns the TWinControl's HWND but not its Delphi object pointer — post_message is fire-and-forget and doesn't return values. To bridge HWND → Self pointer:
click_at / send_keys to fire the handler.get_self returns the form pointer (RCX on entry to a Delphi method holds Self).Once you have the form pointer, inspect_object walks the full class chain + published-field/property values cross-process.
inspect_object decodes field-backed properties cross-process by default. For method-backed properties (like TForm.Caption, TEdit.Text, TStrings.Count) pass eval_method_props:true — the daemon hijacks the paused thread, calls the getter on a 64 KB scratch stack, captures the return register, then restores. Per-prop failures surface error_reason; for AVs you also get target_exception_code, target_exception_addr, target_exception_data_addr, and target_exception_op ("read" / "write" / "execute") so you can tell which pointer was bad without parsing EXCEPTION_RECORD. Set eval_timeout_ms (default 1000) to bound a misbehaving getter.
For targeted reads, evaluate takes a Pascal-style path: {expr:"Self.Caption"}, {expr:"Form.Edit1.Text"}, or {expr:"0x<hex>.ComponentCount"}. Returns the same per-prop wire shape inspect_object emits, so the same renderers work. The root is either Self (resolved via get_self's heuristics on the paused frame) or a literal hex pointer. Each .ident step walks fields and properties — method-backed getters dispatch through the same cross-process RPC as inspect_object eval_method_props:true. v1 doesn't support indexing (Components[0]), arithmetic, type casts, method calls with args, or locals; those fail with parseable error_reason values.
Stack frames inside the launched exe carry an optional approximate: true field. When set, the daemon couldn't confidently attribute the RIP to a specific source line — either (a) the RIP is in a procedure whose debug info was elided (Delphi CE Starter has been observed to drop line records and public symbols for certain procs entirely; the linker can place such a procedure between two procs that do have debug info, and the resolver picks the wrong neighbour); or (b) the RIP is legitimately past the last documented line of an extending procedure (epilogue, linker padding). The source, line, symbol, offset fields are all still populated as best-effort breadcrumbs — the flag tells the consumer to treat them as "near here, roughly" rather than as authoritative citations. Cross-check with the call-stack structure (which symbol is calling which) before drawing conclusions from an approximate frame.
list_threads shows every tid the daemon has tracked. The is_paused_thread flag marks the one whose context the other methods default to; pass an explicit tid parameter to get_registers to inspect a different thread.
Method dispatch failed because pause/run state didn't match. Check the last event you saw: if it was paused / breakpoint_hit / target_breakpoint / exception you're paused; if create_process followed by no pause and the target is consuming time, you're running. Fix by either continue-ing or waiting for the next pause event.
The daemon is generally honest about debugging state, but a target that's deadlocked won't give you events. Always wrap reads in a wall-clock timeout (the wire-scenarios harness uses python3 ... communicate(timeout=N)). On timeout: kill the SSH process group, kill the daemon explicitly via taskkill /F /IM rdbgd64.exe /T, kill known target binaries.
Window handles from list_windows are valid "for now". A target that closes and reopens a form gets a new HWND. Re-enumerate after major events (form-create / form-destroy in the target, mode-switching in the UI). Don't cache HWNDs across continue calls.
PostMessage doesn't wait for the message pump. Sending dozens of events in quick succession can flood the target's queue and lose WM_MOUSEMOVE coalescing. Pace at ~50–100ms between distinct UI actions; tighter for keystroke sequences.
Goal: a user reports that clicking Submit in inputtest.exe with an empty Email field crashes the app. Reproduce, capture stack + exception object, write up findings.
# Spawn a daemon on the Windows host.
rdbgd64.exe --listen 127.0.0.1:5902
# Drive a session from the agent's host (here, over SSH).
cat <<'EOF' | ssh windows-host "rdbgc.exe repl 127.0.0.1:5902"
{"method":"hello"}
{"method":"launch","params":{"exe":"C:/path/to/inputtest.exe"}}
{"method":"continue"}
EOF
Stream comes back:
{"id":1,"result":{"daemon":"rdbgd64","protocol":"1"}}
{"event":"create_process","pid":1234,"tid":5678,"image_base":"0x400000",...}
{"event":"paused","reason":"initial_loader_breakpoint",...}
{"id":2,"result":{"exit_code":0}} // launch ack-ish
{"id":3,"result":{"ok":true}} // continue ack
{"event":"debug_output","text":"inputtest started\n"}
... target running ...
Now the form is up. Drive it:
{"method":"list_windows"}
// pick form: class TFormInputtest, hwnd 0x00050aa6
// pick edit: parent matches form, class TEdit, title ""
// pick button: parent matches form, class TButton, title "Submit"
{"method":"send_keys","params":{"hwnd":"<edit>","text":""}}
// (no-op; edit was empty already, but explicit clears would go here)
{"method":"click_at","params":{"target":"Submit"}}
If the bug fires, the stream emits an exception event:
{"event":"exception","kind":"delphi","class":"EAccessViolation",
"exception_object":"0x1A2B3C40","frames":[
{"idx":0,"rip":"0x...","source":"InputtestMain.pas","line":52,
"symbol":"InputtestMain.TFormInputtest.SubmitClick","offset":17},
...]}
{"event":"paused","reason":"exception",...}
Capture state at the pause:
{"method":"inspect_object",
"params":{"address":"0x1A2B3C40","max_depth":3}}
{"method":"get_self"}
{"method":"inspect_object",
"params":{"address":"<self-from-above>","max_depth":2}}
{"method":"get_registers"}
Then either fix it (set_rip past the AV to keep the session going), or report and shutdown:
{"method":"shutdown"}
Write the findings: stack at fault, the form's FEmail field value (likely ''), and the suspected guard (if FEmail = '' then is missing in TFormInputtest.SubmitClick). Recommend the fix. Optionally cycle through R6 to confirm the bug is deterministic.
hello first. Confirms daemon liveness and the cost is negligible. bridge_config too if you need to know which daemon is connected.events after every state-changing call (continue, step_*, run_to, interrupt). The synchronous response is just the ack; the real outcome lands as one or more events. Use filter:["paused","exception","exit_process"] to ignore log-class events.idle_since_last_event_ms over wall-clock waits. A high value means "target genuinely idle" (waiting for input you should drive) or "wire is silent because something broke" — disambiguate with hello.status on reconnect. If a previous agent left a paused session, it'll show in sessions[] with owner:"none". bind_session adopts it and you skip a full launch cycle.set_breakpoint on an already-set BP returns the existing bp_id; safe to retry.valid_registers in the get_registers frame_idx>0 response before reading volatile slots — volatile GPRs (RAX/RCX/RDX/R8-R11) and XMM0-5 are never preserved by the ABI, so their values at the up-stack frame are meaningless.value_kind:"method" from "unreadable". The first means "we know what it is, can't read it without executing" (use eval_method_props:true); the second means "something went wrong reading memory."set_text_field over send_keys for text inputs — instant, no focus-loss races.lsp_open_project (or any lsp_* with a project arg) spawns DelphiLSP and autogens .delphilsp.json if needed; later calls reuse the warm child until idle reap (5 min default).Every method, every event, every field. The single source of truth for clients building their own driver.
Read →The interactive client. Useful when you want a human-driven session for comparison with the agent loop.
Read →Every method, every event, the full request/response shape. The canonical document for any client driving rdbg directly.
Read →The wire is stable in v1, additive only. Pair this guide with the rdbg-debugger plugin (for Claude Code) or write a thin MCP client around rdbg-mcp.exe for any other agent.