01. Blog

18 min read

How to reverse engineer anything

Published
Oct 14, 2026
Tags
Agent · MCP

A small figure kneels on a grassy clifftop above a turquoise sea, taking apart a red roadster, its seat, battery pack, motor, suspension and loose parts laid out around them on the grass

Recently a tool called REA (“Reverse Engineer Anything”) has been all over my GitHub feed. The repository, morluto/rea, was created on 14 April 2026 and when I checked the GitHub API on 14 October it had 210,777 stars. That’s a lot of stars for six months. The pitch is simple: you see a feature in some app, you ask your coding agent “how does this work?”, and the agent goes and takes the app apart for you, down to the binary if it has to.

I’ve always been curious about how the apps I use every day actually work under the hood, so I installed it, read through its code and docs, and then pointed it at an app on my own machine. This post is what I found: first how REA works, then a proper walkthrough where we take apart how Obsidian updates itself without ever running its installer again.

Bit of back story

REA is an MCP server and a CLI. MCP is the protocol coding agents like Claude Code, Codex and Cursor use to call outside tools, so once REA is registered your agent gets a whole reverse engineering toolkit:

  • Hopper, Ghidra or IDA for native code (executables and libraries)
  • a built-in static analyzer for JavaScript and Electron apps
  • JADX and adb for Android
  • a Chrome browser driven over the DevTools protocol for websites
  • readers for .NET assemblies, SQLite databases, packet captures, WebAssembly, firmware and more

The interesting part though isn’t the list of tools. It’s what comes back. Every result is an Evidence record: what was observed, what was inferred, and what is still unknown. We’ll see why that matters in a bit.

Here’s the quick summary:

WhatDetails
Packagerea-agents on npm (rea command), version 6.4.0
LicenseMIT (commercial engines like IDA and Hopper keep their own licenses)
InterfacesMCP server over stdio, and a CLI with 127 commands
AgentsClaude Code, Codex, Cursor, Gemini CLI, Grok Build, and any MCP client
RunsLocally, nothing is uploaded (your agent’s model provider still sees the results)
NeedsNode.js 22.19+, 24.11+ or 26+, and engines only for the targets that need them

Setting it up is one command:

npx rea-agents setup

It asks which agents you use, registers the MCP server with them, installs a matching agent skill and backs up whatever config it touches. Static JavaScript and .NET analysis don’t need any engine at all, which is why everything I did below needed nothing but Node.

How REA works

Here’s the big picture:

How REA worksYou ask your agent how a feature works. The agent, such as Claude Code, Codex or Cursor, calls REA over MCP; you can also run the rea CLI yourself. REA routes the target, runs a tool for you (Ghidra, Hopper or IDA; the JavaScript and Electron analyzer; Chrome DevTools; JADX, adb and apktool; or readers for .NET, SQLite, packet captures and WebAssembly) and records the result. An Evidence record goes back to the agent, with every fact labelled observed, inferred or unknown.How does this feature work?YOUOR rea <cmd>your agentCLAUDE CODE · CODEX · CURSORMCPREA1 ADAPTERS · MCP + CLI2 WORKFLOWS · SHARED3 ROUTING · 1 ENGINE4 LEDGER · EVIDENCETOOLS IT RUNS FOR YOUNATIVE CODEGhidra · Hopper · IDAJS / ELECTRONAST analyzer · V8 InspectorWEBSITESChrome DevTools · HARANDROIDJADX · adb · apktoolDATA + MANAGED.NET · SQLite · pcap · WASMEACH ONE AN OWNED PROCESS:DEADLINES · TEMP ROOTS · CLEANUPEvidence recordHASHED · ATTRIBUTEDobservedinferredunknownEVERY FACT SAYS WHICH ONE IT IS
Fig. 01 How REA works. You ask, your agent calls REA, REA runs the right tool, and an Evidence record comes back with every fact labelled.

Let’s go through it layer by layer.

Two ways in, one set of workflows

The MCP server (src/main.ts) and the CLI (src/cli.ts) are both thin wrappers over the same application layer (src/application/). So when your agent calls the analyze_javascript_application tool and when you type rea analyze-javascript-application in a terminal, the exact same workflow runs and you get the exact same Evidence back. That’s really handy, anything your agent did, you can run again by hand and compare.

