Skip to content

v0.3.0 - Delphi Debugger for VS Code (Win32 and Win64)

Latest

Choose a tag to compare

@csm101 csm101 released this 04 Aug 13:21

Debug Delphi Win32 and Win64 applications from VS Code, or from an AI agent over
MCP. No Delphi toolchain and no build step needed to install: the adapter, the
MCP server and the VS Code extension are all compiled and bundled.

The adapter is always a 64-bit process, whichever target it debugs — a 32-bit
application is debugged across the WOW64 boundary, so the debugger does not work
inside a 32-bit address space, which is where a large project's symbol data
would otherwise run out of room.

What's new

32-bit targets

The debugger now debugs Win32 (32-bit) Delphi applications, not only Win64.
One adapter binary handles both: it reads the target executable's PE header and
picks the right machine model before the process starts.

Working on a 32-bit target, and verified against the 64-bit behaviour rather
than assumed to match it: launch and attach, breakpoints (including breakpoints
that bind later, when the package containing them is loaded), stepping, the call
stack with Pascal names and source lines, locals and parameters, object and
record expansion, expression evaluation, calling methods and property getters in
the running program, and writing variables back.

Two things that took real measurement rather than porting:

  • Floating-point and Int64 values. On 32-bit, results come back off the x87
    stack and out of EDX:EAX, and arguments follow Delphi's 32-bit register
    convention, which gives a register slot only to a non-float that fits 32 bits.
    Single, Double, Real, Extended, Currency, TDateTime and Int64
    all pass and return correctly. Extended (10 bytes on 32-bit) and the
    pre-8087 Real48 read at their true width instead of the 8-byte value slot.
  • The call stack. 32-bit code carries no unwind data, so the stack is walked
    by the saved-EBP chain. Where that chain cannot answer on its own — stopped
    inside a prologue before the frame exists, which a constructor makes ordinary
    — the answer is now proven by decoding instruction lengths forward from a
    known boundary, not guessed from bytes that look call-shaped.

The debugger itself is always a 64-bit process, whichever target it debugs.
A 32-bit application is debugged across the WOW64 boundary, so the debugger is
not sharing a 32-bit address space with the program it is inspecting, and does
not inherit the ceiling a 32-bit debugger works under. On a large project the
symbol data is what consumes memory: indexing a 523 MB .rsm from a real
single-exe build peaks at 692 MB of working set. Comfortable at 64-bit; not
something to attempt inside a 2 GB user-mode address space.

Only one binary is shipped. The target's PE header decides the machine model
before the process starts — there is nothing to choose and no second adapter to
install.

Attaching to a 32-bit process now works from the picker too. The pre-attach
check demanded that the target match the debugger's own architecture, so every
32-bit process was listed as not attachable and refused on selection — while the
engine underneath attached to one perfectly well. If you tried this in 0.2.x and
concluded 32-bit attach was missing, it was the gate, not the debugger.

Limitation, stated plainly: 32-bit locals and parameters need a -$O-
(optimisation off) build. -$O+ omits the frame pointer routinely. This is the
usual debug configuration, but it is a real constraint if you debug an optimised
build.

Raw stack scan — seeing past code you have no debug information for

The problem. You stop somewhere deep — an exception inside the VCL, a
callback out of a third-party control suite, an access violation under the RTL —
and the call stack shows three frames and stops. Everything under it was built
without debug information, or without a frame pointer, so there is nothing to
unwind through. The one thing you actually want to know is invisible: which of
my own routines is underneath all this?

That is the ordinary situation on 32-bit, where there is no unwind data at all
and the walk ends at the first routine compiled without a frame pointer. It also
happens on 64-bit, in any application built largely out of packages you did not
compile with debug info.

The answer. Press Toggle Raw Stack Scan in the Call Stack title bar. The
debugger sweeps the thread's stack word by word for anything that could be a
return address, resolves each one against every module it knows — including
runtime packages — and appends what it finds below the real frames. Your own
routines come back with names and source lines even though nothing could unwind
to them.

Measured on a real 32-bit multi-package application, the sweep surfaced
QBFCreateForm in one package, four control-suite routines in another, and two
methods in the executable itself. None of them appeared in the call stack.

It toggles live, mid-session, on the stop you are already looking at — no
restart, no editing launch.json.

And it tells you exactly how much to trust each hit. Every appended entry is
marked twice, because either marker alone can be lost: the name is prefixed
[raw] (the instruction ending at that address was decoded and is a call) or
[raw?] (there was no line table to decode from), and the row is greyed.

Neither marker means the routine is still on the current chain. A call that
has already returned leaves its return address behind, and no sweep can tell the
difference. These are places the program has been, not callers — which is
still enough to answer "how did execution get here", and is why they are
appended below the real stack rather than mixed into it. Off by default: a stack
you did not ask to be swept never shows a position next to a real frame.

Better answers when something cannot be answered

