How the code fits together¶
This page is for contributors. It describes how one definition serves the REPL, the Python SDK, the command reference and the other hosts (DAP, gdbserver, MCP), and what to write to add a command or an SDK method.
Layers¶
The crate is six layers. src/lib.rs declares them bottom to top, and each is a folder with a layer file (src/nt.rs declares the modules in src/nt/), except that the base layer has a second folder, src/arch/: the base reads page tables and decodes instructions through it, so the two use each other. A module may use its own layer and the layers before it, never a later one:
Layer |
Modules |
What it is |
|---|---|---|
|
|
Byte and text helpers, errors, notices and output, the core types, memory access, the disassembler |
|
|
What ntoseye knows about each CPU architecture, behind the |
|
|
The debug transports and memory sources, and the debugger-data format they speak |
|
|
The Windows model over a backend, with its dump formats, breakpoints and expressions |
|
|
A debugging session: run control, stops, inspection |
|
|
Reports built over a session ( |
|
|
The front ends |
The crate root keeps Backend, TargetSpec and the default endpoints, which Session::open takes; they count as the session layer.
An architecture’s facts are all in src/arch/<cpu>/: what can be known from bytes and reads of memory alone. Logic that needs a target or a protocol stays in its layer and asks for the architecture’s part through a trait with one implementation per architecture, picked by the Arch: the stack walk’s Unwinder (nt::unwind), which finds a frame’s function and runs the format’s unwind against the walked stack, and KD’s KdMachine (backend::kd), which knows the register that holds the kernel’s page-table root, what a continue must carry, and the registers KD reports outside the CONTEXT. Tables of one format against every architecture (gdbserver’s register numbers, KTRAP_FRAME layouts, dump streams) stay with their format. Adding an architecture means a folder in src/arch/ and an implementation of each trait.
Modules of one layer may use each other. The Windows model is one web: the target, the guest, layouts, symbols and stacks read each other. So are the hosts, as their features allow: the SDK runs commands through the REPL’s dispatcher, and the CLI starts MCP, DAP and gdbserver. One reference goes up a layer on purpose: the backend’s PhysMem (backend::phys) is the enum of every memory the model reads, so it names the dump readers (nt::dmp, nt::userdump) and a partition’s EPT (nt::guest).
Some modules sit where their dependencies put them:
The dump formats are part of
nt: their parsers build model records (aModuleInfowith its CodeView, named memory regions), and the model reads their streams back. Gathering a live session’s dump header is the session’s (session::dump).breakpoints,bugchecksandexprare part ofnt: the target owns the breakpoint site journal, stack walks use the breakpoints’ kernel-space test, the model uses the bugcheck module’s pointer and module-name helpers, and ETW resolves logger names as expressions. None of them uses anything abovent.typeviewreads guest values through aSession, so it is insession, andanalysisis abovesessionbecause the triage reports are built from one.
The core names no host. Where the session needs one, the host installs it: every host opens its sessions through repl::open_session, which installs the REPL dispatcher as the session’s ActionRunner. A host’s view of a model type lives in the host: FromArg impls in src/hosts/repl/args.rs, PyArg impls in src/hosts/python/args.rs, and SDK methods on a record’s class in a #[pymethods] block under src/hosts/python/ (Stop.hit() is one). The #[record] macro, which the model’s types carry, names crate::hosts::python only in the code it emits under cfg(feature = "python").
build.rs enforces the layers on every build, whatever the features. It walks the module tree from src/lib.rs, reads every crate::, $crate::, self:: and super:: path in use trees, code, attributes and macro bodies, and fails the build on one that names a later layer, unless its module and target are in ALLOWED (the three PhysMem references). Test code (#[cfg(test)]) is not checked, since a test of a lower module may build its fixture from a higher one. A violation fails the build at its line:
a module uses a later layer (layers, bottom to top: base, backend, nt, session, analysis, hosts; see build.rs and docs/internals/architecture.md):
src/base/bytes.rs:11: crate::base::bytes uses crate::nt::target::Target::generation, but nt comes after base
Records: one type per concept¶
Each Windows-model concept has one Rust type, and that type is also its SDK class. A model struct or enum carries #[record] (the ntoseye-macros crate, macros/src/record.rs):
/// A job object (`!job`).
#[record(impl)]
pub struct Job {
#[record(hex)]
pub address: VirtAddr,
pub job_id: Option<u64>,
#[record(skip)]
pub raw: Vec<JobField>,
}
#[record]
impl Job {
/// The `JOB_OBJECT_LIMIT_*` names of the limit flags that are set.
#[record(getter)]
pub fn limit_flag_names(&self) -> Vec<String> { ... }
}
The struct stays as written. The REPL renders it and the rest of the crate uses it.
With the
pythonfeature, the macro also emits a frozen classJobPy(Python nameJob) that extendsBaseRecord, with one typed property for each field. The doc comments become docstrings, with each intra-doc link as its text (macros/src/docstring.rs):[`crate::nt::target::VP_STATE_REGISTERS`]is`VP_STATE_REGISTERS`and[text](path)istext, so a docstring names no Rust module and a move does not change the stub. A link to anhttps://URL stays a link. The docstrings of#[command]methods, of#[collection]methods and of a handle’s record properties are made the same way; a hand-written#[pymethods]docstring is its doc comment as written.Field options:
hex(reprshows the integer in hex),bytes(aVec<u8>is Pythonbytes),skip(Rust and REPL only), andrename = "key". A field’s Rust type gives its stub type, throughOption,Vec,Box,DiagnosticValue(Diagnostic[T]),DiagnosticMetricandKeyed.Values that are computed from the fields are
#[record(getter)]methods in a#[record] implblock, and the struct then says#[record(impl)].#[record(python)]methods in that block become ordinary Python methods.A fieldless enum is a string: each variant’s text is its
rename, or else its snake_case name, and the stub types the value as theLiteralof every text. An SDK argument of the enum’s type takes the same texts.#[record(label = "...")]on a variant gives the REPL a different text (Display) from the SDK’s, so the SDK sayspdewhere the REPL printsPDE.An enum whose variants each hold one value is the union of those values’ types.
#[record(tagged)]on an enum with struct variants makes a base class withkind, typed as theLiteralof the variant kinds, and a final subclass per variant, nested in it (ListTermination.Cycle). A variant’s class has its ownkindand the variant’s fields as properties that are neverNonefor being in another variant, soisinstance()andmatchnarrow. Withbase = S, the subclasses extend structS’s class instead, andSholds the enum in a#[record(flatten)]field: that is howStophas its shared context andStop.Breakpointitsbreakpoint.Context that depends on the session, not on the guest structure, stays outside the record. For example, the vCPU that runs a thread is a field of
ThreadDetail, not ofThreadInfo.
In Python, a record is a value, like a frozen dataclass. BaseRecord gives it == (same class, equal fields), hash (a TypeError when a field is unhashable), repr, and to_dict(), and each class has __match_args__, its properties in order. It is not a container: it has no len, iteration or record["field"], so a script reads fields as attributes. A Keyed field is a plain dict[str, T].
The runtime that turns a record into Python objects is src/hosts/python/record.rs (ToValue, Typed, to_py). build.rs walks the module tree from src/lib.rs and exports every record class from the _ntoseye module, so a new record needs no list entry. For this reason, a record must be an item in a module that crate::hosts::python can see, written with #[record] itself, not the output of a macro_rules! or a cfg_attr. build.rs writes an ExportedClass impl for each record and handle class it exports, which their macros require, so one it cannot see fails to compile at its name. Everything Python-specific is behind cfg(feature = "python"), so the core builds with --no-default-features.
Commands: one function per operation¶
Each debugger operation is a function with #[command] (macros/src/command.rs; the full reference is the doc comment on ntoseye_macros::command). The function is the REPL command, its entry in help and in the command reference, and its SDK method:
/// Show a job object and the processes in it.
///
/// The address can be a job, or a process or thread, for which the command
/// shows its job.
#[command(names = ["!job", "job"], category = "processes and modules",
kind = Query, target = Kernel, python = Inspect)]
fn job(session: &Session, address: Option<VirtAddr>) -> Result<Job> { ... }
impl Render for Job {
fn render(&self, repl: &mut ReplState<'_>, (): ()) { ... }
}
The first paragraph of the doc comment is the summary, and the rest is the details. Both appear in
help, in the command reference and in the Python docstring.kindsays what the command does:Queryreads state and returns a record.Actionchanges guest, debugger or symbol state and returns a record of the change, or().Runresumes or steps the target. It returns theMotionto drive, and the host drives it to aStop.Hostacts on the REPL itself (aliases, scripts, logging), and has no SDK form.
Arguments after the receiver (
&Session,&mut Session, or&mut ReplStatefor a REPL-only command) are parsed in order byFromArg(src/hosts/repl/args.rs).#[arg(...)]options cover defaults, the current process or thread, switches, the rest of the line, custom parsers (parse = f), and display flags (render), which go toRenderand never to the function or to Python.Renderprints the REPL text throughoutln!. The hosts capture that output. The renderer also gets theReplState, for things that only the REPL keeps, such as completion caches and the selected thread.The macro registers a
CommandSpecin theCOMMANDSslice (src/hosts/repl/command.rs).help, completion,--dump-command-reference(and the generated command reference in these docs), MCP and the REPL dispatcher all read that one registry.
Usage lines and completion¶
Nobody writes a command’s usage line or its completion by hand. The macro generates both from the arguments (src/hosts/repl/usage.rs), so they cannot drift from what the parser accepts, and it refuses a usage = or completion = key on #[command].
The notation follows WinDbg’s documentation and POSIX:
Notation |
Meaning |
|---|---|
|
a required word |
|
an optional word |
|
one or more, any number |
|
a switch or option, as typed |
|
an option with a value |
|
alternatives within one argument: |
|
nested optional groups; optional words that the line gives in turn nest ( |
|
alternative forms of the whole line |
Names are kebab-case: the parameter’s name, or #[arg(name = "...")]. Each form of the line is a usage line of its own, which starts with the command name: help !alpc shows !alpc /p <port>, !alpc /m <message> and !alpc /lpp [process] on three lines, and the command reference’s usage holds them separated by newlines.
Each argument contributes a fragment and the completion of its words:
A typed argument is
<name>, or[name]when it may be omitted (Option<T>,default,current), and completes as its type’sFromArg::COMPLETION: an expression (VirtAddr, the integers) asExpression, aThreadInfoasThread, aBreakpointIdasBreakpoint, and so on.#[arg(complete = Driver)]covers a word that its type does not describe, such as aStringthat names a driver.#[arg(switch = "/x")]is[/x], andrestis[name]....A custom parser (
parse = f,each = f) declares its words next to its code with#[grammar(usage = "<address> [L<count>|length|end]", complete = [Expression, Expression])]. In the fragment,{}stands for the argument’s name, so a parser that several commands share can name each use.usage = ""declares a parser that reads no word of its own, and a barecomplete = Strategycompletes every word, for a parser that takes the rest of the line.A parser that only hands the words to the operation, which reads them itself, is
#[grammar(operation)]. Each argument that uses it describes the words with#[arg(usage = "...", complete = ...)], as may araworrestargument, whose operation reads its text.
The usage line is the command name and the fragments in parameter order. The completion of the nth word is the nth of the arguments’ words in order; after an argument that takes the rest of the line, every word completes as it does. A test in src/hosts/repl/command.rs checks that every argument which names a value has a completion position.
Forms: several SDK methods behind one REPL name¶
When one REPL command is several operations (!alpc decodes a port, a message, or a process’s ports), each operation is a form: a function with its own return type and docstring, marked #[command(form_of = alpc, python = Inspect)]. The command lists its forms (forms = [alpc_port, alpc_message, ...]) instead of python, parses its line as before, and calls the form that the line selects. A form has no help entry of its own, and its SDK method admits the command’s effects. A form can take arguments that the REPL grammar has no word for, such as a Process handle or a keyword-only option, and a form may exist only in the SDK, when the REPL reaches its operation another way (Debugger.hypercalls(), a form of !hvcalls). The command’s own arguments describe the line, so its usage and completion come from them, not from its forms.
Effects and Session::admit¶
A command declares what it needs and what it changes, instead of checking for itself:
target = Any | Kernel | Machine | Nt(defaultAny):KernelandMachineare refused on a user-mode minidump;KernelandNtread NT’s structures, which a VTL1 address space does not have, whileMachinereads processors, MSRs, page tables, the hypervisor or the VTLs;state = Any | Halted | HaltedOrParkedThread | Running(defaultAny;Runningisbreak’s, refused on a halted target);run = None | Step | Run(defaultNone): whether the command moves the target;writes = [Memory, Registers, Hardware, Breakpoints, Symbols, Files]; writing guest state (Memory,Registers,Hardware) implieslive_only;live_only(refused on a dump);capability = <DebugCapability>.
A form of a command can declare its own effects in place of the command’s (!gflag’s set form declares writes = [Memory]).
Session::admit(&Effects) (src/session/effects.rs) makes the decision for every host and returns a Refusal. It checks in a fixed order, so every host refuses one operation for one reason: the target kind, the VTL1 address space, the run state, execution control (a run on a target that cannot run, such as a crash dump), the static dump (live_only and guest writes), then the capability. The REPL reports a refusal with the message that it has always printed (report_refusal). The SDK raises the matching exception (python::command::admit), for example TargetRunningError. Hand-written SDK methods go through the same function.
Before admit, the REPL’s dispatcher (ReplState::admit, src/hosts/repl/commands/mod.rs) applies gates that depend on where the line came from, which no SDK call has, in this order:
host_command_denial:.shellruns host programs only at the interactive prompt;run_control_denial: a breakpoint action or an exception command may not move the target (by itsruneffect, orstate = Runningfor an exception command), a GDB client may not have it moved behind its back, a DAP client may not block on arun = Run, and a remote host may not runq(flow = Quit);gate_remote_command: for MCP and the SDK’scommand(), a command that needs the target halted, or moves it, waits for a stop while the target runs, or is not run when nothing is set to stop the target; MCP also refuses one resume past a stop that its client has not seen.
VTL1 is part of the same decision. In a VTL1 address space (the .vtl 1 view, or the root of a live VTL1 stop), admit refuses what reads NT (Kernel, Nt) or writes guest state (Memory, Registers, Hardware). The .vtl 1 view is memory alone, so it also refuses anything that needs the target halted, except breakpoint bookkeeping. Both hosts word the refusal with Vtl1Refusal::message.
Running the target: the run driver and Stop¶
Session::run(Motion, &Cancel) -> Result<RunOutcome> (src/session/driver.rs) is how every host resumes, steps or waits; the two resumes that bypass it are below. A Motion is Continue, Wait, Break, Step, StepOver, StepOut, RunTo or Walk. A Cancel carries the cross-thread flag, a deadline, a behavior for when it fires, and a tick hook that the host calls between polls. The driver polls the backend, skips stops that do not count, applies the exception policy, surfaces parked stops, and runs breakpoint actions and filter commands through the session’s ActionRunner: the REPL dispatcher, which every host installs by opening its sessions through repl::open_session. Every breakpoint stop runs its action, a wt that ends at one included. An action that resumes (gc) passes the hit as a declined one, unless a halt was asked for (Motion::Break, a cancellation that halts, Ctrl+C): halt wins, and the target stays at the hit.
The result is a RunOutcome: Stopped(Stop), or Running when the Cancel fired with CancelBehavior::ReturnRunning and left the target running. A cancellation that halts (CancelBehavior::Halt) ends on a Stop, of kind Interrupt unless a real stop raced it, never Running. In the SDK, a method whose command is admitted on a running target (state = Any, such as run() and wait()) returns Stop | None, None for Running (its timeout ran out); the others return a Stop. A Stop is a record: the context at the stop (vCPU, RIP, symbol, process, thread, inspection process, VTL, partition) and a StopKind, a tagged enum flattened into it, whose variants carry their own parts (Breakpoint { breakpoint }, Exception { exception }, ModuleLoad { module }, …). In Python, each variant is a subclass, Stop.Breakpoint with a breakpoint that is never None. Each host only maps it. The REPL prints the stop card (ReplState::run_motion), the SDK returns the Stop itself (python::runcontrol), DAP sends a stopped event, gdbserver a stop reply, and MCP goes through the REPL.
Two resumes call Session::resume instead of the driver, because neither is a run that ends on a stop. Session::with_target_halted breaks in to edit breakpoints on a running target and resumes its own break-in at once (a real stop that the break-in exposed is parked for the driver instead). gdbserver’s detach (GdbTarget::release) leaves the guest running for good: no client is left to report a stop to, and it must resume even with a stop parked, which Session::run would return instead of resuming. When one of the client’s breakpoints cannot be removed, it leaves the guest halted instead, as Session::cleanup_for_exit does.
The Python SDK¶
A generated method runs through python::command::run. That function takes the object’s context, admits the command’s effects on the session thread, calls the function, and returns the result as its record class (Typed<R>). The class that a method is placed on implements CommandHost (src/hosts/python/command.rs):
session()says how the object reaches its session: through itsOwnerfor a handle or a namespace, or directly for theDebugger.context()gives the process or thread that its calls run in.refuse()andprepare()are optional. They refuse an operation that the object cannot run, and set up the session after admission (for example, aHeaploads its process’s symbols).A
#[arg(receiver)]argument comes from the object throughBind<T>. For example, aThreadbinds theThreadInfothat!threadtakes.
A Python argument type implements PyArg, whose Py type is what the stub shows. A fieldless #[record] enum is one already, taking its variants’ texts (Literal[...] in the stub), and a handle’s record is one taking the handle (ProcessInfo takes a Process). A VirtAddr argument is an AddressArg (src/hosts/python/args.rs): an int, a debugger expression, which is evaluated on the session thread in the method’s context, or a handle, which gives its object’s address (a Process its _EPROCESS, a Module its base, …; also Thread, Driver, Device and Heap) and must belong to the same debugger. Hand-written methods take AddressArg too, through AddressArg::resolve or AddressArg::pending. Run commands get a timeout and the when= handling of python::runcontrol.
The arguments a method is about (an address, a name, an object) are positional. An argument that may be omitted (Option<T>, default, switch, sdk) is a mode, flag or option, so the generated method makes it keyword-only, after the *; #[arg(positional)] keeps an omittable subject positional (job(address=None), pool_usage(tag=None, *, sort=...)).
Handles and collections¶
A live handle (Process, Thread, Module, Breakpoint) is a struct with #[handle(record = R, key = field, ...)] (macros/src/handle.rs). It holds its Owner and its record R (or, with fetch = f, reads the record now, as a Cpu or a Breakpoint does). The Owner records the target generation (Target::generation, the count of guest rebuilds: a reboot, a crash, a reload) that the handle was made in. A process, thread or module handle goes stale only when that generation changes, not when its process, thread or module exits; a later call then raises, and == and hash include the generation, so an old handle never equals a new one for the same object. The macro makes the frozen class, a typed property for each of R’s properties (with its doc comment), info (the record), to_dict(), == and hash by the key (or by identity = f), and the CommandHost (its context, refuse and prepare given as expressions), Bind<R> and PyArg for R. A #[handle] impl block adds hand-written methods; a property it defines replaces the record’s.
A collection (dbg.processes) is an impl block with #[collection] (macros/src/collection.rs). It writes fn enumerate, which lists the items, and fn lookup for a key; the macro makes __len__, a typed __iter__, __getitem__, get and __contains__, and find/at from matcher functions marked #[collection(find)] or #[collection(at)]. #[collection(records)] also takes an item’s record as a key (dbg.threads[stop.thread]), and #[collection(sequence)] indexes by position.
Every SDK class reaches the module without a list: build.rs finds each #[record], #[handle] and #[pyclass] and writes their #[pymodule_export]s. A tagged variant class reaches it through its base, which nests it.
build.rs also fails the build on a name clash, which PyO3 would resolve silently by keeping one of the two: two SDK classes with one Python name, or two members of one class with one Python name. It collects a class’s members from every source: a record’s properties and #[record(getter)]s, a handle’s generated members (info, to_dict, its record’s properties), a #[collection]’s protocol, #[command(python = ...)] methods and forms, and hand-written #[pymethods]. A #[handle] impl item with the name of a generated property replaces it on purpose, and is not a clash.
Some SDK methods are written by hand. These are properties and setters (Symbols.path), mapping objects (Cpu.msr), cursor reads (Debugger.debug_log), and operations that no command performs. They return model records through Typed as well. A method that performs a command’s operation but returns a live handle or takes a Python callback, such as breakpoints.add() (bp, bu, ba) with its when=, is a thin wrapper: it calls the session function that the command calls (Session::set_code_breakpoint, set_symbolic_breakpoint, set_hardware_breakpoint), which holds the operation’s checks, so the two cannot drift.
Adding a command¶
Make the result a model record: add
#[record]to the model type (or write a new one next to the model), and use#[record(skip)]for fields that only the REPL needs.Write the function with
#[command]next to the other commands of its area insrc/hosts/repl/commands/. Put the help text in the doc comment, and declarekind,category, the effects, andpython = Objectorpython = none.Implement
Renderfor the result, reading the record.For a new kind of argument, implement
FromArg(andCurrentif it has a current value) insrc/hosts/repl/args.rs, andPyArgif Python takes it insrc/hosts/python/args.rs, so the model’s module names no host; or write aparse = fparser, with its#[grammar], for a grammar of the command’s own.Check
help <name>, the REPL text, andntoseye --dump-command-reference. A change in the reference is a change in the docs.
Adding an SDK method¶
If a REPL command performs the operation, generate the method: use
python = Objecton the command, or add a form (form_of) when the command is several operations or the method needs arguments that the line has no word for. Usepython_nameto choose the Python name.On a class that has no commands yet, implement
CommandHost, andBind<T>for each argument that the object supplies. A#[handle]has both. A generated method’s Python name must not be one of the class’s members;build.rsstops the build if it is.Write a method by hand only for the cases listed above. Return records, and route halt and kernel checks through
python::command::admit.Regenerate the stub and check it, as
python/README.mddescribes:maturin develop --generate-stubs, thenmypy --strict -p ntoseyeand the target-free tests inpython/tests. A script-visible change is an SDK change, and belongs in the changelog.