Honestly the CLI is the better way to learn REA. Every command is one process, the output is JSON you can save to a file, and later commands take that saved file as input. Your whole investigation ends up being just a folder of JSON files.

The target decides the tool

REA’s agent skill starts every investigation the same way: look at what the target is, then pick the first tool. A few of the routes:

TargetFirst toolEngine
Electron .asar or a JS app folderanalyze_javascript_applicationbuilt in, static
Native executable or libraryopen_binary, then analyze_functionHopper, Ghidra or IDA
Android .apkinspect_android_packageJADX and a JDK
.NET assemblyinspect_managed_artifactbuilt in, static
A page open in your browserlist_browser_targetsChrome over CDP
HAR, SAZ or mitmproxy captureinspect_web_network_capturebuilt in
Go or Rust binaryinspect_go_binary, inspect_rust_binarybuilt in, reads embedded build metadata

For native code, open_binary binds exactly one engine to the target for the whole session. The architecture notes literally say “no fallback”: if Ghidra can’t do something, REA tells you, instead of quietly asking a different engine and mixing the answers together. I like that.

Engines run as separate processes

The native engines don’t run inside REA. REA launches them and talks to a small bridge that runs inside each one: a Python script inside Hopper (bridge/hopper_bridge.py), a headless Java script inside Ghidra (bridge/ghidra/ReaGhidraBridge.java), and the community ida-pro-mcp server for IDA. All of them sit on one process layer (src/process/) that owns the process, enforces deadlines, keeps file reads bounded and cleans up temp folders afterwards (on Windows it uses Job Objects). So if a decompiler hangs, it gets killed instead of hanging your agent with it.

Evidence is the real product

Every REA operation returns an Evidence record. Here’s the top of the one from our walkthrough, trimmed:

{
  "evidence_id": "ev_4686a28bd38fd63e73087ff4bdf8549ac1beefadf1967522c3f4c9324034a120",
  "subject": { "format": "asar", "digest": { "sha256": "d128a8a06e91…5475739" } },
  "provider": { "id": "rea-javascript-application", "version": "1" },
  "operation": "analyze_javascript_application",
  "confidence": "derived",
  "authority": "shipped-artifact",
  "limitations": [
    "JavaScript and HTML were parsed as inert text; bundle bootstrap code was never executed.",
    "Dynamic IPC channel expressions remain unresolved and are never paired by textual similarity.",
    "…"
  ]
}

Three things in there do most of the work:

  • Identity: the record is tied to the SHA-256 of the exact bytes that were analyzed, and the ID itself is a hash of the record. Follow-up tools take “authenticated” Evidence and only combine records whose target hashes match exactly.
  • Authority: every fact says where it came from: artifact-bytes (observed), static-relationship-inference (inferred), or a runtime observation. Static analysis never claims something actually ran.
  • Unknowns: gaps are kept, not hidden. If REA sees a require() but can’t work out what it loads, you get an edge pointing to ?, not a missing edge. Their design notes put it nicely: “Missing coverage is unknown, not absence.”

This is why it works so well with agents. Give a model raw decompiler output and it will happily fill the gaps with guesses. Give it facts already labelled observed, inferred and unknown, and it’s much easier for it to say “I don’t know yet, let me look”.

These records get big though (REA’s own docs mention a 334 MB Evidence file for an Obsidian analysis), so the follow-up commands read a saved record instead of redoing the analysis: inspect-analysis-view gives you a summary, a page of modules or one module, trace-application-feature walks the graph from a seed (a module, string, API, IPC channel or native export), and compare-application-versions diffs two versions of an app.

Static first, runtime when you ask

Most of REA just reads files without running them. There’s a separate set of capture-* and observe-* tools that do run things: capture-browser-scenario clicks and types through a page, observe-javascript-runtime attaches to a Node or Electron V8 Inspector, capture-process records a terminal program, and on macOS observe-native-calls launches a binary under LLDB. Each one only touches the target and actions you declare, and saves its results as runtime Evidence so it never gets mixed up with the static kind.

The skill and the prompts

The last layer is just advice for the agent. Setup installs a skill called reverse-engineer-anything that teaches it the routing above, the “summary first, then drill down” habit, and to keep observed, inferred and unknown separate in its final answer. The MCP server also exposes six guided prompts like investigate_feature, compare_application_versions and trace_crash, which suggest where to start without forcing a sequence of calls.

Let’s take apart Obsidian

