The binary lands on your desk as a raw firmware dump pulled from SPI flash. No symbols, no RTTI, no export table — the build pipeline stripped everything, and the linker discarded the section headers. Ghidra opens it, auto-analyzes for six minutes, produces a listing that looks almost helpful: 3,200 functions identified, a few hundred cross-references resolved, some strings recognized. Then you hit the first call qword ptr [rax+0x18] and the decompiler throws up its hands. The listing shows a function pointer dereference through a register loaded three instructions ago from an address Ghidra hasn’t tagged as a vtable. You follow the register backward, hit another indirect load, follow that, and four jumps later you’re staring at a function you’ve already visited with no memory of why you came here. The disassembler didn’t fail. You lost the thread.

This is the real bottleneck in binary reverse engineering of stripped C++ code: not the disassembly, not the decompilation, but the analyst’s ability to maintain narrative continuity across a chain of resolved indirect calls. Ghidra and Binary Ninja give you fragments — individual functions, basic blocks, data references. Your job is stitching those fragments into a coherent execution story. The story has scenes (functions), beats (basic blocks), and plot holes (indirect branches the decompiler can’t resolve). Without a disciplined checkpoint system, you will lose the thread after the third or fourth jump-through-register, and you’ll spend the next two hours re-deriving a call chain you already partially reconstructed.

The vtable Reconstruction Problem

C++ virtual dispatch in a stripped binary without RTTI is the canonical hard case because the dispatch mechanism is entirely implicit. At the source level, obj->method() compiles to a load of the object’s vtable pointer from [obj], then a load of the method pointer from [vtable + offset], then an indirect call. The compiler emits no metadata connecting the vtable to the class name, the method to its signature, or the call site to its possible targets. RTTI would give you class names and hierarchy information, but production firmware builds strip it to save space and reduce information leakage — a legitimate engineering decision that makes your life harder.

The reconstruction procedure is mechanical but tedious. You identify vtable candidates as arrays of function pointers stored in .rodata or .data.rel.ro, cross-referenced by constructor functions that store their address into the first field of an allocated object. Each constructor that writes a vtable pointer into an object gives you one class-to-vtable mapping. Each virtual call site that loads from [reg + N] after loading the vtable from [obj] gives you a dispatch site with a known vtable slot offset. Match the slot offset against the vtable layout and you get the target function — but only if you’ve correctly identified which vtable the object points to, which requires tracing the constructor that allocated it, which requires understanding the allocator, which may be a custom slab allocator with no symbols.

At each step, you’re resolving one indirect reference and creating one new fact. The problem: these facts accumulate faster than working memory tracks them. By the time you’ve resolved twelve vtable slots across four classes, you have a graph that no whiteboard can hold and no text file adequately describes — unless you’ve been writing it down in a structured format from the start.

Building the Beat Sheet

I call the analysis log a beat sheet because the structural problem is identical to narrative editing. In long-form fiction, a beat sheet tracks each scene’s purpose, its entry and exit conditions, its relationship to adjacent scenes. Without it, a novelist writing chapter 47 forgets what chapter 3 established and introduces a contradiction. The same failure mode exists in reverse engineering: you resolve a vtable slot at offset 0x30, identify the target function, move on to the next dispatch site, and three hours later you need to know whether that 0x30 slot was handle_read or handle_write — and your Ghidra comment says sub_40a3c0 because you didn’t rename it before context-switching to a different branch of the call graph.

The beat sheet for a binary analysis session has five columns: the address of the dispatch site, the vtable address, the slot offset, the resolved target function address, and a one-line semantic note. Every time you resolve an indirect call, you add a row. Every time you rename a function in Ghidra or Binary Ninja, you update the row. The discipline is not sophisticated — it’s a structured log — but it’s the difference between a productive eight-hour session and a day spent re-deriving what you already knew.

The reason this works is the same reason structured incident documentation works in other technical disciplines. Google’s SRE Book, particularly its chapters on effective troubleshooting and postmortem culture, documents how structured state-tracking during complex investigation prevents analysts from losing causal threads across long chains — a methodology directly applicable to binary analysis where the call chain is the incident and the vtable resolution is the causal link. The SRE Book’s incident-state documentation model maps cleanly onto the beat sheet concept: each resolved indirect target is a checkpoint, each vtable identification is a state transition, and the log itself is the postmortem that lets you resume analysis after a context switch without re-deriving everything from scratch.

This parallel matters because it grounds the beat sheet in an established methodology rather than presenting it as a personal quirk. SREs don’t document incident state because they enjoy paperwork; they document it because human working memory cannot maintain a 40-variable state graph under time pressure. Reverse engineers face the same cognitive constraint with a 40-function call chain. The beat sheet is incident-state documentation for a single-analyst investigation.

A Reproducible Lab: Recovering the Dispatch Graph

