TTD Behavior: browse the API calls a trace made - #1155
Open
xusheng6 wants to merge 24 commits into
Open
Conversation
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>
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
marked this pull request as ready for review
July 30, 2026 21:03
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 easierThe extraction is done out of process by
ttdcapa-extract(built by CI from Vector35/ttd-capa, vendored incore/ttd-extract/). It writes a report to disk thatcore/ttdbehavior.cppreads.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:
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