Enough theory, let’s actually use it. Obsidian is an Electron app, and every now and then its settings show a “Relaunch” button to finish an update, even though I never downloaded a new installer. So where does the new version come from? How does Obsidian decide to trust it? And how does it swap it in?

Electron apps ship their code as .asar archives, which is exactly what REA’s static analyzer reads. Everything below ran on Windows 11 with Node 24.11.1 and rea-agents@6.4.0, against Obsidian’s 1.10.6 installer. REA never ran Obsidian.

Step 0: Make a copy first

Obsidian’s code lives in %LOCALAPPDATA%\Programs\Obsidian\resources: a 479 KB app.asar, a 26 MB obsidian.asar and an app.asar.unpacked folder. Rule number one of reverse engineering, never touch the original. So we copy them out and make sure the copies are identical:

O="$LOCALAPPDATA/Programs/Obsidian/resources"
cp "$O/app.asar" "$O/obsidian.asar" ./target/
cp -r "$O/app.asar.unpacked" ./target/
sha256sum "$O/app.asar" ./target/app.asar

The output:

d128a8a06e9192e5b442c7958c2293264f7d3c9885b8c63cae86c92c85475739  …/resources/app.asar
d128a8a06e9192e5b442c7958c2293264f7d3c9885b8c63cae86c92c85475739  ./target/app.asar

Same hash, so we’re good.

Step 1: The first run fails

Electron loads the small app.asar first, so that’s where we start:

npx -y rea-agents@6.4.0 analyze-javascript-application ./target/app.asar --format json > app.json

and about six seconds later, here we have the error:

{
  "code": "artifact_integrity_mismatch",
  "message": "Declared ASAR integrity for unpacked entry node_modules/btime/binding.node contradicts its companion file; packaging tools commonly sign or strip unpacked native binaries after writing the archive. …",
  "details": {
    "declared_sha256": "4cbfb569d7ca58bd3af49e06e5688412aad848af816743a079edd4a60f77005a",
    "calculated_sha256": "5ae52323bc9c911d7e125c5758239bdfe82ff4d50198925d7aceb4882b89ce5f"
  }
}

Annoying, right? But this one is actually a finding. The ASAR header stores a hash for every file, including the native addons kept outside the archive in app.asar.unpacked. Two of those addons, btime and get-fonts, don’t match their stored hashes.

REA’s guess was that they got signed after packing, so I checked with PowerShell’s Get-AuthenticodeSignature, and sure enough all four .node files are validly signed by “Dynalist Inc” (the company behind Obsidian). The build signed the binaries after writing the archive, which changed their bytes. Nothing shady, so we run it again with the option REA suggests, which keeps those bytes but marks them as untrusted:

npx -y rea-agents@6.4.0 analyze-javascript-application ./target/app.asar \
  --integrity-policy record-and-continue --format json > app.json

Thirteen seconds later we have a 12 MB Evidence file. Here’s what happened in between:

REA's static JavaScript analysisREA reads app.asar, 479 KB and 29 files, without running it. Five stages: inventory hashes 29 entries and finds 4 integrity mismatches; parse reads 19 JavaScript files, 9,403 syntax nodes; the application graph has 174 nodes and 225 edges; the semantic graph has 6,672 nodes and keeps 1,987 unknowns; seal produces one Evidence record, ev_4686a28b, with partial coverage. From that record you can view a summary, a page or one module, trace the graph from a seed, or compare two versions.app.asar479 KB · 29 FILESREAD, NOT RUNREAANALYZE-JAVASCRIPT-APPLICATION13 SECONDS1 INVENTORY29 HASHED4 MISMATCHES2 PARSE19 JS FILES9,403 NODES3 APP GRAPH174 NODES225 EDGES4 SEMANTIC6,672 NODES1,987 UNKNOWN5 SEAL1 RECORDPARTIALEvidenceev_4686a28b… · 12 MBTHEN READ THE RECORDViewSUMMARY · A PAGE · ONE MODULETraceWALK THE GRAPH FROM A SEEDCompareDIFF TWO VERSIONS OF THE APP
Fig. 02 What analyze-javascript-application does with Obsidian's app.asar: five stages, nothing executed, one sealed record. Every later command reads that record instead of the app.

Step 2: Read the summary first

Before reading any code, the summary already tells us a lot:

  • One entry point: a single electron-main node, main.js. The package.json says "name": "obsidian", "version": "1.10.6", "main": "main.js".
  • Almost no dependencies: five packages, Obsidian itself, @electron/remote, node-addon-api, and the two signed native addons btime and get-fonts.
  • No windows: browser_windows: 0 and preload_entrypoints: 0. The 15 IPC operations it found all belong to @electron/remote, not to Obsidian.

Wait, an Electron app whose main archive doesn’t create a single window? That’s not the app. That’s a loader.

Step 3: Trace out of main.js

Let’s see what main.js reaches. We build a trace input with main.js as the seed, outgoing only:

node -e '
const fs = require("fs");
const application = JSON.parse(fs.readFileSync("app.json", "utf8"));
fs.writeFileSync("trace.json", JSON.stringify({
  application, seed: { kind: "module", value: "main.js", match: "exact" }, direction: "outgoing"
}));'
npx -y rea-agents@6.4.0 trace-application-feature ./trace.json --format json > trace.out

The trace reached 56 nodes over 69 edges (47 observed facts, 97 inferred). The imports are path, util, os, crypto, fs, zlib, events, electron and @electron/remote, plus one import that points to ?.

A loader that needs crypto and zlib? It’s verifying and unpacking something. And that ? is the interesting one: REA can see a require() there but not what it loads, because the path is only known at runtime. A static tool can’t follow that, so this is where we go read the code ourselves.

Step 4: Read the bootstrap

Luckily main.js is only 465 lines and isn’t minified. The startup code at the bottom does this:

let asarPath = path.join(APP_PATH, 'obsidian.asar');
// …scan app.getPath('userData') for obsidian-<version>.asar, keep the newest…
if (updatedAsarPath) {
	log('Loading updated app package', updatedAsarPath);
	success = loadApp(updatedAsarPath);
}
if (!success) {
	log('Loading main app package', asarPath);
	success = loadApp(asarPath);
}

And loadApp is our ? from the trace:

function loadApp(asarPath) {
	let main = path.join(asarPath, 'main.js');
	let fn;
	try { fn = require(main); } catch (e) { return false; }
	if (fn) { fn(asarPath, updateEvents); return true; }
	return false;
}

So the program you install is just a shell. The real app is a second archive, loaded from one of two places: an updated copy in your user data folder if it’s newer than the shell, otherwise the obsidian.asar that came with the installer. If loading the update throws, it falls back to the shipped one. The app’s main.js exports a single function, which gets its own path and an EventEmitter called updateEvents.

How Obsidian startsObsidian.exe, installed as 1.10.6, opens resources/app.asar, whose 465-line main.js is a loader; REA's static trace shows its require of the app as unknown. The loader asks whether there is a newer obsidian-version.asar in %APPDATA%\obsidian. If yes, here obsidian-1.14.4.asar, it loads that. If not, or if requiring the update throws, it loads the shipped resources/obsidian.asar, version 1.10.6. Either way it calls the package's main.js with the package path and the updateEvents emitter: the app starts and the loader keeps running the updater.Obsidian.exeINSTALLED 1.10.6APP.ASAR · MAIN.JS465-line loaderREA TRACE: require(main) → ?PATH ONLY KNOWN AT RUNTIMENewer update in%APPDATA%\obsidian?YES%APPDATA% · THIS MACHINEobsidian-1.14.4.asarNORESOURCES · SHIPPEDobsidian.asar 1.10.6IF REQUIRE THROWSrequire(asar + '/main.js')(asarPath, updateEvents)THE APP STARTS · THE LOADER KEEPS RUNNING THE UPDATER (FIG. 04)
Fig. 03 How Obsidian starts. The program you install is a small loader that picks the newest app package it can find; on this machine that's 1.14.4, which the loader downloaded itself.

Step 5: Read the updater

The rest of main.js is the updater. It runs once when Electron is ready and then every hour (setInterval(queueUpdate, 60 * 60 * 1000)). Each run:

  1. Fetches desktop-releases.json from the obsidianmd/obsidian-releases repository on GitHub, and falls back to releases.obsidian.md if that fails. On the side, it also sends a fire-and-forget request to releases.obsidian.md/desktop with the version, the platform and a random 16-byte install ID that it keeps in a file called id in the user data folder.
  2. Reads minimumVersion, latestVersion, downloadUrl, hash and signature. If the installed shell is older than minimumVersion, it emits update-manual-required and stops, because the new app needs a newer Electron than this shell has, and only a full reinstall fixes that.
  3. Downloads the gzipped archive into memory, not to disk.
  4. Verifies it before writing a single byte:
let newHash = crypto.createHash('SHA256').update(compressedBuffer).digest('base64');
let verifiedHash = hash === newHash;
let verifiedSignature = crypto.createVerify('RSA-SHA256')
	.update(compressedBuffer)
	.verify(SIGNATURE_CERT, signature, 'base64');
  1. Only if both pass, it gunzips it, writes obsidian.asar.tmp, renames that to obsidian-<version>.asar, emits update-downloaded and deletes older versions (but never the one that’s currently running).

SIGNATURE_CERT is a certificate pasted right into the source. I decoded it with Node’s crypto.X509Certificate and it’s a self-signed 2048-bit RSA certificate for CN=dynalist.io, O=Dynalist, valid from May 2016 to May 2040.

This is the clever bit. The hash on its own proves nothing, because it comes in the same JSON as the download URL, so anyone who could swap the download could swap the hash too. The signature though can only come from whoever holds the private key for a certificate the app already has baked in.

Obsidian's update loopYou click Check for updates in the renderer, app.js, which sends the IPC message check-update. The main process, obsidian.asar, emits check on the updateEvents emitter, and the loader in app.asar runs queueUpdate; it also runs hourly and at start. The update fetches the releases JSON from GitHub, then obsidian.md; stops and asks for a manual reinstall if the shell is older than minimumVersion; downloads the gzipped package into memory; verifies its SHA-256 hash and RSA signature against a certificate for dynalist.io, logging and writing nothing if either fails; then gunzips it to a temporary file and renames it to obsidian-version.asar. The loader emits update-downloaded, the main process stores it and returns it to the renderer's sendSync update, the renderer shows Relaunch, and the next start loads the new package.YOURENDERER · APP.JSCheck for updatescheck-updateMAIN · OBSIDIAN.ASARipcMain.on(…)emit('check')LOADER · APP.ASARqueueUpdate(true)HOURLY≥releases JSONGITHUB, THEN OBSIDIAN.MDminimumVersionSHELL TOO OLD?→ MANUAL REINSTALLasar.gzDOWNLOADED INTO MEMORYNOT TO DISKverifySHA-256 + RSA SIGNATURECERT: DYNALIST.IOobsidian-X.asarGUNZIP → .TMP → RENAME✕ FAIL → LOG IT, WRITE NOTHINGemit('update-downloaded')MAIN STORES IT · sendSync('update')RENDERERRelaunchNEXT START LOADS THE NEW PACKAGE (FIG. 03)
Fig. 04 The update loop. Your click reaches the loader in two hops, and the new package is checked against a certificate built into the loader before anything touches the disk.

Step 6: Follow it into the app

The bootstrap only emits events, so something inside obsidian.asar has to listen to them and talk to the UI. Same command on the 26 MB archive, and this time it took 8 minutes 55 seconds and wrote 48 MB of Evidence: 76 files, 3.8 million AST nodes, two BrowserWindows, three renderer pages and 39 IPC handlers in the main process, every single one with a literal channel name. Six of those channels are the update plumbing: check-update, update, relaunch, disable-update, insider-build and update-menu-items.

My first trace from the check-update channel used "direction": "both" and came back with 388 nodes. That’s the entire graph. REA’s skill actually warns about this: containment edges let a two-way trace wander into every sibling module. Tracing incoming only gave a clean 10-node answer:

package obsidian-dev --loads--> electron-main index.js
index.js --imports--> main.js
main.js --contains--> ipc-handler check-update   (observed, main.js:38:5747)
ipc-handler check-update --handles--> ipc-channel check-update

What the trace doesn’t show is who sends on that channel. The summary says renderer_transmissions: 0, and the limitations say why: REA’s worker ran out of its default 1 GB heap on the 3.5 MB app.js and only kept the static facts for that file. REA marks this as unknown, and reading it as “the renderer never sends anything” would be the mistake here.

So I just searched app.js for the channel names. The Settings button calls ipcRenderer.send("check-update", true), then polls sendSync("check-update") every second while the check is running, reads sendSync("update") to decide between showing “Relaunch” or the “needs a major installer update” message, and the Relaunch button calls sendSync("relaunch"). In the main process, check-update emits check on updateEvents, which is the bootstrap’s cue to run queueUpdate(true). And that closes the loop in the diagram above.

I did try giving it more memory with --max-heap-mb 4096, but no luck. The worker still ran out of memory on app.js, and 13 minutes in the run stopped with provider_timeout on a PDF.js fallback file that hit the 300-second per-file limit. It did hand back its partial Evidence inside the error though, still with renderer_transmissions at 0. So for a bundle this size, use the static graph for the main process and search the code for the renderer side.

Step 7: Check it against my machine

Static analysis tells you what the code can do. The machine tells you what it actually did. Two quick read-only checks close the loop.

First, %APPDATA%\obsidian has a 27 MB obsidian-1.14.4.asar in it. So even though my installer is 1.10.6, the app I actually run every day is 1.14.4.

Second, obsidian.log in the same folder logs every step of the code we just read:

2026-10-10 23:13:54 Downloading update from https://github.com/obsidianmd/obsidian-releases/releases/download/v1.14.4/obsidian-1.14.4.asar.gz
2026-10-10 23:14:36 Verifying hash & signature. Size= 9200670
2026-10-10 23:14:36 Saving file
2026-10-10 23:14:36 Update complete.
2026-10-11 02:13:49 Latest version is 1.14.4
2026-10-11 02:13:49 An update is already downloaded.
2026-10-11 02:15:47 Loading updated app package C:\Users\…\AppData\Roaming\obsidian\obsidian-1.14.4.asar
2026-10-11 02:15:47 App is up to date.

The download, the verification, the hourly checks, and the next start loading the new package. All of it matches the code.

What I’d steal from it

Usually the reason you reverse engineer a feature is to build your own. If you’re shipping a desktop app, Obsidian’s updater comes down to five ideas worth copying:

  • Split the shell from the app: the installed program is a 465-line loader that barely changes. Updates only replace the app archive, so no installer, no admin rights, no reinstall.
  • Pin the signer, not the server: verify an RSA signature against a certificate built into the shell. Then even a compromised GitHub release or CDN can’t push code.
  • Verify in memory, then write atomically: download to a buffer, check it, write a .tmp file, then rename it. A half-written file never looks like a valid update.
  • Keep an escape hatch: minimumVersion covers the day the app needs a newer runtime than the shell has, and falling back to the shipped archive covers a broken download.
  • Log every step: a plain text log with timestamps is what made Step 7 a two-minute check.

Where REA falls short

It’s not perfect, a few things I ran into:

  • The output is heavy. A 479 KB archive produced 12 MB of Evidence. That’s the price of hashing and labelling everything, and it’s why the summary, page and trace views exist. Don’t paste raw Evidence into a chat.
  • Static analysis has a ceiling. REA was right to mark the require() handoff as unknown, but that means the most important edge in this whole teardown came from me reading code, not from the graph. Dynamic loading, computed IPC channel names and huge minified bundles all leave gaps that only reading the code, or runtime capture, can fill.
  • Native code needs engines. Anything compiled needs Hopper, Ghidra or IDA. Setup can install Hopper for you (with your approval), but Ghidra and IDA (and IDA’s license) are on you. Ghidra on Windows is still marked experimental.
  • It moves fast. The README asks you to update often, and the skill warns that a skill from the main branch can describe tools your installed version doesn’t have yet. Pin a version like I did with @6.4.0 if you want a run you can repeat.

Use it responsibly

REA’s code is MIT licensed, but its disclaimer says it’s meant for lawful research, education, interoperability, software preservation, and analysis of software you own or are authorized to examine. A lot of apps’ terms of service forbid reverse engineering outright, so check before you go poking.

Everything in this post is static analysis of files already installed on my own machine, done on copies, plus reading a log Obsidian writes for its own user. I haven’t published any keys, tokens or private endpoints, and the only certificate I decoded is a public one that exists to verify signatures.

Conclusion

REA doesn’t do the reverse engineering for you, but it does the boring parts really well: picking the right tool, running it safely, and being honest about what it actually knows. The rest is still you (or your agent) reading code and checking it against reality. If you want to try it yourself, the repository has the full tool catalog and installation guide, and rea.tools has more case studies, including a DX-Ball sound routine rebuilt from x86 and Notion’s Electron clipboard bridge.