Skip to content

TTD Behavior: browse the API calls a trace made - #1155

Open
xusheng6 wants to merge 24 commits into
devfrom
test_ttd_behavior
Open

TTD Behavior: browse the API calls a trace made#1155
xusheng6 wants to merge 24 commits into
devfrom
test_ttd_behavior

Conversation

@xusheng6

@xusheng6 xusheng6 commented Jul 30, 2026

Copy link
Copy Markdown
Member

This PR implements #956

This adds a sidebar listing every Windows API call in a TTD trace, with parameters decoded against Microsoft's Win32 metadata -- names, types, resolved strings, symbolic flags, captured buffers, and [Out] values re-read at the call's return. Double-clicking time travels to the call and navigates to its IP. The sidebar has tabs like other TTD widgets which makes iterative exploration easier

The extraction is done out of process by ttdcapa-extract (built by CI from Vector35/ttd-capa, vendored in core/ttd-extract/). It writes a report to disk that core/ttdbehavior.cpp reads.

A query language is created to allow the user to effectively query and find the calls they are interested in.

Example of Python API usage:

from debugger import TTDBehaviorReport
with TTDBehaviorReport("trace.ttdb") as report:
    for call in report.query("module:kernel32 api:WriteFile ret:!0"):
        print(call.position, call.param_summary)
image

Scope of the review: code changes in this repo. Code in Vector35/ttd-capa are out of scope

Important design or architectural decisions: The load and filtering are all implemented in the debugger core, the UI and Python AI consumes it

Known limitations: Sometimes the memory bytes cannot be retrieved even if it is recorded and would be retrievable if the user time travels to it. This is because the way the extraction is done. See more details in the docs: https://github.com/Vector35/ttd-capa/blob/master/docs/ttdcapa-extract.md#notes-on-what-the-data-can-and-cannot-tell-you