A run of fixes with one theme: the debugger used to produce a plausible value
where it should have admitted it did not know.

  • A watch on a method that raises now shows the exception —
    <GetKey raised EDatabaseError: connection closed> — instead of
    <method invocation failed>.
  • Obj.Member stays scoped to Obj. It used to fall back to a same-named
    global, so Self.HandleAllocated and a bare HandleAllocated answered the
    same number.
  • An indexed property evaluated without an index is refused rather than
    answering with the getter's address.
  • A threadvar is refused with a reason. It used to resolve into the PE headers
    and report 0 for a variable holding $5A5A5A5A.
  • An object in the Watch panel is labelled with the class it is, not the one
    it was declared as.
  • A ^T renders as a dynamic array only when the debug info says it is one, and
    array indexing is bounds-checked instead of returning past-the-end data.
  • A bare identifier no longer resolves to an enum member declared inside an
    unrelated class.
  • Locals that share a frame address are dropped: two distinct stack locals
    cannot occupy one address, and on a large real binary 22 of them were showing
    the saved frame pointer as their value, with plausible names and types.

VS Code extension

  • Hover to evaluate. Hovering an expression in the editor evaluates it,
    including qualified chains like Form.Grid.DataSource.

  • The debugger's logging has moved out of the Debug Console. Symbol loading,
    module events, breakpoint binding and timings used to be interleaved with your
    program's own output, which made a Writeln hard to find among the chatter and
    vice versa. They now go to their own channel:

    Output panel (Ctrl+Shift+U) → the dropdown on the right → Delphi Debugger

    The Debug Console is left to the debuggee. The channel appears once a Delphi
    session has started, since VS Code lists a channel only after something creates
    it.

  • You will be told when a new release exists. This extension is installed by
    an installer rather than from a marketplace, so until now nothing announced a
    new version and people ran an old one for months without knowing. It now asks
    GitHub once a day at most and, when there is something newer, shows a single
    notification with a link to the download page.

    It downloads and installs nothing — you decide. It sends nothing about you,
    your code or your projects: the request is an anonymous read of a public
    releases endpoint. It stays silent when the check fails or the answer is not
    newer, Skip this version is remembered, and
    delphi-win64.checkForUpdates: false turns it off for good.

    This release is the first one the check can find, so the notice starts
    working from the next one.

For agents (MCP server)

36 tools now. New in this release:

  • get_loaded_modules — every image mapped in the process with its load base,
    symbol state and the debug-info formats that actually loaded, so "why is this
    frame nameless" and "is that package even loaded yet" are answerable.
  • get_source_files — the source files the loaded debug info can name, grouped
    by module. This is the exact file spelling set_breakpoint expects.
  • get_raw_stack_scan — the sweep described above.

Naming

The extension is now Delphi Debugger (Win32 and Win64); it used to say Win64
in several places that were no longer true. The debug type is still
delphi-win64 and the command ids are unchanged: renaming them would break every
existing launch.json, which is not a trade worth making for a label.

Documentation

WHAT_WORKS_WHERE.md is new: a per-configuration matrix of what is measured to
work — monolithic executable vs. package host, crossed with 32-bit and 64-bit.
Every cell is either a measurement or an explicit blank; nothing is filled in by
inference from the neighbouring column.

Its most useful conclusion is that the shape of the application predicts less
than people expect. What predicts behaviour is whether the module whose code you
are standing in carries debug information at all — and a module without it
degrades rather than fails: the stack still walks through it, and object
inspection still works, because expansion reads the running program's RTTI
rather than the debug info on disk. What you lose inside such a module is names,
source lines, breakpoints and calls to non-virtual methods.

Install

  1. Download delphi-win64-debugger-setup-v0.3.0.zip below and extract it anywhere.
  2. Run Setup.exe. It packages the extension into a .vsix and installs it
    through the VS Code CLI, updating any previous version in place, then offers
    to register the MCP debug server with Claude Code and VS Code.
  3. Reload VS Code.

Windows will warn you: these executables are not code-signed, so SmartScreen
shows "Windows protected your PC". Choose More info -> Run anyway, or build the
identical zip yourself from source with build_setup_zip.bat — a reasonable
preference for a debugger, which by nature attaches to other processes.

You will also want the Delphi IDE plugin:
it generates the workspace and launch configuration from your Delphi project,
which is otherwise a few hundred search paths to write by hand.

Requirements

  • Windows x64, VS Code 1.80 or later.

  • The program you want to debug must be compiled with full debug information,
    or most of this will not work — a debugger can only show what the compiler
    emitted. In the Delphi project options, for the Debug configuration of the
    platform you are building (Win32 or Win64):

    • Compiling -> Optimization off, Debug information on, Local symbols on
    • Linking -> Debug information on, Include remote debug symbols on
      (this is the .rsm), Map file: Detailed

    On the command line: -$O- -V -VN -VR. Keep the .map and .rsm beside the .exe.

What each artefact buys you:

Artefact Without it
TD32 section, .map, or JCL data — any one no source lines: no breakpoints, no stepping
.rsm breakpoints and stepping still work, but local variables, types and expression evaluation are severely limited
optimizations off breakpoints land on the wrong line and locals read as garbage, because the code no longer matches the source

The same applies to every runtime package you want to step into: a BPL
compiled without debug information stays a black box even when the host has full
symbols. To step into the RTL and VCL, also enable Use debug .dcus.

What is in the box

Setup.exe Installer and updater
local.delphi-win64-debug/ The VS Code extension plus the DAP adapter
DelphiDebuggerMcp.exe MCP server — 36 tools that let an agent set breakpoints, step, and read locals
register-mcp.ps1 Registers or unregisters the MCP server

SHA-256 of the zip:
681FFE1BA6D6260A9F2B3E9C4ADEF02DFBA12FF6B19D5610CE480EBC7B5A3EF6