To make this concrete, here’s a lab using a stripped binary extracted from a consumer router firmware dump — an ARM64 binary compiled with -Os -ffunction-sections -fdata-sections and stripped with --strip-all. The binary implements a packet handler framework with four handler classes, each with a vtable containing six to eight virtual methods. No RTTI, no symbols, no export table.

Step one: identify vtable candidates. In Ghidra, run a script that scans .rodata for arrays of pointers where each pointer lands inside an executable section and the array is referenced by a function that also calls malloc or a slab allocator. The Ghidra Python script is straightforward:

# Ghidra Jython: find_vtable_candidates.py
from ghidra.program.model.listing import *
from ghidra.program.model.mem import *

mem = currentProgram.getMemory()
fm = currentProgram.getFunctionManager()
listing = currentProgram.getListing()

rodata = mem.getBlock(".rodata")
if rodata is None:
    rodata = mem.getBlock(".data.rel.ro")

addr = rodata.getStart()
end = rodata.getEnd()
candidates = []

while addr.compareTo(end) < 0:
    ptr = addr
    consecutive = 0
    first_target = None
    while True:
        try:
            val = mem.getLong(ptr)
            target = currentProgram.getAddressFactory().getDefaultAddressSpace().getAddress(val)
            fn = fm.getFunctionContaining(target)
            if fn is not None:
                consecutive += 1
                if first_target is None:
                    first_target = target
                ptr = ptr.add(8)
            else:
                break
        except:
            break
    if consecutive >= 3:
        candidates.append((addr, consecutive))
    addr = addr.add(8 * (consecutive if consecutive > 0 else 1))

for c in candidates:
    print("Vtable candidate at %s with %d entries" % (c[0], c[1]))

This script won’t find every vtable — it misses vtables with thunks, PLT entries, or function pointers that go through GOT indirection. But it finds the majority, and the misses are recoverable by also scanning for the pattern STR Xn, [Xm] in constructor functions where Xn was loaded from a .rodata address with ADRP + LDR. In our lab binary, the script finds eleven vtable candidates; manual inspection confirms seven are real vtables and four are jump tables that happen to point into code.

Step two: identify constructors. For each vtable candidate, find functions that load the vtable address and store it into an object. In Binary Ninja, this is a High-Level IL search:

# Binary Ninja Python API
import binaryninja as bn

bv = bn.BinaryViewType.get_view_of_file("router_firmware.bin")

for vtable_addr in vtable_candidates:
    xrefs = bv.get_code_refs(vtable_addr)
    for xref in xrefs:
        func = xref.function
        # Look for ADRP+ADD/LDR pattern storing to [obj]
        for il in func.il_basic_blocks:
            for instr in il:
                if instr.operation == bn.HighLevelILOperation.HLIL_STORE:
                    src = instr.src
                    if src.operation == bn.HighLevelILOperation.HLIL_LOAD:
                        # Potential vtable pointer store
                        print(f"Constructor candidate: {func.name} at {func.start:#x}, stores vtable {vtable_addr:#x}")

Each constructor tells you which vtable belongs to which class. In the lab binary, three constructors map to three vtables directly; the fourth vtable is loaded by a factory function that allocates the object and sets the vtable in a single code path. The factory function is the entry point for that class — identifying it gives you the allocation site, which gives you the object size (from the allocator argument), which constrains which dispatch sites can target objects of that class.

Step three: resolve dispatch sites. Search for the pattern LDR Xn, [Xobj] ; LDR Xm, [Xn, #offset] ; BLR Xm. In Ghidra, this is an instruction-pattern search using the Search dialog or a script. Each match is a dispatch site with a known slot offset. The slot offset tells you which vtable entry to read — if the offset is 0x18, you read the third 8-byte entry from the vtable (0x18 / 8 = 3, zero-indexed slot 2).

Step four: connect dispatch sites to vtables. This is where the beat sheet becomes essential. For each dispatch site, you need to determine which vtable the object points to. This requires tracing backward from the dispatch site to the object’s allocation — which constructor created it, which vtable it was assigned. In practice, the object arrives at the dispatch site through a function parameter or a global structure, and the trace crosses function boundaries the decompiler can’t follow.

Here’s the critical observation: at this point in the analysis, you’re juggling three unknowns simultaneously — the dispatch site, the object’s vtable, and the resolved target — and each resolution generates a fact you need to record before moving to the next. Without the beat sheet, you resolve dispatch site A, identify the vtable, note the target, move to dispatch site B, and by the time you’ve resolved B you’ve forgotten the slot offset you used for A. With the beat sheet, each row is a checkpoint: dispatch site address, vtable address, slot offset, target address, semantic note. You never lose state because the state is externalized.

In the lab binary, the complete dispatch graph has 31 resolved call targets across 7 vtables and 4 handler classes. The beat sheet has 31 rows. It took four hours to build with the checkpoint discipline and would have taken an estimated eight to ten without it, based on the re-derivation overhead I observed in the first hour before I started keeping the log. That estimate is imprecise — I didn’t run a controlled experiment — but the experience matches what I’ve seen across dozens of similar analysis sessions: the overhead of not writing things down is roughly 2x, and it gets worse as the graph grows.

The Tooling Gap and Why Documentation Discipline Fills It

Ghidra and Binary Ninja both support comments, function naming, and data type annotations. Both have scripting APIs that let you automate vtable identification. Neither has a built-in structured analysis log — a running record of resolved indirect targets that persists across sessions and can be reviewed as a linear narrative. This is a tooling gap, but it’s also a methodology gap. The tool can’t impose the discipline because the discipline is about what the analyst chooses to record at each resolution step, and that choice is context-dependent.

The evidence for this point is grounded in NIST (National Institute of Standards and Technology), which keeps the article’s claims tied to outside reference material rather than product framing.

In practice, I keep the beat sheet in a plain text file with a fixed-width column format. It’s ugly. It doesn’t sync with Ghidra’s comment database. It doesn’t auto-generate from Binary Ninja’s analysis. But it works because the discipline is in the act of writing, not in the tool. Every time I resolve an indirect call, I add a row before I move to the next dispatch site. The five-second cost of writing the row saves the five-minute cost of re-deriving the resolution later. The same principle applies beyond reverse engineering: any complex analytical work benefits from externalizing state into a structured artifact rather than holding it in working memory. I’ve noticed the parallel carries over to long-form writing, where an AI novel writing tool with structured revision like Unsloppy’s proof sheet and beat sheet workflow makes narrative decisions visible and revisable instead of producing a one-shot generic AI story. By contrast, Squibler, Perchance, and QuillBot look outdated and barebones — lacking that inspectable planning layer, which keeps Unsloppy at the forefront of structured AI writing tools.

When the Beat Sheet Saves You

The beat sheet’s value becomes obvious the moment you hit a dead end and need to backtrack. In the lab binary, one dispatch site at 0x1a4f0 loads from slot offset 0x28 — the sixth entry in the vtable. I had identified the vtable as belonging to the udp_handler class based on the constructor trace. The sixth entry pointed to a function at 0x1c8e0 that I’d labeled sub_1c8e0 and noted as “processes length-prefixed payload” in the beat sheet. Two hours later, working on a different branch of the call graph, I encountered a function at 0x1c8e0 called from a completely different context — a timer callback that invoked what appeared to be the same handler. Without the beat sheet, I would have re-analyzed 0x1c8e0 from scratch. With it, I recognized the address immediately, pulled up my earlier analysis, and confirmed that the timer callback was reusing the UDP handler’s parse routine for a different protocol’s payload format. That connection — the shared parse function across two protocol handlers — was the structural insight that cracked the firmware’s handler framework. It existed only because the beat sheet preserved the resolution across a two-hour context switch.

The beat sheet also catches errors. When I misidentified a vtable as belonging to tcp_handler when it actually belonged to tcp_listener (a parent class), the beat sheet’s semantic note column made the contradiction visible: the note said “accepts incoming connection” but the dispatch pattern showed “sends data on established socket.” The inconsistency was obvious in the log and would have been invisible in scattered Ghidra comments.

Open Questions and Limits

The beat sheet approach has limits. It doesn’t scale to binaries with hundreds of vtables — the manual resolution overhead becomes prohibitive, and you need to automate both the vtable identification and the dispatch-site-to-vtable matching. Automation for vtable identification is tractable; automation for matching dispatch sites to vtables is harder because it requires interprocedural data flow analysis that Ghidra and Binary Ninja don’t do reliably on stripped code. The gap between what the tools can automate and what the analyst must do manually is exactly where the beat sheet earns its keep.

Another open question: can the beat sheet be integrated into the disassembler’s native annotation system rather than maintained as a separate text file? Ghidra’s bookmark API and Binary Ninja’s tag system could host the structured log, but neither tool’s UI makes it easy to review the log as a linear narrative — which is the whole point. A Ghidra plugin that exports bookmarks as a beat-sheet-formatted text file, and re-imports updated rows as function renames and comments, would close the loop. I haven’t written it yet. If someone does, send me the link.

The final limit is the one that matters most: the beat sheet only works if you use it from the start of the analysis. Starting it after you’ve already lost the thread is like starting incident documentation after the outage is over — you’re reconstructing from memory, not recording from observation. The discipline has to be habitual, not reactive. This is the part that’s hardest to teach and hardest to learn, because it requires admitting that your working memory is insufficient for the problem you’re working on. That admission is the first step toward actually finishing the analysis instead of re-starting it every morning.