Testing and validation: tested the UI and Python API manually. Automated CI tests are not setup yet (#918)

Performance considerations, when relevant: Measured on a 3.4M-call trace: opening a report 0.4ms, a scoped query 8ms, a text query 70ms. TTD trace size 476 MB, ttdb file size 610MB. Extraction took 30 seconds. The extraction is done in the background and it does not block the UI. The query should generally be fast. For very large traces we will like need Vector35/ttd-capa#2 in the future.

The original repo exports the data to a JSON file. Upon testing, I noticed a full extraction and load took 2 minutes. Among them, only 30 seconds are spent on the actual extraction. The 1.5 mins are spent on writing and parsing JSON. To address this, the extraction now uses a binary format for the record, which is faster and smaller than the JSON file. The write took seconds, and the load took <1s.

The UI by default only pulls the minimum info per line when it is displayed in the table (e.g., module, function name, address, pre-rendered text of the call). It is only when the specific line is clicked, it pulls in the detailed parameter and all extra info and show that in the details panel. This is in order to maximize the UI responsiveness

How the change will be exposed or delivered to customers: They can both used it in the UI and with Python API. And they are also very likely to just give the extracted API calls to AI. To assist this process, the format of the ttdb binary file is documented: https://github.com/Vector35/ttd-capa/blob/master/docs/ttdcapa-extract.md#the-ttdb-format

Expected follow-up work: 1) More UI polishing and enhancement, 2) automated (non-AI) analysis on top of the trace (e.g., aggregate and report the file/network/registry/thread/module/memory allocation behavior

xusheng6 and others added 22 commits July 24, 2026 17:08
Extraction is done out-of-process by a tool built on the TTD replay SDK,
which sweeps the trace and decodes each call's arguments against Microsoft's
Win32 API metadata: real arity, resolved strings, symbolic flag names,
captured buffers, and [Out] parameters re-read at the call's return position.
The sidebar loads that JSON report, or runs the extractor itself over the
trace the current session is replaying.

Double-clicking a call navigates the TTD session to its position.

Part of #956.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SetTTDPosition drives the backend's !tt directly without posting a stop
event, so nothing moved the code view. Read the IP back from the adapter
and navigate there, defining a function first when analysis has none --
a call's position is the callee's entry, typically inside a system DLL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Filtering a 3.4M-call report froze the UI. The substring search was never
the problem -- measured on real data it is 60-190ms for a full pass over
every call. QSortFilterProxyModel was: it maintains a bidirectional
source/proxy row mapping, and updateStatus() called its rowCount(), which
forces that entire mapping to be materialised. Every keystroke rebuilt it
from scratch over 3.4M rows.

Drop the proxy. The model now owns the filter and keeps a vector of
visible source indices, rebuilt in one linear pass.

Also:
- Debounce input by 150ms so a burst of keystrokes is one pass rather than
  one per character. Enter applies immediately. A single pass is fast
  enough to stay synchronous, so no worker thread or progress bar.
- The per-call filter haystack becomes a lowercased std::string instead of
  a QString: halves its footprint (~200MB less on this report) and
  searching 8-bit data beats a case-insensitive QString compare.
- Count decoded calls once at load instead of walking all 3.4M on every
  status update.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…as such

The report is the expensive artifact -- minutes of extraction -- so it no
longer goes to a temp directory the OS will clean up. It lands beside the
trace as <trace>.ttd.json. If that directory is not writable the user is
asked where to put it rather than silently falling back to temp, and an
existing report offers to be loaded instead of regenerated, since
re-extraction is deterministic and would otherwise replace it.

A capped buffer parameter also rendered as though its captured size were
its real size: a 4096-byte WriteFile read as a 256-byte buffer. It now
reads "first 256 of 4096 bytes" when the extractor reports the buffer was
cut short, and the limit is exposed as debugger.ttdBehaviorMaxBuffer so it
can be raised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Buffer parameters are a small fraction of the calls in a trace, so the
capture limit costs far less report size than it appears; 256 bytes was
cutting off ReadFile/WriteFile/RegGetValueW buffers for no real saving.

A 64 KiB buffer is 4096 lines of hex dump though, which the detail pane
can neither show usefully nor lay out quickly, so the rendering is clipped
at 4 KiB with a note. The data is not clipped -- the full buffer stays in
the report.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… to fit

Three things, all visible on a multi-million-call report.

Columns: resizeColumnsToContents() only samples the leading rows, so "#"
was sized for three digits when the last index has seven, and Position for
its short early values when later ones are wider. Both are now sized from
the widest value actually in the report.

Blocking: extraction ran asynchronously already, but parsing the resulting
650MB report did not -- that was the freeze. Parsing now happens on a
worker thread and the finished report is installed on the UI thread.

Progress: a bar, elapsed time and a linear ETA sit at the bottom of the
widget while an extraction or load is in flight, driven by the extractor's
new --progress output. Writing the report has no measurable progress, so
the bar goes indeterminate for that phase rather than pretending. Cancel
asks the extractor to stop via --cancel-on-stdin, which keeps every call
recorded up to that point -- killing the process would discard them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Extraction now asks for -b, so it writes the mappable format next to the
trace as <trace>.ttdb instead of JSON. That was the missing half: the
extractor could already write it, but the sidebar could not read it, so it
kept requesting JSON.

The report grew a second backend. The binary one memory-maps the file and
decodes a call record straight out of the mapping when a row is asked for,
so opening a 3.4M-call report is a header validation rather than ~17s of
parsing and ~1.4GB of per-call objects. Parameters are decoded only for the
selected row, and the filter matches against the prebuilt haystack in the
mapping via string_view -- no copy, no allocation. A one-row cache keeps
data() from decoding the same row once per column.

JSON loading stays, dispatched on the file's magic, because reports already
on disk are in that format and it is what capa consumes.

The status line now reports how long the save and the load took, which is
how the difference is visible at all: 3.6s to write and a memory map to
load, against ~56s and ~17s before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reads it from either backend and adds a Return Address column, plus the
call site in the detail pane's header line alongside the return value.

Tracks the binary format's bump to version 2, whose call record grew from
40 to 48 bytes; reports written before this need re-extracting, which the
existing version check already tells the user.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Searching WriteFile matched kernel32's and ntdll's alike, and there was no
way to express a condition on the return address at all. The filter now
takes whitespace-separated terms ANDed together: a bare word still matches
anywhere, and field:value narrows.

  module:kernel32 api:WriteFile     only kernel32's
  api:Reg* ret:!0                   registry calls that failed
  retaddr:0x400000-0x500000         calls the sample made itself

Chosen over a conditions grid or a full expression language because the
format makes it the fast option, not merely the cheap one. module and api
are deduplicated string-table offsets and tid/ret/retaddr are fixed record
fields, so a scoped term resolves once against the few thousand distinct
strings and then costs an integer compare per row -- less work than the
substring scan it replaces. It also stays a single input, and a bare word
keeps its old meaning, so nothing anyone already types breaks.

Numeric fields take !=, >, >=, <, <=, and a-b ranges, decimal or 0x hex.
Parsing never fails: an unrecognised prefix is treated as text, so a
half-typed query narrows rather than erroring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Matches the other TTD widgets: a tab bar with a + corner button and
closable tabs, one query per tab.

Split along the line that matters for cost. Loading and extracting act on
the report as a whole, so they stay above the tabs together with the
progress row, and every tab points at the same shared report -- opening a
second query does not re-read anything. Each tab owns only what is actually
per-query: its filter, its model's visible-row set, its selection and its
detail pane.

Two details that make several tabs worth having. A new tab is seeded with
the current tab's filter, so refining a query is opening a tab and editing
rather than retyping. And a tab is labelled with the query it is running
rather than 'Query 3', which is what tells them apart once there are a few.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reader and query engine were UI-only, so nothing outside the sidebar
could ask a trace anything. They now live in core/ttdbehavior.{h,cpp},
de-Qt'd and binary-only: the JSON path is gone, since it cost 16.6s to load
where a memory map costs 0.4ms, and it only ever existed for capa, which
talks to the extractor directly.

The FFI boundary sits where the measurements said it had to. No loop over
the call list crosses it: BNTTDBehaviorRunQuery filters all 3.4M calls
core-side and returns matching row indices in one crossing, and callers
then fetch only the rows they display. Crossing per row instead measured
30-50% worse, two thirds of that being marshalling rather than the call.

Measured through the built debuggercore.dll on a 3.4M-call report:

  open (mmap + header validation)          0.38 ms
  query "writefile" (text scan)            69.3 ms
  query "module:kernel32 api:WriteFile"     8.1 ms
  query "ret:!0" (1.66M rows returned)     19.6 ms
  40 visible rows per repaint              19.2 us
  one call with full parameters             1.9 us

The text scan matches what the UI-resident version measured, so the move
costs nothing observable. Structured queries are 8x faster than a text
scan, because module and api resolve to string-table offsets once and then
cost an integer compare per row -- moving to core made querying faster.

Python bindings generate from ffi.h automatically; api/python/ttdbehavior.py
is the wrapper over them, where report.query(...) yields decoded calls and
parameters are decoded lazily.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The widget carried its own Qt implementation of the reader and query
engine alongside core's, which is two of everything to keep in step. It
now goes through BinaryNinjaDebuggerAPI::TTDBehaviorReport, so the sidebar
and Python run the same code.

The model gets simpler for it: filtering was a hand-rolled scan, and is now
one RunQuery call that returns the matching rows. Rows are still decoded
one at a time into a single-row cache, since nothing is held as objects.

Also exports the report types from the debugger Python package, which is
what was missing to make them reachable from the console, and adds the C++
API wrapper the UI needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
query() defaulted to summary-only while get_call() defaulted to the full
parameter list, so the same call gave you different objects depending on
how you reached it -- and the useful case, wanting to read a parameter, was
the one that needed the extra argument.

Both default to decoding now. with_params=False remains for sweeps over
millions of rows, where it is about 4x cheaper per call and holds far less
memory; that is the case worth opting into rather than the default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The extractor gained --recover-strings but the sidebar never passed it, so
extracting from the UI still produced reports with a quarter of the string
parameters missing -- the flag might as well not have existed for anyone
who does not run the extractor by hand.

On by default, since a report whose strings are absent is the wrong default
whatever it costs, and exposed as debugger.ttdBehaviorRecoverStrings for
when speed matters more. The recovery pass is slow enough to look like a
hang, so the progress row now names it rather than sitting silent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Recovering the strings the sweep could not read quadruples extraction time
on a large trace -- 33s to 132s -- to recover about three quarters of them.
That is a deliberate choice, not something to impose on every extraction, so
debugger.ttdBehaviorRecoverStrings now defaults to off and its description
explains what is lost either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The extractor no longer needs TTDReplay.dll beside it; pass the folder the
WinDbg install already provides, found the same way ttdrecord.cpp finds the
recorder. When it cannot be found the flag is omitted and the extractor
falls back to looking beside itself.

Also drops the debugger.ttdBehaviorRecoverStrings setting, whose flag is no
longer part of the extractor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Extraction previously required building ttdcapa-extract yourself and
pointing a setting at it, which is a wall in front of the first thing
anyone wants to do with the sidebar.

The CI-built extractor and its API metadata index now live in
core/ttdbehavior/, staged to plugins/ttdbehavior/ beside debuggercore the
same way the DbgEng DLLs already are. The sidebar uses it unless
debugger.ttdBehaviorExtractorPath names another one, so a configured path
still wins for anyone running their own build -- and if that path is set
but wrong we say so rather than quietly falling back, which would report
results from a different binary than they expect.

Microsoft's replay DLLs are deliberately not vendored; the extractor takes
their location and we pass the WinDbg install the debugger already manages.

Provenance and update instructions are in core/ttdbehavior/README.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three files are a unit -- the executable reads the index from beside
itself -- so nesting them under an architecture and putting a separate
README outside that folder had it backwards. They now sit flat in
core/ttd-extract/, staged to plugins/ttd-extract/, with the extractor's own
reference doc as the folder's README.

Provenance is stamped onto that README by CI rather than maintained by hand
here, so it cannot drift from the binary it describes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moving the parameter rendering into core dropped the buffer case, so a
captured buffer showed only its pointer -- which tells you nothing, where
the head of the bytes tells you at a glance what a WriteFile wrote.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moving report rendering into the core left formatParamValue and its
helpers behind with no callers. They looked live enough to reason from,
which made the recently fixed buffer-preview regression read as two
renderers drifting apart rather than one branch being dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Extraction replays the trace through Microsoft's TTD engine, so it only
works on Windows. Reading a report is a memory-mapped file with no
Windows dependency, so the sidebar, the API and the Python bindings work
everywhere -- a report extracted on Windows opens on Linux and macOS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@xusheng6 xusheng6 changed the title TTD Behavior sidebar: browse the API calls a trace made TTD Behavior: browse the API calls a trace made Jul 30, 2026
extract() runs the same sweep the sidebar does, resolving the extractor
and WinDbg's replay DLLs the same way, and returns the opened report. A
progress callback follows the sweep and can cancel it, which keeps the
calls recorded so far.

The two TTD behavior settings move from the UI to the core, since a
headless script has to be able to configure them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@xusheng6
xusheng6 marked this pull request as ready for review July 30, 2026 21:03
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@xusheng6
xusheng6 requested a review from plafosse July 30, 2026 21:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant