diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..a0380f4
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+*.e5r-patch merge=union
+*.e5r-proj merge=union
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
new file mode 100644
index 0000000..60049c9
--- /dev/null
+++ b/.github/workflows/ci.yaml
@@ -0,0 +1,165 @@
+name: ci
+
+# `main` and pull requests, and nothing else.
+#
+# A branch name in this list is a name that will be wrong later: it was
+# `full-rewrite-to-rs`, which is one branch of one period of this repository's
+# life, and every other branch went unchecked while that one burned a runner on
+# every push. Work in progress is checked when it is proposed, which is what a
+# pull request is; a push to a branch nobody has proposed is not yet a claim
+# that anything works.
+on:
+ push:
+ branches: [main]
+ # Any base branch, so a feature branch proposed against anything is checked.
+ pull_request:
+
+env:
+ CARGO_TERM_COLOR: always
+ RUSTFLAGS: -D warnings
+
+# One in-flight run per ref. Pushing a fix ten seconds after a typo should not
+# leave the first run holding a runner; `main` is exempt so a merge is never
+# cancelled by the merge behind it.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+
+jobs:
+ # Runners are disposable, so these build the dev profile on purpose. The
+ # release-only rule in CLAUDE.md is about the maintainer's machine.
+ check:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ components: rustfmt, clippy
+ - uses: Swatinem/rust-cache@v2
+ - run: cargo fmt --all --check
+ - run: cargo clippy --workspace --all-targets -- -D warnings
+
+ # G2, G3, G4, G6, G7, G9: the whole suite, with the corpus built so the
+ # oracle-backed gates actually run. Without fixtures they skip silently.
+ test:
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ steps:
+ - uses: actions/checkout@v4
+ # The AArch64 std, for the Rust fixture: `build-fixtures.sh` builds it
+ # for a fixed target so the counts the tests hold do not follow the host.
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: aarch64-unknown-linux-gnu
+ - uses: Swatinem/rust-cache@v2
+ # Every gate that has no tool skips silently, which is the worst
+ # possible outcome: a green run that measured nothing. These are what
+ # the oracle-backed gates need, named so a missing one is visible.
+ # clang, llvm, binutils the fixture corpus and objdump parity
+ # llvm-objdump the ARM32 and x86-64 parity oracles
+ # gcc-aarch64-linux-gnu the `.a64` corpus. This runner is x86-64, so
+ # without it plain `gcc` writes an x86-64 binary
+ # into a name claiming AArch64, and the tests
+ # that read one measure the wrong file
+ # qemu-user-static both halves of the lifter's hardware oracle,
+ # and the recorded output of every executed
+ # AArch64 fixture
+ # golang-go the Go fixture, which found five decoder bugs
+ - name: Install the oracles
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y clang llvm binutils qemu-user-static golang-go \
+ gcc-aarch64-linux-gnu g++-aarch64-linux-gnu
+ - name: Build the fixture corpus
+ run: ./scripts/build-fixtures.sh
+ - name: Say which oracles are present
+ run: |
+ for t in clang llvm-objdump objdump readelf c++filt qemu-x86_64 go rustc; do
+ printf '%-16s %s\n' "$t" "$(command -v $t || echo MISSING)"
+ done
+ # `--no-fail-fast`: without it cargo stops at the first failing test
+ # binary, so a runner that differs from the maintainer's machine in
+ # several ways reports them one push at a time.
+ - run: cargo test --workspace --no-fail-fast -- --nocapture
+
+ # G5: the same bytes must produce the same output at any thread count.
+ determinism:
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v4
+ # The AArch64 std, for the Rust fixture: `build-fixtures.sh` builds it
+ # for a fixed target so the counts the tests hold do not follow the host.
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: aarch64-unknown-linux-gnu
+ - uses: Swatinem/rust-cache@v2
+ # The cross gcc because this runner is x86-64 and a `.a64` fixture
+ # has to be AArch64; qemu because several of them are executed.
+ - run: |
+ sudo apt-get update
+ sudo apt-get install -y clang \
+ gcc-aarch64-linux-gnu g++-aarch64-linux-gnu qemu-user-static
+ - run: ./scripts/build-fixtures.sh
+ - run: cargo build --release
+ - run: ./scripts/check-determinism.sh
+
+ # G8: what the analysis FOUND, against the floors in scripts/bench-budget.json.
+ #
+ # Not a benchmark, and deliberately not run as one. Recall against the symbol
+ # table and the count of entries no symbol names are the same bytes in, same
+ # numbers out, on any machine at any thread count, so they gate. Wall time
+ # and peak memory are facts about a machine, and a shared runner with a noisy
+ # neighbour is not the machine the ceilings were recorded on -- so timing is
+ # not measured here, not gated here, and not published from here. It is
+ # measured by hand on a known machine and written down in docs/benchmarks.md.
+ #
+ # One run per binary for the same reason: the fastest of three exists to beat
+ # down timing noise, and there is no timing to beat down.
+ recall:
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v4
+ # The AArch64 std, for the Rust fixture: `build-fixtures.sh` builds it
+ # for a fixed target so the counts the tests hold do not follow the host.
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: aarch64-unknown-linux-gnu
+ - uses: Swatinem/rust-cache@v2
+ # The cross gcc because this runner is x86-64 and a `.a64` fixture
+ # has to be AArch64; qemu because several of them are executed.
+ - run: |
+ sudo apt-get update
+ sudo apt-get install -y clang binutils \
+ gcc-aarch64-linux-gnu g++-aarch64-linux-gnu qemu-user-static
+ - run: ./scripts/build-fixtures.sh
+ # Release, like determinism above: the recall numbers have to come from
+ # the code path a user runs.
+ - run: cargo build --release
+ - run: RUNS=1 SKIP=rizin OUT=target/recall.json ./scripts/compare-tools.sh
+ - run: ./scripts/check-bench-budget.py target/recall.json --portable-only --no-timing
+
+ # `install.sh` is what people pipe into a shell, and nothing else in this
+ # workflow would notice it rotting: it is not built, not linked and not
+ # imported. Two checks, both cheap.
+ #
+ # `sh -n` is the syntax gate. `shellcheck` is the rest of it -- an unquoted
+ # expansion in an installer run as root is not a style note -- and it is
+ # pinned to POSIX `sh` because the shebang is `sh`, not bash.
+ installer:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+ - run: sh -n install.sh
+ - run: sudo apt-get update -q && sudo apt-get install -y shellcheck
+ - run: shellcheck --shell=sh install.sh
+ # The one thing syntax cannot see: that the script still knows where the
+ # release puts things. `build-release.sh` names the archive and the
+ # directory inside it, and the installer reaches into that directory by
+ # name -- so a change to either that forgot the other is a download that
+ # unpacks into a path nobody looks at.
+ - name: The installer agrees with the release layout
+ run: ./scripts/check-installer.sh
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
new file mode 100644
index 0000000..29afade
--- /dev/null
+++ b/.github/workflows/release.yaml
@@ -0,0 +1,232 @@
+name: release
+
+# Cut by pushing a tag. Everything a user downloads is built here, checksummed
+# here and signed here, so the tag is the only thing a maintainer types.
+#
+# What has actually been run, and what has not. The project's rule is that a
+# gate nobody has exercised is a gate that always passes, and a release
+# workflow is no different:
+# * The `install` and `linux` jobs run commands that were run by hand on the
+# maintainer's machine before this file was written. The two musl builds,
+# the archive layout and the checksum file are the output of
+# scripts/build-release.sh, which is the same script this workflow calls.
+# * The `macos` and `windows` jobs are UNVERIFIED. Neither can be run on the
+# development machine, which is aarch64 Linux with no macOS SDK and no
+# Windows import libraries, and cross-compiling is a different claim from
+# cross-testing in any case. The first tag that runs them is the first time
+# they have ever run; that is why the release is created as a draft below.
+on:
+ push:
+ tags: ["v*"]
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: "Tag to build and attach to"
+ required: true
+
+permissions:
+ contents: write
+ # Sigstore keyless signing uses this token as the identity. It is what lets
+ # the release be signed without a private key in the repository secrets.
+ id-token: write
+
+env:
+ CARGO_TERM_COLOR: always
+
+jobs:
+ # M12 promises `cargo install` works. That claim is about a copy of the crate
+ # with no fixtures/, no tests/ and no scripts/ beside it, so it is checked by
+ # installing into an empty root and running the binary there, not by reading
+ # the manifest. Verified locally before this file existed.
+ install:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+ - name: cargo install into an empty root
+ run: cargo install --path crates/e5r-cli --root "$RUNNER_TEMP/inst" --locked
+ - name: The installed binary runs with nothing else around it
+ run: |
+ cd "$RUNNER_TEMP"
+ ./inst/bin/e5r --version
+ ./inst/bin/e5r info /bin/ls
+ ./inst/bin/e5r funcs --json /bin/ls | head -c 200
+
+ # Static musl, both architectures, linked with the rust-lld that ships in
+ # every toolchain: no musl-gcc, no cross, no container. Verified on the
+ # maintainer's machine for both targets.
+ linux:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: x86_64-unknown-linux-musl,aarch64-unknown-linux-musl
+ - uses: Swatinem/rust-cache@v2
+ - run: ./scripts/build-release.sh x86_64-unknown-linux-musl aarch64-unknown-linux-musl
+ # The runner is x86_64, so one of the two can be run rather than merely
+ # linked. `file` reports the other, which is all this job can honestly
+ # claim about it.
+ - name: Say what came out
+ run: |
+ file dist/*.tar.gz
+ tar -tzf dist/e5r-*-x86_64-unknown-linux-musl.tar.gz
+ tmp=$(mktemp -d)
+ tar -xzf dist/e5r-*-x86_64-unknown-linux-musl.tar.gz -C "$tmp"
+ bin=$(find "$tmp" -name e5r -type f)
+ file "$bin"
+ # The build script already fails the job if either archive carries a
+ # NEEDED or an INTERP. This is the same fact in the form a reader of
+ # the log recognizes.
+ ldd "$bin" || true
+ "$bin" --version
+ "$bin" info /bin/ls
+ - uses: actions/upload-artifact@v4
+ with:
+ name: dist-linux
+ path: dist/*.tar.gz
+
+ # UNVERIFIED. No macOS machine and no SDK here, and Apple's linker cannot be
+ # had on Linux, so a macOS binary can only be built on a macOS runner. The
+ # runner is arm64; the x86_64 half is a cross-compile even there and is not
+ # executed, so this job claims a build and not a test.
+ macos:
+ runs-on: macos-14
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: aarch64-apple-darwin,x86_64-apple-darwin
+ - uses: Swatinem/rust-cache@v2
+ - run: ./scripts/build-release.sh aarch64-apple-darwin x86_64-apple-darwin
+ - name: Run the native one
+ run: |
+ tmp=$(mktemp -d)
+ tar -xzf dist/e5r-*-aarch64-apple-darwin.tar.gz -C "$tmp"
+ bin=$(find "$tmp" -name e5r -type f)
+ otool -L "$bin"
+ "$bin" --version
+ "$bin" info /bin/ls
+ - uses: actions/upload-artifact@v4
+ with:
+ name: dist-macos
+ path: dist/*.tar.gz
+
+ # UNVERIFIED. x86_64-pc-windows-msvc needs the MSVC libraries, which exist
+ # only on a Windows runner; x86_64-pc-windows-gnu was tried on the
+ # development machine and stops at the mingw-w64 import libraries, which
+ # rustup does not ship (only crt2.o). The code itself does compile for
+ # Windows: `cargo check --target x86_64-pc-windows-msvc` passes here, so what
+ # is untested is the link and the run, not the source.
+ windows:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: x86_64-pc-windows-msvc
+ - uses: Swatinem/rust-cache@v2
+ - name: Build
+ shell: pwsh
+ env:
+ RUSTFLAGS: -C strip=symbols
+ run: cargo build --release --target x86_64-pc-windows-msvc -p e5r-cli --bin e5r
+ - name: Archive
+ shell: pwsh
+ run: |
+ $version = (cargo metadata --no-deps --format-version 1 | ConvertFrom-Json).packages |
+ Where-Object { $_.name -eq "e5r-cli" } | ForEach-Object { $_.version }
+ $name = "e5r-$version-x86_64-pc-windows-msvc"
+ New-Item -ItemType Directory -Force -Path "dist/$name" | Out-Null
+ Copy-Item "target/x86_64-pc-windows-msvc/release/e5r.exe" "dist/$name/"
+ Copy-Item README.md, LICENSE, MANUAL.md "dist/$name/"
+ & "dist/$name/e5r.exe" --version
+ Compress-Archive -Path "dist/$name" -DestinationPath "dist/$name.zip"
+ - uses: actions/upload-artifact@v4
+ with:
+ name: dist-windows
+ path: dist/*.zip
+
+ # One checksum file over every archive, one signature over that file.
+ # Linux is the floor: if macOS or Windows fails, the release still happens
+ # with what built, and the notes say which platforms are in it. A missing
+ # platform should be visible, not a blocked release.
+ publish:
+ needs: [install, linux, macos, windows]
+ if: always() && needs.linux.result == 'success' && needs.install.result == 'success'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/download-artifact@v4
+ with:
+ path: dist
+ merge-multiple: true
+ - uses: sigstore/cosign-installer@v3
+
+ - name: Checksums
+ run: |
+ cd dist
+ rm -f SHA256SUMS
+ sha256sum e5r-* > SHA256SUMS
+ cat SHA256SUMS
+
+ # Keyless: the signing identity is this workflow's OIDC token, so there
+ # is no key to store and nothing to rotate. It needs the id-token
+ # permission above and nothing else. If it fails anyway (a fork, a
+ # policy, Sigstore being down) the release still goes out with its
+ # checksums rather than not going out at all, and the notes below say
+ # whether a signature is present.
+ - name: Sign the checksum file
+ id: sign
+ continue-on-error: true
+ run: |
+ cosign sign-blob --yes \
+ --output-signature dist/SHA256SUMS.sig \
+ --output-certificate dist/SHA256SUMS.pem \
+ dist/SHA256SUMS
+
+ - name: Release notes
+ run: |
+ tag="${{ github.event.inputs.tag || github.ref_name }}"
+ repo="${{ github.repository }}"
+ {
+ echo "## Downloads"
+ echo
+ echo "| File | SHA256 |"
+ echo "| --- | --- |"
+ while read -r sum file; do echo "| \`$file\` | \`$sum\` |"; done < dist/SHA256SUMS
+ echo
+ echo "The Linux archives are static musl binaries: no dynamic loader,"
+ echo "no glibc version to match, no runtime dependency of any kind."
+ echo
+ if [ -f dist/SHA256SUMS.sig ]; then
+ echo "### Verifying"
+ echo
+ echo '```'
+ echo "sha256sum -c SHA256SUMS"
+ echo "cosign verify-blob --signature SHA256SUMS.sig --certificate SHA256SUMS.pem \\"
+ echo " --certificate-identity-regexp '^https://github.com/$repo/' \\"
+ echo " --certificate-oidc-issuer https://token.actions.githubusercontent.com \\"
+ echo " SHA256SUMS"
+ echo '```'
+ else
+ echo "Signing did not run for this release; the checksums are unsigned."
+ fi
+ echo
+ echo "Built from \`$tag\`. The macOS and Windows archives are"
+ echo "cross-built and only smoke-tested on their own runner."
+ } > notes.md
+ cat notes.md
+
+ # A draft on purpose while the macOS and Windows jobs are unproven: the
+ # first tag that runs them is the first time they have ever run, and a
+ # human should look at the assets before anyone downloads them. Drop
+ # --draft once a real tag has produced a full set.
+ - name: Create the release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ tag="${{ github.event.inputs.tag || github.ref_name }}"
+ gh release create "$tag" --draft --title "$tag" --notes-file notes.md \
+ dist/SHA256SUMS dist/e5r-* $(ls dist/SHA256SUMS.sig dist/SHA256SUMS.pem 2>/dev/null)
diff --git a/.gitignore b/.gitignore
index 7fbbebb..1199877 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,174 +1,31 @@
-# custom
-build/
-
-# Byte-compiled / optimized / DLL files
-__pycache__/
-*.py[cod]
-*$py.class
-
-# C extensions
-*.so
-
-# Distribution / packaging
-.Python
-build/
-develop-eggs/
-dist/
-downloads/
-eggs/
-.eggs/
-lib/
-lib64/
-parts/
-sdist/
-var/
-wheels/
-share/python-wheels/
-*.egg-info/
-.installed.cfg
-*.egg
-MANIFEST
-
-# PyInstaller
-# Usually these files are written by a python script from a template
-# before PyInstaller builds the exe, so as to inject date/other infos into it.
-*.manifest
-*.spec
-
-# Installer logs
-pip-log.txt
-pip-delete-this-directory.txt
-
-# Unit test / coverage reports
-htmlcov/
-.tox/
-.nox/
-.coverage
-.coverage.*
-.cache
-nosetests.xml
-coverage.xml
-*.cover
-*.py,cover
-.hypothesis/
-.pytest_cache/
-cover/
-
-# Translations
-*.mo
-*.pot
-
-# Django stuff:
-*.log
-local_settings.py
-db.sqlite3
-db.sqlite3-journal
-
-# Flask stuff:
-instance/
-.webassets-cache
-
-# Scrapy stuff:
-.scrapy
-
-# Sphinx documentation
-docs/_build/
-
-# PyBuilder
-.pybuilder/
-target/
-
-# Jupyter Notebook
-.ipynb_checkpoints
-
-# IPython
-profile_default/
-ipython_config.py
-
-# pyenv
-# For a library or package, you might want to ignore these files since the code is
-# intended to run in multiple environments; otherwise, check them in:
-# .python-version
-
-# pipenv
-# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
-# However, in case of collaboration, if having platform-specific dependencies or dependencies
-# having no cross-platform support, pipenv may install dependencies that don't work, or not
-# install all needed dependencies.
-#Pipfile.lock
-
-# PEP 582; used by e.g. github.com/David-OConnor/pyflow
-__pypackages__/
-
-# Celery stuff
-celerybeat-schedule
-celerybeat.pid
-
-# SageMath parsed files
-*.sage.py
-
-# Environments
-.env
-.venv
-env/
-venv/
-ENV/
-env.bak/
-venv.bak/
-
-# Spyder project settings
-.spyderproject
-.spyproject
-
-# Rope project settings
-.ropeproject
-
-# mkdocs documentation
-/site
-
-# mypy
-.mypy_cache/
-.dmypy.json
-dmypy.json
-
-# Pyre type checker
-.pyre/
-
-# pytype static type analyzer
-.pytype/
-
-# Cython debug symbols
-cython_debug/
-
-# Prerequisites
-*.d
-
-# Compiled Object files
-*.slo
-*.lo
+# Rust
+/target/
+**/*.rs.bk
+*.pdb
+
+# Build and analysis artifacts
+/fixtures/build/
+/fixtures/corpus/
*.o
-*.obj
+*.elf
+*.exe
-# Precompiled Headers
-*.gch
-*.pch
+# Coverage and mutation reports
+/lcov.info
+/mutants.out*/
+/coverage/
-# Compiled Dynamic libraries
-*.so
-*.dylib
-*.dll
+# Fuzzing
+/fuzz/artifacts/
+/fuzz/coverage/
-# Fortran module files
-*.mod
-*.smod
+# Machine-local Claude Code settings (wires up scripts/deny-debug-build.py)
+.claude/settings.local.json
-# Compiled Static libraries
-*.lai
-*.la
-*.a
-*.lib
+# Editors
+.vscode/
+.idea/
+*.swp
-# Executables
-*.exe
-*.out
-*.app
+# Release archives, built by scripts/build-release.sh.
+dist/
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..b831b62
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,36 @@
+# CLAUDE.md
+
+Scope, milestones and the list of things this project will not build live in
+[`ROADMAP.md`](ROADMAP.md). Read that first.
+
+## This checkout is release-only
+
+The machine this repository is developed on has 7.5 GB of RAM and the workspace
+will grow a SLEIGH compiler, several decoders and a decompiler. A dev-profile
+build of that is large enough to be a problem here, and the release build is
+already warm, so a debug build is also the slower path.
+
+Whether a dev build is affordable is a fact about the machine, not about the
+project, so the guards are opt-in and gitignored rather than committed settings:
+
+1. `scripts/no-debug-guard.sh on` parks a regular file at `target/debug`, so
+ cargo stops with "failed to create directory ... File exists" before it
+ compiles anything. `off` and `status` do what they say. It works against every
+ tool, not just Claude Code. The file is under `target/`, which is ignored.
+2. `scripts/deny-debug-build.py` is a PreToolUse hook that refuses dev-profile
+ cargo commands, target-directory redirection, and attempts to remove the
+ guard. Wire it up in `.claude/settings.local.json`:
+
+ ```json
+ { "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [
+ { "type": "command",
+ "command": "python3 \"$CLAUDE_PROJECT_DIR/scripts/deny-debug-build.py\"" } ] } ] } }
+ ```
+
+Both are on in this checkout. Every cargo invocation takes `--release` and the
+binary is `./target/release/e5r`. Do not work around a "File exists" failure on
+`target/debug`: build release, or turn the guard off deliberately, which is the
+owner's call.
+
+CI runs on disposable runners, so the workflow builds dev profile on purpose. Do
+not add `--release` there.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..31a1818
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,43 @@
+# CLAUDE.md
+
+Scope, milestones and the list of things this project will not build live in
+[`ROADMAP.md`](ROADMAP.md). Read that first.
+
+## This checkout is release-only
+
+The machine this repository is developed on has 7.5 GB of RAM and the workspace
+will grow a SLEIGH compiler, several decoders and a decompiler. A dev-profile
+build of that is large enough to be a problem here, and the release build is
+already warm, so a debug build is also the slower path.
+
+Whether a dev build is affordable is a fact about the machine, not about the
+project, so the guards are opt-in and gitignored rather than committed settings:
+
+1. `scripts/no-debug-guard.sh on` parks a regular file at `target/debug`, so
+ cargo stops with "failed to create directory ... File exists" before it
+ compiles anything. `off` and `status` do what they say. It works against every
+ tool, not just Claude Code. The file is under `target/`, which is ignored.
+2. `scripts/deny-debug-build.py` is a PreToolUse hook that refuses dev-profile
+ cargo commands, target-directory redirection, and attempts to remove the
+ guard. Wire it up in `.claude/settings.local.json`:
+
+ ```json
+ { "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [
+ { "type": "command",
+ "command": "python3 \"$CLAUDE_PROJECT_DIR/scripts/deny-debug-build.py\"" } ] } ] } }
+ ```
+
+Both are on in this checkout. Every cargo invocation takes `--release` and the
+binary is `./target/release/e5r`. Do not work around a "File exists" failure on
+`target/debug`: build release, or turn the guard off deliberately, which is the
+owner's call.
+
+CI runs on disposable runners, so the workflow builds dev profile on purpose. Do
+not add `--release` there.
+
+That difference matters for more than space. Dev profile panics on an
+overflowing `-`; release wraps it and returns a wrong answer quietly. So an
+arithmetic bug in a decoder is invisible to every run on this machine and shows
+up only on CI -- `move_wide_preferred` underflowed on every 32-bit
+`orr Rd, ZR, #imm`, and the local suite was green throughout. A red CI is worth
+reading before assuming it is about the runner.
diff --git a/CMakeLists.txt b/CMakeLists.txt
deleted file mode 100644
index 7e4643a..0000000
--- a/CMakeLists.txt
+++ /dev/null
@@ -1,20 +0,0 @@
-cmake_minimum_required(VERSION 3.13)
-project("libmydisas" LANGUAGES C CXX)
-
-# find_package(Threads REQUIRED)
-
-set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3 -mtune=native -march=native")
-
-set(SOURCE_DIR "src")
-set(SCRIPT_DIR "script")
-set(TEST_DIR "test")
-
-include(GNUInstallDirs)
-
-include_directories("${PROJECT_SOURCE_DIR}/src")
-
-add_subdirectory(${SOURCE_DIR})
-add_subdirectory(${SCRIPT_DIR})
-
-enable_testing()
-add_subdirectory(${TEST_DIR})
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..c7dcee8
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,78 @@
+# Contributing
+
+## The rule that matters: clean room
+
+This project reimplements ideas that other reverse engineering tools also
+implement. Ideas are not the problem; copied code is.
+
+**Work from public specifications and from observed behaviour, never from
+another tool's source.** In practice:
+
+- Read the published specification: the ELF gABI and the processor supplements,
+ the DWARF standard, the PE/COFF specification, the Itanium C++ ABI, the ARM
+ and Intel architecture reference manuals, the public documentation of the PDB
+ format.
+- Observe what an existing tool *does* as much as you like. Running `objdump`
+ and comparing its output against ours is how the decoders are tested, and
+ that is fine: the output is a fact about the input, not a piece of someone's
+ program.
+- Do not read another tool's implementation and then write the same thing here.
+ If you have read it, say so in the pull request, and expect the reviewer to
+ ask someone who has not to write that part.
+- One exception, deliberately narrow: the SLEIGH *language definitions* under
+ Ghidra's processor tree are data, and loading them is the point of M2. Their
+ format is specified. The decompiler's source is not to be consulted.
+
+If a fixture or a test case comes from another project, say where it came from
+and under what licence, in the file that uses it.
+
+## What a change has to come with
+
+Every claim this tool makes is measured, and a change that makes a claim has to
+bring its measurement. `ROADMAP.md` says what each milestone is gated on; the
+short version:
+
+- **An external oracle wherever one exists.** A decoder is compared against
+ `objdump` and `llvm-objdump`; a lifter against what a processor produced when
+ the same program was run; a demangler against `c++filt`; debug information
+ against `readelf`; a merge against `git merge`. Numbers that only compare the
+ tool against itself are not evidence.
+- **Wrong and missing are different.** A wrong answer is a bug with no
+ allowance and the test asserts zero of them. A missing answer is a gap with a
+ floor or a ceiling that only moves one way, and the number stays in
+ `docs/scorecard.md` even when it is unflattering.
+- **A test that cannot fail is not a test.** After writing one, break the code
+ it covers on purpose and check that it goes red.
+
+## Style
+
+- Comments are concise and explain *why*. The code says what it does. A comment
+ that restates the line above it is noise, and a banner is worse.
+- Say what is not known. An analysis that cannot work something out reports
+ that rather than guessing, and the type system should make the difference
+ visible: `Option`, an explicit `Unresolved`, a strength on the evidence.
+- No emoji, no em dashes.
+- `cargo clippy --release --all-targets` leaves no warnings.
+
+## Building
+
+This checkout is release-only; `CLAUDE.md` explains why and how the guards
+work. Every command takes `--release`:
+
+```
+CARGO_BUILD_JOBS=4 cargo build --release
+CARGO_BUILD_JOBS=4 cargo test --release
+bash scripts/build-fixtures.sh # the corpus the gates measure against
+bash scripts/check-determinism.sh # the same input must give the same output
+```
+
+Fixtures are built, not committed. A test whose fixture or tool is missing
+returns early rather than failing, so a fresh checkout on a different machine
+still runs the suite it can.
+
+## Reporting something wrong
+
+A decoding that disagrees with `objdump`, a lifting that disagrees with the
+processor, or a recovered fact that is untrue are the most valuable reports
+this project can get. Include the bytes, the architecture, and what the other
+tool said.
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..9de2d26
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,498 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "bitflags"
+version = "2.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+
+[[package]]
+name = "clap"
+version = "4.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+ "terminal_size",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
+
+[[package]]
+name = "e5r-analysis"
+version = "0.1.0"
+dependencies = [
+ "e5r-arch",
+ "e5r-core",
+ "e5r-format",
+ "e5r-types",
+ "rayon",
+ "serde",
+]
+
+[[package]]
+name = "e5r-api"
+version = "0.1.0"
+dependencies = [
+ "e5r-analysis",
+ "e5r-core",
+ "e5r-db",
+ "e5r-decomp",
+ "e5r-format",
+ "e5r-ir",
+ "e5r-types",
+]
+
+[[package]]
+name = "e5r-arch"
+version = "0.1.0"
+dependencies = [
+ "e5r-core",
+]
+
+[[package]]
+name = "e5r-asm"
+version = "0.1.0"
+dependencies = [
+ "e5r-arch",
+ "e5r-core",
+]
+
+[[package]]
+name = "e5r-cli"
+version = "0.1.0"
+dependencies = [
+ "clap",
+ "e5r-analysis",
+ "e5r-api",
+ "e5r-arch",
+ "e5r-asm",
+ "e5r-core",
+ "e5r-db",
+ "e5r-decomp",
+ "e5r-diff",
+ "e5r-format",
+ "e5r-ir",
+ "e5r-types",
+ "memmap2",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "e5r-core"
+version = "0.1.0"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "e5r-db"
+version = "0.1.0"
+dependencies = [
+ "e5r-analysis",
+ "e5r-arch",
+ "e5r-core",
+ "e5r-format",
+ "e5r-types",
+ "serde",
+]
+
+[[package]]
+name = "e5r-decomp"
+version = "0.1.0"
+dependencies = [
+ "e5r-analysis",
+ "e5r-api",
+ "e5r-arch",
+ "e5r-core",
+ "e5r-decomp",
+ "e5r-format",
+ "e5r-ir",
+ "serde",
+]
+
+[[package]]
+name = "e5r-diff"
+version = "0.1.0"
+dependencies = [
+ "e5r-analysis",
+ "e5r-arch",
+ "e5r-core",
+ "e5r-db",
+ "e5r-format",
+ "serde",
+]
+
+[[package]]
+name = "e5r-format"
+version = "0.1.0"
+dependencies = [
+ "e5r-core",
+ "e5r-types",
+ "serde",
+]
+
+[[package]]
+name = "e5r-ir"
+version = "0.1.0"
+dependencies = [
+ "e5r-analysis",
+ "e5r-arch",
+ "e5r-core",
+ "e5r-format",
+ "e5r-types",
+ "serde",
+]
+
+[[package]]
+name = "e5r-sla"
+version = "0.1.0"
+dependencies = [
+ "e5r-sleigh",
+]
+
+[[package]]
+name = "e5r-sleigh"
+version = "0.1.0"
+dependencies = [
+ "e5r-arch",
+ "e5r-core",
+]
+
+[[package]]
+name = "e5r-types"
+version = "0.1.0"
+
+[[package]]
+name = "either"
+version = "1.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "memmap2"
+version = "0.9.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rayon"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
+dependencies = [
+ "either",
+ "rayon-core",
+]
+
+[[package]]
+name = "rayon-core"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
+dependencies = [
+ "crossbeam-deque",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "3.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "terminal_size"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
+dependencies = [
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..57c5de1
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,60 @@
+# e5r — a CLI reverse engineering toolkit.
+#
+# The library owns every fact; the CLI is a thin client of it.
+# Crate boundaries and the dependency direction are documented in ROADMAP.md
+# under "Architecture": edges run downward only, and e5r-core depends on
+# nothing else in the workspace.
+[workspace]
+members = ["crates/*"]
+resolver = "3"
+
+[workspace.package]
+version = "0.1.0"
+edition = "2024"
+rust-version = "1.85"
+license = "Apache-2.0"
+repository = "https://github.com/h5i-dev/e5r"
+authors = ["e5r contributors"]
+# Publishing metadata. Here rather than in each crate so a crate that becomes
+# publishable only has to opt in; e5r-cli is the one that is published today.
+homepage = "https://github.com/h5i-dev/e5r"
+documentation = "https://docs.rs/e5r-cli"
+readme = "README.md"
+keywords = ["reverse-engineering", "disassembler", "decompiler", "binary", "elf"]
+categories = ["command-line-utilities", "development-tools::debugging"]
+
+[workspace.dependencies]
+e5r-core = { path = "crates/e5r-core", version = "0.1.0" }
+e5r-format = { path = "crates/e5r-format", version = "0.1.0" }
+e5r-arch = { path = "crates/e5r-arch", version = "0.1.0" }
+e5r-asm = { path = "crates/e5r-asm", version = "0.1.0" }
+e5r-sla = { path = "crates/e5r-sla", version = "0.1.0" }
+e5r-sleigh = { path = "crates/e5r-sleigh", version = "0.1.0" }
+e5r-analysis = { path = "crates/e5r-analysis", version = "0.1.0" }
+e5r-db = { path = "crates/e5r-db", version = "0.1.0" }
+e5r-types = { path = "crates/e5r-types", version = "0.1.0" }
+e5r-diff = { path = "crates/e5r-diff", version = "0.1.0" }
+e5r-ir = { path = "crates/e5r-ir", version = "0.1.0" }
+e5r-decomp = { path = "crates/e5r-decomp", version = "0.1.0" }
+e5r-api = { path = "crates/e5r-api", version = "0.1.0" }
+
+# Kept deliberately short. Loaders and decoders are written here rather than
+# pulled in, because provenance tagging and hostile-input behaviour are the
+# product, not an implementation detail we can delegate.
+memmap2 = "0.9"
+rayon = "1.10"
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+clap = { version = "4.5", features = ["derive", "wrap_help"] }
+
+# A dev-profile build lands in target/debug, which this checkout forbids
+# (see CLAUDE.md). Release is the only profile used here.
+[profile.release]
+opt-level = 3
+# Off on purpose: this machine has 7.5 GB of RAM and debug info in a workspace
+# this size makes linking the peak-memory step. Profile with a temporary
+# override when a flamegraph is actually needed.
+debug = false
+lto = "thin"
+codegen-units = 16
+panic = "unwind"
diff --git a/MANUAL.md b/MANUAL.md
new file mode 100644
index 0000000..aabf042
--- /dev/null
+++ b/MANUAL.md
@@ -0,0 +1,485 @@
+# e5r
+
+Reverse engineering from the command line. Load a binary, recover its
+functions, disassemble them, decompile them, and ask questions about what is
+there.
+
+Every command takes `--json` and prints the same information in a form a
+script can read. Exit codes are `0` for success, `1` for nothing found, `2` for
+a bad command line, and `3` for input that could not be read.
+
+```
+cargo build --release
+./target/release/e5r info /bin/ls
+```
+
+## Installing
+
+### A release binary
+
+Every release attaches one archive per platform, a `SHA256SUMS` file and a
+signature over it. Unpack and put `e5r` on your path:
+
+```
+tar -xzf e5r-0.1.0-x86_64-unknown-linux-musl.tar.gz
+install -m755 e5r-0.1.0-x86_64-unknown-linux-musl/e5r /usr/local/bin/
+```
+
+Check what you downloaded first. The checksums cover the archives, and the
+signature covers the checksum file. Signing is Sigstore keyless, so the
+identity is the release workflow itself and there is no key to distribute:
+
+```
+sha256sum -c SHA256SUMS
+cosign verify-blob --signature SHA256SUMS.sig --certificate SHA256SUMS.pem \
+ --certificate-identity-regexp '^https://github.com/h5i-dev/e5r/' \
+ --certificate-oidc-issuer https://token.actions.githubusercontent.com \
+ SHA256SUMS
+```
+
+| Platform | Archive |
+| --- | --- |
+| Linux x86-64 | `e5r-VERSION-x86_64-unknown-linux-musl.tar.gz` |
+| Linux AArch64 | `e5r-VERSION-aarch64-unknown-linux-musl.tar.gz` |
+| macOS Apple silicon | `e5r-VERSION-aarch64-apple-darwin.tar.gz` |
+| macOS Intel | `e5r-VERSION-x86_64-apple-darwin.tar.gz` |
+| Windows x86-64 | `e5r-VERSION-x86_64-pc-windows-msvc.zip` |
+
+### What the Linux build guarantees
+
+The Linux archives are static musl binaries. There is no dynamic loader, no
+`libc.so` to find and no glibc version to match, so one file runs on any Linux
+with the same architecture: a current distribution, an eight-year-old one,
+Alpine, a distroless container, or a rescue initramfs. `ldd` on it says it is
+not a dynamic executable, and that is the whole claim:
+
+```
+$ file e5r
+e5r: ELF 64-bit LSB pie executable, x86-64, static-pie linked, stripped
+```
+
+Nothing outside the binary is needed at run time. The fixture corpus under
+`fixtures/` is what the test suite measures against; an installed `e5r` never
+reads it, and no data file, configuration or cache directory is required to
+start.
+
+### From source
+
+```
+cargo install --git https://github.com/h5i-dev/e5r e5r-cli
+cargo install --path crates/e5r-cli # from a checkout
+```
+
+The dependency list is deliberately short (`serde` and `serde_json`, `clap`,
+`memmap2`, `rayon`, and nothing else), and no crate in the workspace has a
+build script or links against a C library. That is what makes cross-compiling
+a matter of naming a target rather than assembling a sysroot: the loaders and
+decoders are written here rather than pulled in, so nothing in the graph needs
+a platform toolchain.
+
+To build the release archives yourself, for any target whose standard library
+rustup has installed:
+
+```
+scripts/build-release.sh x86_64-unknown-linux-musl aarch64-unknown-linux-musl
+```
+
+It links the musl targets with the `rust-lld` that comes with the toolchain, so
+cross-building the other architecture's static binary needs no musl gcc, no
+`cross` and no container. The archives and the `SHA256SUMS` land in `dist/`.
+The tarball adds no variation of its own: entries are sorted and ownership and
+timestamps are zeroed, so the same binary always packs to the same checksum.
+
+### install.sh
+
+```
+curl -fsSL https://raw.githubusercontent.com/h5i-dev/e5r/main/install.sh | sh
+```
+
+It works out the platform, asks the releases API for the latest tag, downloads
+that archive, **verifies it against the release's `SHA256SUMS` and refuses to
+install if it does not match**, and puts the binary in `/usr/local/bin` with
+`install` rather than `mv`, so a `sudo` install does not leave a user-writable
+binary in a root-owned directory.
+
+Four environment variables, all of them for a case the default does not cover:
+
+| | |
+| --- | --- |
+| `E5R_INSTALL_DIR` | somewhere other than `/usr/local/bin` |
+| `E5R_VERSION` | a tag other than the latest |
+| `E5R_BASE_URL` | a mirror, or a staged release that is not published yet |
+| `E5R_SKIP_CHECKSUM` | install without verifying, said out loud on stderr |
+
+There is no Homebrew formula. There was a draft one, and it was deleted rather
+than published: it had never been run against `brew`, and a tap is a second
+place a version number has to be right. A script that has been run against a
+real archive, and refuses a tampered one, is the smaller promise and the kept
+one.
+
+## What it will and will not tell you
+
+Every recovered fact carries the evidence for it, and the strength of that
+evidence is printed next to it:
+
+| Strength | Meaning |
+| --- | --- |
+| `asserted` | A person or an agent wrote it in the annotation log. |
+| `proven` | The file says so: a symbol table, debug information, an entry point, an unwind record. |
+| `inferred` | Something in the code implies it: a call target, a jump table, an import thunk. |
+| `heuristic` | A pattern suggests it: a prologue, a linear sweep, a pointer in data. |
+
+Where a thing cannot be worked out, it is reported as unknown rather than
+guessed at. A function whose control flow could not be followed to the end is
+marked incomplete; a jump table whose bound could not be established is left
+unresolved; an instruction the lifter does not model appears as unmodelled in
+the output rather than being approximated.
+
+## Looking at a file
+
+```
+e5r info FILE container, architecture, entry, what the loader noticed
+e5r sections FILE sections and where they are mapped
+e5r symbols FILE symbols the container names
+e5r imports FILE what it needs from elsewhere
+e5r exports FILE what it offers
+e5r strings FILE strings found in the image, by section
+e5r stats FILE counts: functions, blocks, instructions, references
+```
+
+`info` and the listings above need only the container, so they are instant
+whatever the size of the file.
+
+## Code
+
+```
+e5r funcs FILE every recovered function and its evidence
+e5r disas FILE TARGET disassemble a function, an address, or `all`
+e5r xrefs FILE TARGET [--from] references to an address, or from one
+e5r decompile FILE TARGET pseudo-C for a function, or for `all`
+e5r emulate FILE TARGET [ARGS...] run a function and report what it did
+```
+
+`TARGET` is an address (`0x401000`, `401000`), a symbol name, or `all`.
+
+The decompiler's output says what the machine does in C's notation. It does
+not claim to be the source. Where the control flow does not fit an `if` or a
+`while`, a labelled `goto` appears rather than a shape that is not there, and
+the number of them is printed above the function: that is the honest measure
+of how well the structuring worked. Where the binary carries debug
+information, parameters are named and typed as the compiler recorded them;
+where it does not, they are recovered from what the code does with them.
+
+`emulate` runs the function in the same interpreter the lifters are tested
+against. Nothing escapes the process: memory is a copy of the image, a system
+call stops the run, and a budget bounds it.
+
+## Windows
+
+A PE keeps its debug information in a separate `.pdb`, so it is looked for: at
+the path the linker recorded, and beside the binary, which is where the file
+usually actually is.
+
+**A database from the wrong build is refused**, with a note saying so. Symbols
+from a different build are worse than no symbols, because they are confidently
+wrong, so the identity the image asks for is checked before anything in the
+database is believed.
+
+```
+$ e5r funcs prog.exe
+0x140001000 ... proven program database compare_points
+0x140001020 ... proven program database walk
+```
+
+Without it, the same binary yields only its entry point.
+
+`e5r info` reports `pe.pdb`, `pe.pdb.key` and `pe.pdb.age`, which together
+are what a symbol server is indexed by.
+
+## C++
+
+```
+e5r classes prog # the hierarchy
+e5r classes prog --members # and every member function, with its `this`
+```
+
+Classes come from the type information where a binary has it, and from the
+virtual tables alone where it does not. Both Itanium and Microsoft layouts are
+read. Every claim says what it rests on: a name read out of RTTI or a mangled
+symbol is proven, one inferred from what a function writes is not, and the two
+are never printed as each other.
+
+A binary built with `-fno-rtti` degrades and says which it is:
+
+```
+13 class(es) from 13 table(s); 9 named by symbol, 0 only by type information
+ (unknown, absent (-fno-rtti): vtables only, no hierarchy)
+```
+
+`--members` lifts every member function to see what it touches through
+`this`, which on a real C++ library takes tens of seconds, so it is off by
+default.
+
+## Structure and types
+
+```
+e5r shapes FILE TARGET what the pointers a function takes point at
+e5r vtables FILE virtual tables and the classes they belong to
+```
+
+`shapes` reports the offsets a function touches through each pointer it is
+given, and the element size when it walks an array of them. It reports what it
+saw rather than a conclusion: it does not name fields and it does not average
+two disagreeing strides into one.
+
+## Asking questions
+
+```
+e5r query FILE 'functions where insns > 100 and name ~ "crypt"'
+e5r query FILE 'strings where length >= 20 and text ~ "/etc/"'
+e5r query FILE 'xrefs where kind = call and to = 0x401000 limit 20'
+```
+
+Entities: `functions`, `strings`, `symbols`, `imports`, `exports`, `sections`,
+`xrefs`. Operators: `=` `!=` `<` `<=` `>` `>=`, `~` for contains and `!~` for
+does not, combined with `and`, `or`, `not` and brackets. Numbers may be
+decimal or hexadecimal. A field that does not exist is a mistake in the query
+and is reported as one, with the fields there are.
+
+## Comparing builds
+
+```
+e5r diff OLD NEW [--all]
+```
+
+Functions are matched across the two builds by a fingerprint of their
+instruction shape rather than by address, so the match survives a relink.
+Matched, changed, added and removed are reported with a similarity score.
+Given a vulnerable build and a patched one, this points at the change.
+
+## Naming a stripped binary
+
+```
+e5r sig FILE create -o LIBRARY a signature for every named function
+e5r sig FILE apply LIBRARY name what the library recognizes
+```
+
+A signature identifies a function by its content rather than its address, so a
+library built from a binary with symbols names the same code in one without
+them. It refuses every coin flip: two signatures that disagree about a hash
+name nothing, a short function is not identified by shape alone, and import
+thunks are excluded because they differ only in an offset.
+
+The file is sorted text, so a signature library reviews in a diff.
+
+## An interactive session
+
+```
+$ e5r repl prog
+38 function(s) in prog. `help` lists the commands, `quit` leaves.
+0x400144> f
+0x400144> seek parse_header
+0x400180> dec
+0x400180> q 'calls to "memcpy" where arg3 is not bounded'
+0x400180> quit
+```
+
+The commands are the ones above, spelled the same way and without the file, so
+nothing has to be learned twice. `seek` moves, and the commands that take an
+address use where you are when given none. Short forms exist only for the ones
+typed a hundred times an hour: `d` `dec` `f` `i` `s` `x` `q` `?`.
+
+The point is not saved typing. Analyzing a large binary takes seconds, and a
+session pays it once: five `funcs` on libcrypto is 1.26s as five invocations
+and 0.63s in one session, and the gap widens with the binary.
+
+A session runs the same code the command line does, so the two cannot disagree
+about what a command means.
+
+## Telling it what you know
+
+An assertion is not a note on the side: it changes the analysis. Decompile,
+see what the engine could not work out, say what it is, decompile again.
+
+```
+$ e5r decompile prog nestedoffset
+uint64_t nestedoffset(uint64_t arg0, uint64_t arg1, uint64_t arg2)
+{
+ return (uint64_t)((uint64_t)(uint32_t)*(uint32_t *)(arg0 +
+ ((int64_t)(int32_t)((uint32_t)arg1 + (uint32_t)arg2) << 2) + 12));
+}
+
+$ e5r annotate prog type nestedoffset \
+ "int nestedoffset(struct outer { int header; int pad; int array[8]; } *ptr, int a, int b)"
+
+$ e5r decompile prog nestedoffset
+struct outer { int32_t header; int32_t pad; int32_t array[8]; };
+
+int32_t nestedoffset(struct outer *ptr, int32_t a, int32_t b)
+{
+ return (int32_t)((uint64_t)(uint32_t)*(uint32_t *)((uint64_t)ptr +
+ ((int64_t)(int32_t)((uint32_t)a + (uint32_t)b) << 2) + 12));
+}
+```
+
+The binary above has no debug information at all. The declaration is refused
+if it does not parse, so a typo is caught when it is typed.
+
+An assertion outranks what the engine recovered, and where the two disagree
+the disagreement is recorded rather than hidden: declare two parameters where
+the code reads four argument registers and `--json` reports the other two as
+conflicts. That is the point of the strength ladder, not a caveat on it.
+
+`--json` also carries every variable with its name, type, size, role and
+where the machine kept it, so a program driving this can see what there is to
+assert:
+
+```
+$ e5r decompile prog nestedoffset --json
+ ...
+ "asserted": true,
+ "variables": [
+ { "name": "ptr", "type": "struct outer *", "role": "parameter", "storage": "register+56" },
+ { "name": "a", "type": "int32_t", "role": "parameter", "storage": "register+48" },
+ { "name": "b", "type": "int32_t", "role": "parameter", "storage": "register+16" }
+ ]
+```
+
+## Annotations
+
+```
+e5r annotate FILE name TARGET NAME
+e5r annotate FILE comment TARGET TEXT
+e5r annotate FILE list
+e5r annotate FILE undo
+```
+
+Annotations are written to a log beside the binary, keyed to a content anchor
+rather than an address, so the work survives a rebuild. The log is text sorted
+by that anchor, so two analysts on separate branches merge without conflict
+markers. Add this to `.gitattributes`:
+
+```
+*.e5r merge=union
+```
+
+## Containers that are not one program
+
+An `ar` archive holds objects rather than being one, so it is listed rather
+than loaded: picking a member silently would make every later answer about
+bytes you did not choose.
+
+```
+e5r archive libfoo.a # members, sizes and offsets
+e5r archive libfoo.a --symbols # and what each one defines
+```
+
+GNU and BSD archives, long names, both symbol index forms, and thin archives.
+
+```
+e5r overlay firmware.exe
+```
+
+reports where the headers stop describing the file, what is appended past that
+point, and the entropy of every section with a peak per 4 KB window, which is
+how a packed region inside an ordinary-looking section shows up.
+
+Findings carry a strength and never a verdict. A section that is writable and
+executable is a fact; what put it there is not. A packer is named only where a
+section carries that packer's own signature.
+
+## Patching
+
+An edit is anchored to the code around it and carries the bytes it expects to
+find, so a patch written against one build still lands on the right
+instruction in the next one, and says how it found it.
+
+```
+e5r patch prog record 0x401234 --asm "nop" --note "skip the check" -o fix.e5r-patch
+e5r patch prog record 0x401234 --bytes 90909090 -o fix.e5r-patch
+e5r patch prog record 0x401234 --asm "mov eax, 1" --pad-to 8 -o fix.e5r-patch
+e5r patch prog preview fix.e5r-patch # where it lands, what it overwrites
+e5r patch prog apply fix.e5r-patch -o prog.patched
+e5r patch prog.patched revert fix.e5r-patch -o prog
+e5r patch prog merge a.e5r-patch b.e5r-patch -o both.e5r-patch
+```
+
+`--asm` assembles at the target address, because a branch encodes a
+displacement from where it sits. It accepts what `e5r disas` prints, so a
+line can be copied out, changed, and assembled back. `--pad-to` fills the rest
+with no-ops: an instruction that encodes shorter than the one it replaces
+would otherwise leave the bytes after it meaning something they did not mean.
+
+A set applies as a whole or not at all. Overlapping edits are a conflict
+rather than an order-dependent result, and an edit is always the same length
+as what it replaces, because a different length would move every byte after it
+and invalidate the rest of the set.
+
+`apply` refuses by default when an edit resolved on its address alone, since
+that means the code it was written against is no longer there; `--allow-address-only`
+overrides. `revert` does not refuse, because a patched binary no longer holds
+the bytes the anchor fingerprinted, which is exactly what applying it did.
+
+## Saving a session
+
+```
+e5r project new prog -o prog.e5r-proj --set scan_gaps=true
+e5r project add prog.e5r-proj --signatures libc.e5r-sig --patch fix.e5r-patch
+e5r project verify prog.e5r-proj
+e5r project show prog.e5r-proj
+```
+
+A project names its binary by content as well as by path. Opening it against a
+rebuilt binary says so rather than answering about bytes that are not there,
+and a binary that only moved is still the right one.
+
+## Driving it from a program
+
+Every command takes `--json`. The schema is named in every document
+(`"schema": "e5r/1"`) and addresses are hex strings so nothing is lost to a
+float.
+
+## Shell integration
+
+```
+e5r completions bash > /etc/bash_completion.d/e5r
+e5r completions zsh > ~/.zsh/completions/_e5r
+e5r completions fish > ~/.config/fish/completions/e5r.fish
+e5r manpage > /usr/local/share/man/man1/e5r.1
+```
+
+Both are generated from the command tree, so they cannot describe a command
+that does not exist.
+
+## Common options
+
+| Option | Effect |
+| --- | --- |
+| `--json` | Print the same information as JSON. |
+| `--base ADDR` | Load a raw image, or rebase a relocatable one, at an address. |
+| `--arch NAME` | The architecture of a raw image, which has no header to say. |
+| `--threads N` | How much of the analysis to run in parallel. The output does not depend on this. |
+| `--color WHEN` | `auto` (a terminal only), `always`, or `never`. `NO_COLOR` is honoured. |
+| `--no-pager` | Do not page, even when a terminal is reading. `PAGER` and `E5R_PAGER` choose the pager. |
+| `--budget SECONDS` | Stop after this long and report what was finished. |
+| `--limit N` | Stop after this many items and report what was finished. |
+| `--progress` | Show a counter on stderr. Silent when stderr is not a terminal. |
+
+A job that runs out of budget prints why on stderr and how much is left, and
+the output it already produced is still a valid document:
+
+```
+$ e5r decompile libstdc++.so.6 all --budget 3 > partial.c
+e5r: out of time after 3.5s and 1024 of 5609 function(s); 4585 not done.
+Raise --budget or --limit, or narrow the target.
+```
+
+Colour, paging and width-fitting happen only when a person is reading. Piped
+output carries no escape sequences and is never truncated, so a listing taken
+today diffs against the same listing taken a week ago.
+
+## What is not built yet
+
+SLEIGH languages, i386, Windows-specific entry points, an assembler, and an
+interactive session. `ROADMAP.md` is the list, and it says what each milestone
+is and what it is measured by.
diff --git a/README.md b/README.md
index 2479b85..73cc036 100644
--- a/README.md
+++ b/README.md
@@ -1,69 +1,346 @@
-# MyDisassembler
+
+
+
+
+
+
-Welcome to MyDisassembler, a project designed to help learners understand X86-64 machine codes, assembly language, ELF format, and disassembly strategies. With this tool, we can explore the intricacies of low-level programming and gain insights into how binaries are structured and executed.
+
The Reverse Engineering Toolkit for AI Agents
-You might find [our memo](MEMO.md) helpful if you're new to X86-64 and assembly (like us).
+**e5r** is a disassembler, decompiler and binary differ with a command line as
+its only front end. Every command speaks JSON, every recovered fact carries the
+evidence for it, and every name, type and comment an agent writes lands in a
+git-mergeable log. One static Rust binary: no JVM, no project server, no
+proprietary database.
-## Install
+
+
+
+ Agent-native
+ JSON on every command, and analysis an agent can correct
+
+
+ Evidence, not assertions
+ Every gate measured against an outside oracle, including a processor
+
-To get started with MyDisassembler, follow these simple steps:
+**Let agents read binaries the way a human does with IDA — and write what they
+learn back into the repository.**
-- Clone the Repository:
+```bash
+# Look at a binary.
+e5r info ./a.out # container, architecture, entry, what the loader noticed
+e5r funcs ./a.out # every function, with the evidence for each boundary
+e5r disas ./a.out main # one function, or a range, or everything
+e5r decompile ./a.out main # pseudo-C, with a goto where the shape is not there
+
+# Ask questions instead of reading output.
+e5r xrefs ./a.out 0x4006e8 # who reaches this, and how
+e5r strings ./a.out # ASCII, UTF-8 and UTF-16LE, by section
+e5r classes ./a.out # C++ hierarchy from the vtables and the RTTI
+e5r query ./a.out 'functions where insns > 100 and name ~ "crypt"'
+
+# Write what you worked out, and merge it like code.
+e5r annotate ./a.out name 0x4006e8 parse_header
+e5r annotate ./a.out type 0x4006e8 "int parse_header(struct hdr *h, size_t n)"
+e5r decompile ./a.out parse_header # the assertion reaches the output
+
+# Drive it from an agent.
+e5r batch ./a.out --command funcs --command strings # several commands, one document
+e5r funcs ./a.out --json # every command takes it
+```
+
+---
+
+## 1. Install
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/h5i-dev/e5r/main/install.sh | sh
+# cargo install --path crates/e5r-cli # build from source
+```
+
+One binary, no runtime dependency. The script works out the platform, verifies
+the download against the release's `SHA256SUMS`, and refuses to install if it
+does not match. [`MANUAL.md`](MANUAL.md) has the environment variables.
+
+```bash
+e5r completions bash > /etc/bash_completion.d/e5r # or zsh, fish, elvish
+e5r manpage > ~/.local/share/man/man1/e5r.1
+```
+
+---
+
+## 2. Use it
+
+### 2.1. Read a binary
+
+Everything e5r recovers carries the evidence for it, and the strength of that
+evidence is printed next to it, so a boundary from an `.eh_frame` record and one
+from a prologue pattern are never the same claim:
+
+```bash
+e5r info ./a.out # container, architecture, entry, warnings
+e5r sections ./a.out # and how each maps into memory
+e5r funcs ./a.out # address, size, blocks, strength, evidence, name
+e5r disas ./a.out main # a function, an address, a range, or `all`
+e5r stats ./a.out --json # counts, for a script rather than a reader
+```
+
+| Strength | Meaning |
+| --- | --- |
+| `asserted` | A person or an agent wrote it in the annotation log. |
+| `proven` | The file says so: a symbol table, debug information, an unwind record. |
+| `inferred` | The code implies it: a call target, a jump table, an import thunk. |
+| `heuristic` | A pattern suggests it: a prologue, a sweep, a pointer in data. |
+
+Where a thing cannot be worked out it is reported as unknown, never guessed.
+
+### 2.2. Decompile
+
+```bash
+e5r decompile ./a.out main # pseudo-C
+e5r decompile ./a.out all --json # every complete function, with a position map
+e5r shapes ./a.out parse_header # what the pointers it takes appear to point at
+e5r emulate ./a.out checksum 1 2 3 # run it in the interpreter and see what comes back
+```
+
+The output says what the machine does in C's notation; it does not claim to be
+the source. Where the control flow does not fit a loop or a branch, a labelled
+`goto` appears rather than a shape that is not there, and the count is printed.
+
+**Every function that can be compiled and run is compiled and run, on every
+argument vector, and agrees with the interpreter.** That gate started at 130
+disagreeing functions and is at zero.
+
+### 2.3. Write analysis back, and merge it
+
+Names, types and comments are lines in an append-only log keyed to **content
+anchors** — a shape hash and a body hash — rather than to addresses, so they
+survive a rebuild that moves everything:
```bash
-git clone https://github.com:Koukyosyumei/MyDisassembler.git
+e5r annotate ./a.out name 0x4006e8 parse_header
+e5r annotate ./a.out comment 0x400710 "length is attacker-controlled"
+e5r annotate ./a.out list
```
-- Build the Project:
+Two analysts merge with `git merge`. Reviewing reverse engineering work becomes
+a pull request. Add `*.e5r merge=union` to `.gitattributes`: the fold ignores
+line order, so the union of two branches is the correct merge and git already
+knows how to compute it.
+
+### 2.4. Compare two builds
```bash
-cd MyDisassembler
-./script/build.sh
+e5r diff ./old ./new # which functions changed, moved, appeared, vanished
+e5r diff ./old ./new --json # and what changed inside each one
```
-## Usage
+Matching is by content, not by address, so a rebuild that shifts every function
+is not reported as a rewrite of the program.
+
+### 2.5. Patch, and say why
+
+```bash
+e5r patch ./a.out record 0x4006f0 --asm "nop" --out fix.e5rpatch
+e5r patch ./a.out preview fix.e5rpatch # what it would change
+e5r patch ./a.out apply fix.e5rpatch --out ./patched # a new file, never in place
+```
-Once installed, you can use MyDisassembler to disassemble binary files and delve into their assembly code. Here's how you can use it:
+### 2.6. Drive it from an agent
```bash
-./build/script/mydisas example/jmp.o
+e5r funcs ./a.out --json # every command takes it
+e5r batch ./a.out --command funcs --command strings
+e5r project new ./a.out --out a.e5rproj # reopen it later without reanalysing
```
-```yaml
-section: .text ----
-
-40 <_start>:
- 40: mov eax 0x00000000 ( b8 0 0 0 0 )
- 45: cmp eax 0x00 ( 83 f8 0 )
- 48: jz 4e ; relative offset = 4 ( 74 4 )
- 4a: jmp 52 ; relative offset = 6 ( eb 6 )
- 4c: jmp 40 <_start> ; relative offset = -14 ( eb f2 )
-
-4e :
- 4e: push rsp ( 54 )
- 4f: xor eax eax ( 31 c0 )
- 51: ret ( c3 )
-
-52 :
- 52: push rdi ( 57 )
- 53: xor ecx ecx ( 31 c9 )
- 55: ret ( c3 )
--------------------
-Done!
+Every command takes `--json`, and the exit codes are documented: `0` ok, `1`
+nothing found, `2` bad usage, `3` bad input. That is the whole agent interface,
+deliberately: the CLI is a thin client of the library, so anything it can do is
+a function call away, and a second protocol on top would be a second surface to
+keep in step with the first.
+
+---
+
+## 3. What works
+
+| | |
+| --- | --- |
+| Containers | ELF, PE and COFF, Mach-O (thin and fat), PDB, `ar` archives, raw images |
+| Decoders | AArch64, x86-64, i386, ARM32 and Thumb-2 — each at zero disagreements with its oracle |
+| Also | any architecture a Ghidra SLEIGH specification covers, through our own runtime |
+| Lifting | p-code-style IR and SSA for AArch64, x86-64, i386 and ARM32/Thumb |
+| Analysis | functions with provenance, control flow, jump tables, no-return propagation, cross references, strings, data regions |
+| Decompiler | expressions, types, structuring, variable naming, C++ classes from vtables and RTTI |
+| Names | Itanium C++, Rust (both schemes), MSVC |
+| Storage | git-mergeable annotation log keyed to content anchors |
+| Surfaces | CLI with JSON on every command, REPL, binary diff, patching |
+
+Scope, and what is deliberately **not** built, is in
+[`ROADMAP.md`](ROADMAP.md), which is the authority on both.
+
+---
+
+## 4. How it is tested
+
+Every correctness gate is measured against something outside this repository,
+so no number here can be produced by writing more assertions about our own
+behaviour. The ones that go against us stay in
+[`docs/scorecard.md`](docs/scorecard.md).
+
+| gate | oracle | result |
+| --- | --- | --- |
+| AArch64 decoding | `objdump -d` | 1,640,904 instructions, 0 wrong |
+| x86-64 decoding | `llvm-objdump --x86-asm-syntax=intel` | 0 wrong |
+| ARM32 and Thumb-2 | `llvm-objdump-18 -d` | 0 wrong |
+| i386 decoding | `llvm-mc`, over a swept encoding space | 235,357 encodings, 0 wrong |
+| SLEIGH decoding | `objdump` and `llvm-objdump` | 0 wrong on AArch64, x86-64 and RISC-V |
+| Lifting | **a processor**, natively and under `qemu` | every case agrees |
+| Decompiled C | **a processor**, again: compile it and run it | 0 functions disagree |
+| Function boundaries | DWARF, via `readelf` | 95,697 functions; 0.766 recall blind, 0.937 precision |
+| ELF, Mach-O, PDB loading | `readelf`, `llvm-objdump -t`, `llvm-pdbutil` | every section, symbol and record |
+| Demangling | `c++filt` | 5,953 libstdc++ names |
+| Annotation merge | `git merge` itself | two branches, clean |
+| Determinism | itself, at 1, 4 and 10 threads | 153 fixtures, identical output |
+
+A processor is the oracle wherever one can be: the lifters are checked by
+assembling a case, running it, and comparing against our own interpreter, so
+"the manual says this instruction sets the carry flag" is never the last word.
+
+Plus mutation fuzzing of every loader and decoder in the ordinary test run,
+which found the one place in the loaders that bypassed the bounds-checked
+reader, and a no-panic gate over 1.26 million inputs across every entry point
+that takes foreign bytes.
+
+---
+
+## 5. Where it stands against the others
+
+Ghidra, rizin and IDA are each better than e5r at breadth of architecture and
+at accumulated analysis lore. Two measurements, both with every command written
+down in [`docs/benchmarks.md`](docs/benchmarks.md) and
+[`docs/decbench.md`](docs/decbench.md):
+
+- **Against rizin**, on the same 16 binaries on the same machine: 21× to 3,291×
+ faster and 2.4× to 8.6× less memory. Three qualifications belong with that
+ ratio and are in the table rather than under it — including the one row e5r
+ loses.
+- **Against angr, on DecBench**, a third-party decompiler benchmark: 23.4 union
+ against angr's 37.0. That is a third behind, and the milestone this project
+ set itself is Ghidra's published 32.2, which is **not met**.
+
+The bets are on the axes where the others' design, not their effort, is the
+limit: speed, annotations in git, determinism, provenance, and an interface
+built for a program rather than for a person.
+
+---
+
+## 6. Build
+
+The checkout is release-only; see [`CLAUDE.md`](CLAUDE.md).
+
+```bash
+cargo build --release
+./scripts/build-fixtures.sh # the corpus the gates measure against
+cargo test --release --workspace
+./scripts/bench.sh
```
-## Features
+---
+
+## 7. Documentation
+
+- [MANUAL.md](MANUAL.md) / `man e5r`: the full command reference
+- [docs/tutorial.md](docs/tutorial.md): a stripped binary to a committed annotation log
+- [docs/design/](docs/design/): one document per subsystem, and why it is shaped that way
+- [docs/scorecard.md](docs/scorecard.md): every measured number, including the bad ones
+- [CONTRIBUTING.md](CONTRIBUTING.md): what a change has to measure, and the clean-room rule
+
+---
+
+## 8. FAQ
+
+
+What is e5r?
+
+A reverse engineering toolkit: it loads a binary, recovers its functions,
+disassembles and decompiles them, and lets you write names, types and comments
+back into a log you can commit. It runs locally, is written in Rust, and ships
+as one static binary.
+
+
+
+
+Why another one, when Ghidra and rizin exist?
+
+Not because they are bad. Because a few things are hard to retrofit: an
+annotation store that merges with `git merge` rather than with a project-file
+lock, output that is deterministic across thread counts, a provenance record on
+every recovered fact, and an interface designed for a program to call rather
+than for a person to click. Those are design choices rather than effort, and
+they are what e5r is betting on.
+
+
+
+
+Is the decompiler as good as Hex-Rays?
+
+No, and the gap is measured rather than estimated: 23.4 union on DecBench
+against IDA's published 47.9 — on a different corpus, which
+[`docs/decbench.md`](docs/decbench.md) is careful about. What e5r does claim is
+narrower and checked: every function it decompiles, that can be compiled and
+run, computes what the machine computes.
+
+
+
+
+What does "agent-native" actually mean here?
+
+Two things that are properties of the tool rather than a wrapper around it.
+Every command emits JSON with a schema and a documented exit code, so an agent
+drives it the same way it drives `git`. And an agent's conclusions are
+first-class input: an asserted name or type reaches the decompiled output and
+the recovered prototype, so read, conclude, correct, re-read is the normal way
+to use it rather than a feature bolted on.
+
+
+
+
+Can I trust what it tells me?
+
+Look at the evidence column, and at [`docs/scorecard.md`](docs/scorecard.md).
+"Wrong" is treated as a bug with no allowance; "not recovered" is a gap with a
+number and a floor that only rises. Where the two could be confused, the
+scorecard says so — it has a section for what is *not* measured.
+
+
+
+
+What is not built?
+
+WASM, .NET, DEX and Java class files; scripting beyond the batch language; and
+the decompiler quality needed to clear the DecBench milestone. All of it is in
+[`ROADMAP.md`](ROADMAP.md) with the reason, and none of it is claimed here.
+
+
+
+---
+
+## 9. History
-- Implemented entirely from scratch in C++
-- Supports both linear sweeping and recursive descent disassembly strategies
-- Handles most basic operations with precision
-- Capable of parsing ELF headers for deeper analysis
+e5r began as a C++ teaching disassembler for x86-64. That code is in the git
+history, and its x86-64 and ELF notes are kept at
+[`docs/x86-64-notes.md`](docs/x86-64-notes.md).
-## Future Improvements
+---
-- Expand test coverage for enhanced reliability
-- Add support for VEX Prefix
-- Incorporate additional instructions, including floating-point operations
-- Introduce support for AT&T syntax
+## 10. License
-Feel free to contribute to MyDisassembler and make it even better!
+Apache-2.0. See [LICENSE](LICENSE).
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 0000000..380abf4
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,989 @@
+# Roadmap
+
+Status: in progress, 2026-09-13. This file is the scope authority for e5r:
+what it is, what gets built in what order, and what will be refused in review
+rather than argued about again.
+
+## Where it stands
+
+| Part | State |
+| --- | --- |
+| workspace, guards, fixtures, CI | built (M0) |
+| ELF, PE and COFF, Mach-O, raw | built (M1) |
+| AArch64 decoder | built, objdump parity over 395K fixture instructions, 99.67% decoded |
+| x86-64 decoder | built, llvm-objdump parity over 4,760 instructions, 100% decoded |
+| SLEIGH runtime and compiler | not started (M2) |
+| i386, ARM32 and Thumb-2 decoders | not started (M2) |
+| functions, CFG, xrefs, strings, jump tables, no-return | built and parallel (M3) |
+| IR, lifters, interpreter, SSA, dataflow | built for AArch64 and x86-64, gated against real execution (M4) |
+| stack promotion, ABI model, algebraic rules | built (M4) |
+| decompiler to pseudo-C | built, output gated on compiling (M6) |
+| type model, DWARF, PDB | not started (M5) |
+| demanglers | Itanium, Rust both schemes, MSVC names (M5) |
+| annotation log, content anchors, git merge | built (M7) |
+| CLI with JSON on every command | built (M8) |
+| binary diff | built (M10) |
+| patch, signatures, emulation, queries | not started (M10) |
+| benchmarks and scorecard | built (M11) |
+| fuzzing, coverage and mutation tooling | built (M12); packaging not started |
+
+The measured numbers live in [`docs/scorecard.md`](docs/scorecard.md),
+including the ones that go against us. In short: `libc.so.6` analyzes in 0.10s
+and 58 MB, finding 3,517 functions with 95.3% analyzed completely, where
+`objdump -d` takes 0.26s and only disassembles; both decoders are at zero
+disagreements with their oracle; memory use is about ten times objdump's, which
+is the axis e5r is worse on. 164 tests, clippy clean.
+
+e5r is a reverse engineering toolkit with a command line as its only front end.
+It loads a binary, recovers functions, disassembles, lifts to an IR, decompiles
+to C, diffs two builds, and writes an analyst's findings to a text log that git
+can merge. One static Rust binary, no JVM, no project server, no proprietary
+database.
+
+The previous contents of this repository were a C++ teaching disassembler for
+x86-64. It is gone from the tree as of M0 and stays readable in git history. The
+x86-64 and ELF notes in `MEMO.md` survive the rewrite and move under `docs/`.
+
+## The bets
+
+Ghidra, rizin and IDA are each better than e5r will be for years at breadth of
+architecture and at accumulated analysis lore. Beating them means picking the
+axes where their design, not their effort, is the limit.
+
+1. **Startup and throughput:** Ghidra pays JVM startup and a project import
+ before it shows a function. rizin is single-threaded through most of its
+ analysis. e5r memory-maps the image, analyzes functions in parallel, and
+ analyzes a function only when something asks for it. The target is first
+ useful output in under 200 ms on a 100 MB binary, and full analysis of
+ `/bin/bash` faster than `rizin -A` by a factor we publish and regression-test.
+
+2. **Annotations in git, not in a database:** Every name, type and comment an
+ analyst writes is one line in an append-only log, keyed to a content anchor
+ that survives a rebuild and a rebase. Two analysts working on branches merge
+ with `git merge` and no conflict markers, because the log folds to the same
+ state regardless of line order. Review of reverse engineering work becomes a
+ pull request. Ghidra needs a server for this and IDA needs a plugin.
+
+3. **Determinism:** Same bytes, same output, any thread count, any machine. This
+ is what makes the other features possible: diffing two builds, gating a CI
+ job on an analysis result, and reproducing a bug report all need the tool to
+ stop guessing differently on Tuesday.
+
+4. **Proven, inferred, asserted:** Every fact the engine reports carries which of
+ the three it is and what evidence produced it. A function boundary from an
+ `.eh_frame` FDE is not the same claim as one from a prologue pattern, and the
+ output says so. The incumbents flatten both into the same listing.
+
+5. **Built for automation first:** Structured JSON on every command, stable exit
+ codes, and a library API that the CLI is a thin client of. Not
+ a headless mode retrofitted onto a GUI.
+
+6. **Breadth through SLEIGH, depth by hand:** Writing 40 instruction decoders is
+ not a good use of the next two years. e5r ships a Rust SLEIGH runtime and
+ compiler so Ghidra's processor specifications load directly, plus
+ hand-written decoders for x86-64 and AArch64 where speed matters. Kuna proved
+ the SLEIGH port is tractable.
+
+## Architecture
+
+The library owns every fact. The CLI and any future UI compute
+nothing of their own.
+
+```
+e5r-core addresses, spaces, memory map, ids, error model, provenance
+e5r-format ELF, PE/COFF, Mach-O, raw, archives, overlays
+e5r-arch decoder trait, hand-written x86/x86-64 and AArch64/ARM
+e5r-sleigh .sla runtime plus the SLEIGH compiler (breadth)
+e5r-ir p-code-style IR, SSA, dataflow framework
+e5r-analysis partition, function discovery, CFG, jump tables, xrefs, strings
+e5r-types type model, DWARF, PDB, demanglers, type archives
+e5r-decomp regions, structuring, expression rebuild, C emission
+e5r-db content anchors, the annotation log, git merge semantics
+e5r-diff function matching, build-to-build diff
+e5r-patch assembler-backed patch sets
+e5r-api stable library surface and the versioned JSON schema
+e5r-cli the e5r binary
+```
+
+Dependency edges run downward only. `e5r-cli` may depend on everything;
+`e5r-core` depends on nothing in the workspace.
+
+## Milestones
+
+### M0. Clear the ground
+
+- [x] Copy the release-only build guards from h5i (`scripts/no-debug-guard.sh`,
+ `scripts/deny-debug-build.py`) and wire the hook in
+ `.claude/settings.local.json`. This machine has 7.5 GB of RAM; a dev-profile
+ build of a workspace this size is not affordable.
+- [x] Delete `src/`, `test/`, `script/`, `CMakeLists.txt` and the C++ build. Move
+ `MEMO.md` to `docs/x86-64-notes.md`.
+- [x] Cargo workspace, edition 2024, MSRV pinned, `resolver = "3"`.
+- [x] `[profile.release]` with `panic = "unwind"`, because the fuzz targets need
+ it. Debug symbols are off: on 7.5 GB of RAM, linking with them is the
+ peak-memory step.
+- [x] Rewrite `.gitignore` for Rust; drop the Python and C++ sections.
+- [x] Rewrite `README.md` for the new tool. The old one documents a C++ teaching
+ disassembler that no longer exists.
+- [x] CI on GitHub Actions: fmt, clippy with `-D warnings`, `cargo deny`, tests.
+ CI runners are disposable, so CI builds dev profile on purpose.
+- [x] `CONTRIBUTING.md` with the clean-room rule: public specifications and
+ published papers only, no decompiled IDA or disassembled Hex-Rays.
+- [x] Test corpus. A `fixtures/` tree of small committed binaries plus a script
+ that fetches larger ones (coreutils, `libcrypto`, a Go binary, a stripped
+ C++ binary, a Rust binary, a PE from a public malware corpus, an iOS dylib).
+ Ground truth comes from DWARF where the fixture has it.
+- [x] Bench harness that records wall time, peak RSS and output hash per fixture,
+ so a regression shows up as a number and not a feeling.
+- [x] Coverage and mutation tooling, run on demand rather than in CI:
+ `cargo llvm-cov --workspace` for per-crate line coverage and
+ `cargo mutants` for the mutation score, with `mutants.toml` saying what
+ not to mutate. Neither gates yet — the floors below are not met, and a
+ floor nobody can pass is a floor everyone learns to skip — and a CI job
+ that reports a number without gating on it buys nothing: the coverage
+ run re-ran the whole suite under instrumentation for a summary line, and
+ the mutation run ended in `|| true`, so it could not fail. They belong in
+ CI on the day the floors become gates.
+
+### M1. Containers
+
+Depth-first: ELF and PE carry the workload, Mach-O follows, everything else waits.
+
+- [x] ELF32 and ELF64, both endians. Program headers, sections, `symtab`,
+ `dynsym`, GNU hash, symbol versioning, `.dynamic`, `.init_array`,
+ `.eh_frame` and `.eh_frame_hdr`, `.note.gnu.build-id`.
+- [x] ELF relocations for x86-64 and AArch64, enough to load `.o` files
+ correctly: the absolute, relative and branch kinds a compiler emits,
+ including the split immediate an `adrp` carries and the scaled
+ twelve-bit offsets. Gated by running functions out of the objects and
+ comparing against what the processor produced. i386 is still to do.
+- [x] ELF relocations for i386, enough to load `.o` files and to resolve PLT
+ entries to names. REL rather than RELA, so the addend comes out of the
+ bytes being patched; seventeen types applied and every TLS type recorded
+ as unhandled rather than skipped. 37 relocations compared against
+ `readelf` entry for entry with zero disagreements, plus 1,828 in
+ `.rel.debug_*` that were never read before.
+- [x] PE and COFF. Both image and object layouts, import and delay-import
+ descriptors with their IAT slots, exports, the COFF symbol table, the
+ debug directory's PDB path, and the exception directory
+ (`RUNTIME_FUNCTION` unwind data, the best function-boundary oracle
+ Windows offers). Base relocations, resources and load config are still
+ to do.
+- [x] Mach-O, thin and fat, with `LC_FUNCTION_STARTS` for boundaries, the
+ symbol table, dylib dependencies and the UUID. Chained fixups and the
+ exports trie are still to do, as is the dyld shared cache.
+- [x] Raw blob loading with an explicit base, architecture and entry point, plus
+ Intel HEX and S-record, for firmware work.
+- [x] `ar` archives and loose object files. GNU and BSD flavours, long names,
+ both symbol index forms and thin archives, measured against `ar t` and
+ `nm -s`. An archive is opened rather than loaded: it has no architecture,
+ entry point or memory map, so making one an `Object` would mean picking a
+ member and making every later answer about bytes the caller never chose.
+- [x] Overlay detection, section entropy map, and a packer heuristic that reports
+ a suspicion with its evidence rather than a verdict. Nine finding kinds,
+ each with a strength; a packer is named only where a section carries that
+ packer's own signature. The false-positive gate matters as much as the
+ true positives: fourteen ordinary binaries, seven linked and seven
+ relocatable, produce no overlay and no finding at `Inferred` or above.
+- [x] Every loader treats its input as hostile. A malformed header returns a typed
+ error; it never panics and never allocates from an attacker-controlled count.
+- [ ] Later, behind a feature flag: WASM, .NET metadata, DEX, Java class files.
+
+### M2. Instruction decoding
+
+- [x] x86-64 decoder, table-driven in the Intel manual's own operand notation
+ so an entry can be diffed against appendix A. Legacy prefixes, REX, the
+ one-byte, two-byte and three-byte maps, mandatory prefixes, groups, and
+ SSE through SSE4.2. VEX, EVEX and AVX are not written; 32-bit mode is
+ not supported, because the opcodes it spends on inc/dec and the BCD
+ instructions are REX prefixes in long mode.
+- [x] AArch64 A64 decoder including Advanced SIMD. SVE is deliberately not
+ decoded: it is a separate architecture's worth of encodings and the
+ coverage number records its absence.
+- [x] ARM32 and Thumb-2, including interworking and the IT block. 3,568
+ instructions over twelve fixture objects against `llvm-objdump-18`, 0
+ wrong and 0 undecoded, after a development sweep of roughly 260,000
+ random and strided encodings that found about 15,000 disagreements.
+ NEON, the parallel arithmetic, the saturating and packing instructions
+ and the exception-return transfers are declined rather than guessed.
+- [x] i386, the 32-bit mode of the x86 decoder. The mode rather than the
+ opcodes, since the table is shared: no REX, so 0x40-0x4f are `inc` and
+ `dec`; default operand and address size 32; ModRM mod=00 rm=101 is an
+ absolute displacement rather than rip-relative; eight registers; the
+ instructions valid only in 32-bit mode and the ones invalid in it.
+ Measured against `llvm-mc` and `llvm-objdump` over a sweep of 235,357
+ candidates, every one- and two-byte opcode crossed with prefix strings
+ and ModRM shapes, both three-byte maps, all 256 ModRM values of all eight
+ x87 escapes, and 200,000 random byte piles: 0 wrong, 99.711% decoded.
+ 100.000% over the compiled fixtures. What it declines is what llvm
+ declines because it faults on hardware, and what it decodes where llvm
+ declines is listed with the reason.
+- [x] SLEIGH runtime. The `.sla` format is worked out in
+ [`docs/sla-format.md`](../docs/sla-format.md) and its reader consumes all
+ 137 files Ghidra ships. The decode engine measures 0 wrong against
+ objdump on AArch64 (19,710), x86-64 (5,688) and RISC-V 64 (2,567), the
+ last being an architecture this tool could not decode at all and for
+ which no code was written: the language definition is data. Sixteen more
+ decode noise without panic. P-code lifts at 100% for RISC-V, 99.8% for
+ AArch64 and 98.5% for x86-64, and every gap is SLEIGH's user-defined
+ operation, which this IR has no opcode for.
+- [x] The `.slaspec` front end: all 152 language definitions Ghidra ships
+ parse, none fail, 133,097 constructors in two seconds, with the bit
+ patterns reduced to masks that are honest about what they cannot pin
+ down. 24 real encodings across ten architectures select their own
+ constructor and no other.
+- [x] SLEIGH compiler: `.slaspec` to `.sla`. Partial, and the number says how
+ partial: all 152 definitions compile and read back with a model that
+ agrees with the source, and a file we wrote decodes 20,000 encodings
+ identically to the parsed source on RISC-V and x86-64 and all but 8 on
+ AArch64. Against Ghidra's own compiler, 2 of 152 match byte for byte,
+ because the payload is 43% of the reference's: the missing 57% is p-code
+ templates, whose allocation rule is not established and so is not
+ guessed. Where the structure is established, 150 of 152 agree on the
+ space table and 148 on the constructor count.
+- [x] Assembler for x86-64 and AArch64, needed by M10 patching. Encoding only,
+ from public manuals, built around the decoder so it accepts our own
+ disassembly verbatim: a user can copy a line out of `e5r disas`, change
+ it, and assemble it back. Round trip over the whole fixture corpus, 0
+ wrong on both architectures; byte-identical to `as` and
+ `aarch64-linux-gnu-as` on every one of 2,535 and 1,272 comparable forms.
+ A branch whose target does not fit is a typed error naming the range,
+ never a truncated displacement. SIMD and floating point are refused by
+ name rather than guessed.
+- [x] Text formatting for each architecture, with Intel and AT&T syntax for x86
+ and a formatter trait so a caller can render its own.
+
+### M3. Program model and partition
+
+- [x] Address spaces and a memory map that survives overlays, `bss`, and a raw
+ blob loaded at an arbitrary base.
+- [x] Function discovery, layered by evidence quality: symbol table, `.eh_frame`
+ FDEs, PE unwind records, `LC_FUNCTION_STARTS`, Go `pclntab`, ObjC method
+ lists, Swift metadata, call targets found by recursive descent, then
+ prologue scanning as the last resort. Each layer tags its provenance.
+- [x] Recursive descent with a linear sweep fallback over the gaps, with the two
+ reconciled rather than concatenated.
+- [x] Basic blocks, CFG, tail-call detection, and no-return propagation
+ through the call graph. Each block records why it ended, which is what
+ distinguishes a function that returns from one that only ever traps.
+- [x] Jump table recovery for the absolute, base-relative and entry-relative
+ forms, on both architectures. The bound comes from the compare that
+ guards the switch, matched to the register the table is indexed by; a
+ compact table of byte or halfword offsets is refused outright when that
+ compare cannot be found, because every value in such a table yields a
+ plausible target and scanning cannot honestly bound it. MSVC's two-level
+ tables are still to do.
+- [x] Cross references: code to code, code to data, with the reference type
+ recorded, including the AArch64 `adrp`/`add` pair. Data to data is still
+ to do.
+- [x] String extraction: ASCII, UTF-8 and UTF-16LE, scanned by section rather
+ than by segment. Go string headers and Rust slices are still to do.
+- [x] Data flow into the data sections: pointers, vtables, jump tables and
+ literal pools marked as data so the code partition stops at them. Five
+ proofs, each something observed rather than inferred; a candidate
+ overlapping a block some function walked is dropped. 55,577 regions over
+ 154 images, 8.72% of the bytes the gap scan sweeps. It changed no
+ function anywhere -- no word inside a marked region matches a prologue
+ pattern -- so it is a guard measured at zero firings, gated by a
+ hand-built image whose literal-pool word *is* `stp x29, x30, [sp, #-16]!`.
+- [x] A jump table whose entries are bytes scaled from a separate `adr`
+ anchor, which is what gcc emits for a small dense switch at `-O1` and
+ `-Os` on AArch64:
+
+ ```
+ ldrb w1, [x1, w3, uxtw] ; a one-byte entry
+ adr x3, ; not the table's own address
+ add x1, x3, w1, sxtb #2 ; sign extended and scaled by four
+ br x1
+ ```
+
+ Recovery models a four-byte offset from the table's own base and not
+ this, so `pick` in `em-paths.a64.O1` stays incomplete while the same
+ source at `-O2` recovers. Found by the emulation gate, which confirms a
+ recovered table by running the branch for every index: a table that is
+ not recovered at all is the one thing that gate cannot confirm. The
+ `ldrh` form, for a switch too large for a byte, is the same shape.
+- [x] PLT thunk resolution from the relocation table, so a call through one
+ prints the imported name. GOT and IAT still to do for the indirect
+ forms.
+- [x] Windows-specific entry points, which are how a PE runs code the entry
+ point never reaches. TLS callbacks, the `.pdata` exception directory with
+ the `.xdata` unwind info behind it (frame layout, handler, chained
+ parent, and the ARM64 packed form), SEH scope tables, base relocations,
+ and the load config's Control Flow Guard and SafeSEH tables, which are
+ linker-built lists of real entry points the loader enforces and so carry
+ their own evidence kind. Measured against `llvm-readobj` entry for entry
+ over four real linked PE images, x64 and arm64 at O0 and O2, with zero
+ disagreements. Still to do: the resource directory, the 32-bit `fs:[0]`
+ handler chain, which is code recognition rather than a directory, and the
+ `.CRT$XC*` initializer tables, which are a section-name convention.
+- [x] Parallel analysis with deterministic output. Functions are independent
+ units; the work queue order must not reach the result.
+
+### M4. IR and dataflow
+
+- [x] A p-code-style IR with explicit varnodes and address spaces. Operations
+ have no implicit effects, so an instruction that sets four flags becomes
+ four operations; the register file is byte-addressed, so `w0` overlapping
+ `x0` is a property of the addresses rather than a rule. AArch64 lifts
+ 99.52% of the instructions inside recovered functions and x86-64 99.83%,
+ including the integer SIMD of both and floating point.
+- [x] x86-64 lifting with every flag each instruction writes, the wide multiply
+ and divide its one-operand forms need, and SSE. Gated by running the same
+ program on both architectures — natively here, under qemu for the other —
+ and comparing the recorded answers against the interpreted lifting: 59
+ calls across five optimization levels on each architecture, floating
+ point compared as the bits the machine produced.
+- [x] SSA construction over the IR, with partial register writes made explicit
+ rather than papered over: locations are canonical whole registers, a
+ narrow read becomes a `SubPiece` and a narrow write a masked merge, so
+ `w0` sitting inside `x0` is a dependency dataflow can see.
+- [x] Stack depth tracked through a function, so an access at a known offset
+ from the entry stack pointer is recognized whichever block it is in.
+ `alloca` and dynamic frames make the depth unknown, which the analysis
+ reports rather than guesses.
+- [x] Memory promotion, turning stack slots into variables where aliasing
+ allows. It refuses when any stack address is used as a value or when two
+ slots overlap without being identical.
+- [x] Constant folding, copy propagation and dead code elimination, each run to
+ a fixed point. On the fixture corpus they take 7,726 lifted operations to
+ 915, which is what turns IR into something a person can read.
+- [x] An algebraic rule pool: known-bits simplification, which collapses the
+ sub-register merges partial writes produce; local common-subexpression
+ elimination; and pattern rules that turn flag algebra back into the
+ comparison it stands for.
+- [x] The default calling convention per architecture, which dead code
+ elimination needs to know what the caller reads after a return and the
+ decompiler needs to name arguments. Per-function detection of the ones a
+ compiler invents is still to do.
+- [x] Value-set or range analysis, enough to bound a jump table index and to
+ prove a comparison constant. Interval domain with widening over eight
+ rounds; 117 results checked, 62 of them bounded.
+- [x] Calling convention detection per function. Four answers rather than two:
+ standard, standard with fewer arguments where the gap in the register
+ order is the evidence, non-standard with the departures named by
+ register, and unknown where the lifter did not model everything, because
+ an unmodelled instruction's reads are not arguments. Gated against
+ `DW_TAG_call_site_parameter`, which is what the compiler knew: 33 of 37
+ recorded argument registers found.
+- [x] Prototype recovery: parameter count, storage, return value, varargs.
+ 186 prototypes recovered with no argument lost.
+- [x] Feedback edges. A prototype learned late re-runs the callers' dataflow. The
+ schedule is explicit and budgeted, not a `while (changed)` around everything.
+ Callers also settle whether a function returns at all.
+- [x] An IR interpreter, landed with the lifter as planned. Ten semantic tests
+ run real compiled functions through it and compare against the answer
+ computed independently in Rust: arithmetic at every width, signed and
+ unsigned division including division by zero, widening, all seven
+ comparisons, conditional selection, shifts and rotates, nested loops,
+ and a memory-summing loop. It found two lifter bugs that reading could
+ not have: signed overflow was always false at 64-bit width, and the
+ two-operand conditional-select aliases had their condition inverted,
+ which turned an absolute value into a negation.
+
+### M5. Types
+
+- [x] A C type model with structures, unions, arrays, enums, function pointers
+ and typedefs, which prints a declaration the way C spells it: the
+ declarator wraps around the name, so a pointer to an array is not a
+ prefix and a suffix glued together.
+- [x] DWARF 4 and 5 consumption: the type graph, function signatures with
+ named parameters, local variables with frame offsets, and the line
+ table. Relocations are applied to the debug sections first, because a
+ relocatable object writes zero where an address goes. Measured against
+ readelf for names and against the object's own symbol table for
+ addresses.
+- [x] Structure recovery from access patterns: the offsets touched through a
+ pointer become fields, loop-carried pointers are followed through their
+ phi so a walk reports its element size, and the result is what was seen
+ rather than a conclusion. Gated against the debug information: the
+ recovery runs without looking at it and has to agree.
+- [x] A C type model with structures, unions, arrays, enums, function pointers,
+ bitfields and typedefs, sized per architecture, and a parser that reads
+ a declaration into it.
+- [x] DWARF inlined frames, call sites and location lists, compared against
+ `readelf` entry for entry: 39 inlined frames with their ranges and call
+ lines, 16 call sites by argument count, 198 variables with location
+ lists compared range for range. 230 variables in the fixtures change
+ storage inside their own body, which a single-location reader gets
+ wrong for most of the function.
+- [x] PDB consumption for Windows binaries, from the public format
+ documentation. Measured against `llvm-pdbutil` record by record on real
+ databases built here by clang and lld: 14 procedures, 27 public symbols,
+ 24 frame-relative locals, 33 register locations by range and register
+ name, 9 inlined frames, 25 section contributions. It fills the same
+ structures the DWARF reader does, so nothing above that layer knows
+ which format the information came from, and a database whose identity
+ does not match the image is refused with a note rather than believed.
+- [x] Demanglers: Itanium C++ at 94% exact parity with `c++filt` over 5,953
+ real libstdc++ symbols, Rust in both schemes, and MSVC qualified names.
+ Swift and ObjC selectors are still to do, and MSVC's type grammar is a
+ separate job from its names.
+- [x] Type archives. A whole translation unit parses into the type model: all 61
+ declarations of `elf.h`, with `Elf64_Ehdr` laid out at 64 bytes checked
+ against the specification rather than against a compiler, and all 91 of
+ a preprocessed `stdint.h`. Importing Ghidra `.gdt` is still to do.
+- [x] Structure recovery fed back into the decompiler. A declared structure
+ turns an offset into the declared field name, and an inferred one turns
+ it into `field_`, which is this crate saying what is at that
+ offset and cannot be mistaken for something a person wrote.
+- [x] C++ vtable recovery, by symbol and by scanning, reported with what each
+ rests on, and gated against a fixture whose virtual dispatch is run in
+ the interpreter and compared against hardware.
+- [x] C++ recovery beyond the tables: RTTI where present, constructor and
+ destructor identification, `this` pointer typing. Both the Itanium and
+ Microsoft layouts. Over libstdc++, RTTI names 56 tables no symbol named,
+ and with symbols cleared it names 156 of 211 that otherwise had only an
+ address. `-fno-rtti` degrades to the tables alone and says so. It found
+ a defect no fixture could: the constructor rule accepted a vtable-pointer
+ store at any offset, so a class that sets a member's pointer was read as
+ that member's constructor, 114 wrong classes in 335 claims; restricted to
+ offset zero it is 221 claims and none wrong.
+- [x] Go: `pclntab` function names and `moduledata`. 1,299 of 1,299 function
+ names recovered from a stripped Go binary. Interface tables and the
+ runtime type descriptors are still to do.
+- [x] Rust: the metadata that exists, which is less than people expect. Panic
+ location strings carry file and line and are worth mining.
+- [x] ObjC class and method lists with selectors, and Swift nominal type
+ descriptors, field descriptors and protocol conformances. The Swift side
+ is measured against fixtures written from the published ABI, not against
+ compiler output, because there is no Swift toolchain here; that is a
+ weaker claim than the DWARF and Go gates and the scorecard says so.
+
+### M6. Decompiler
+
+- [x] Region identification over the CFG, producing a region tree distinct from
+ the block graph: sequences, two-way branches joined at their immediate
+ post-dominator, natural loops from back edges, and a labelled goto where
+ the graph has no such shape.
+- [x] Structuring into if, while and the loop forms, with break and continue
+ taken from the loop nesting and the goto count reported as the quality
+ signal. Switch recovery and the SAILR edit-and-measure loop are still to
+ do.
+- [x] Expression rebuilding from SSA, with operator precedence and cast
+ insertion that is correct rather than pretty, and the reinterpretations
+ between a value's bits and the number they stand for written down
+ wherever the two disagree.
+- [x] Variable naming: a value read more than once or produced by a phi becomes
+ a named local, a promoted stack slot becomes a named variable, and
+ arguments are named from the calling convention. Merging variables that
+ share storage across their live ranges is still to do.
+- [x] C emission carries every variable with its name, type, size, role and
+ where the machine kept it, which is what a consumer needs to match
+ against what the source declared. A token-level position map, so the CLI
+ can highlight and slice, is still to do.
+- [ ] Port Ghidra's 89 decompiler datatests to our format before the M6 gate
+ opens. 61 cases are ported, representing roughly 48 of the 89; the rest
+ need processors we do not decode, user-applied data types we have no way
+ to attach, or assert on Ghidra's own SSA dump. 56 pass and 5 stay
+ `#[ignore]`d, each naming a real defect, so the list of known defects
+ lives in the test suite rather than in someone's head.
+- [x] Quality gates: goto density per function against a ceiling that only
+ comes down, and a recompilability check — every function recovered from
+ the fixture corpus is decompiled into one translation unit that `clang`
+ has to accept. Density is zero at O0, O1 and Os on both architectures and
+ 0.24 per function at O2 and O3.
+- [ ] M6 does not close until DecBench scores e5r above Ghidra on the
+ unoptimized set: 32.2 union, 29.3 structure. **Measured 2026-09-13: 23.4
+ union, 22.9 structure.** Not met. The gap is structuring: 1.98 gotos per
+ function against angr's 0.57, and the 124 functions with three or more
+ score zero on structure, every one of them.
+- [ ] Options are toggles, not rewrites. A user who wants low-level output and a
+ user who wants idiomatic C get the same engine with different switches.
+
+### M7. The annotation store
+
+This is the feature that distinguishes e5r from every incumbent, so it gets
+designed before it gets coded, and the design lives in
+[`docs/design/db.md`](../docs/design/db.md).
+
+- [x] Content anchors. A function is identified by a fingerprint of its
+ instruction-shape stream with branch targets excluded, so the identity
+ survives a rebase and a relink; by an exact body hash for the same-binary
+ fast path; and by its address as a tiebreak. Resolution reports which of
+ the three matched.
+- [x] Anchors for things that are not functions: a data object, an address inside
+ a function, a structure field, a call site.
+- [x] The log. One assertion per line, append-only, sorted by a sequence number
+ with a content hash as the deterministic tiebreak. A fold over the log is
+ the current state, and the fold is order-independent.
+- [x] Merge semantics. Two branches that annotate the same binary merge with git
+ alone. A conflict on the same field of the same anchor resolves by the fold
+ rule, and the tool can list which assertions lost.
+- [x] What is never stored: anything the engine can recompute. Function
+ boundaries, blocks, xrefs and types inferred from bytes stay out of the
+ file, so regenerating them cannot conflict.
+- [x] Undo and redo as operations on the log.
+- [x] Provenance on every assertion: who, when, and optionally why.
+- [x] A project file that records the binary hash, the load configuration and the
+ analysis options, and nothing else. It names the binary by content as well
+ as by path, so a rebuilt binary is refused and a moved one is a distinct,
+ usable verdict.
+- [x] Tests that run actual `git merge` on diverging annotation branches and
+ assert the folded result.
+
+### M8. The command line
+
+- [x] Non-interactive subcommands that do one thing and exit: `info`, `sections`,
+ `imports`, `exports`, `funcs`, `disas`, `decompile`, `xrefs`, `strings`,
+ `search`, `graph`, `diff`, `patch`, `annotate`, `sig`.
+- [x] `--json` on every one of them, against a schema that is versioned and
+ checked in. Breaking the schema is a major version bump.
+- [x] Stable exit codes, documented, so a script can branch on them.
+- [x] A REPL for the interactive session, with commands that read as words
+ rather than as a two-character grammar, plus short forms for the ones
+ typed a hundred times an hour. It runs the command line's own dispatch
+ against one analysis, so a session and a one-shot invocation cannot
+ disagree about what a command means, and five commands on libcrypto cost
+ 0.63s in a session against 1.26s as five invocations.
+- [x] Address expressions: symbol names, `main+0x20`, `[rip+0x10]`, section
+ relative, file offset.
+- [x] Paging, color, and a terminal-width-aware listing that stays diffable when
+ piped. All three are decided once from whether standard output is a
+ terminal, so piped output is byte-identical to what it was before they
+ existed. `NO_COLOR`, `PAGER` and `E5R_PAGER` are honoured; `--color` and
+ `--no-pager` override. JSON is never coloured or paged.
+- [x] Shell completion for bash, zsh and fish, and a generated man page, both
+ generated from the command tree so neither can describe a command that
+ does not exist.
+- [x] Progress reporting on stderr with an estimate. `--progress` covers the
+ loops the CLI drives, with a rate-based estimate, silent unless stderr is
+ a terminal. The inside of `analyze()` now reports too:
+ `Session::with_progress` takes a callback, five stages report through it,
+ the `Update` allocates nothing, and every report is made from the
+ sequential merge between parallel batches so nothing contends. Cost is
+ indistinguishable from noise.
+
+### M9. Automation and agents
+
+- [x] `e5r-api`: the library surface the CLI and the tests share, so a
+ difference between what a test checks and what a user gets is a
+ difference a user will find. Decompilation, shapes, signatures,
+ emulation and queries go through it.
+ Semver from 1.0, with a compatibility test suite.
+- [x] ~~MCP server~~. Built in M9 and **removed**: it exposed eight of the
+ twenty-nine commands, so it was never the complete agent interface it
+ was advertised as, and everything it did was already reachable through
+ `--json` and a documented exit code. A second protocol over the same
+ library is a second surface to keep in step with the first, and the
+ first one is the one that is complete.
+- [x] Cancellable jobs with a budget, so an agent that asks for the decompilation
+ of a 40,000-function binary gets partial results and a reason. `--budget`
+ in seconds and `--limit` in items, on `decompile` and `disas`. The clock
+ starts when the command does, so loading and analysis count against it,
+ and the reason goes to stderr so a partial document is still a valid
+ document.
+- [x] A batch mode that runs a script of commands and emits one JSON document,
+ each result carried with the command that produced it.
+- [ ] Scripting. Start with the batch language above; add an embedded interpreter
+ only when a real workflow needs control flow. Python via an extension
+ module is the likely answer and it is explicitly not in the first year.
+
+### M10. The differentiators
+
+- [x] Binary diff. Function matching across two builds by anchor, by name and
+ by call graph neighborhood, reporting matched, changed, added and removed
+ with a similarity score, gated on a patched build where the change is
+ known.
+- [x] Diff at instruction granularity inside a changed function, so the answer
+ is the line that changed rather than the function that contains it.
+ Patience alignment over a per-instruction shape token that leaves out
+ exactly what an insertion moves, which is the address a branch or a
+ pc-relative computation resolves to. An aligned pair whose addresses then
+ differ is either displaced, which the alignment inside the function and
+ the function matching outside it can prove, or retargeted, which is a
+ claim about the program and is never folded into the other. Gated on
+ three build pairs whose source difference is one line, with the edit list
+ asserted instruction for instruction: an operator substituted is one
+ replacement and nothing else, a statement added is four insertions and
+ one displaced branch, a statement removed is three deletions and one.
+ `_start`, which the function-level diff calls changed because every byte
+ of its calls moved, reports no change in it at all.
+- [x] Patch sets. An auditable object describing byte edits, previewed before
+ write, applied to a sibling file by default. A set applies as a whole or
+ not at all, overlapping edits are a conflict rather than an order-dependent
+ result, and an edit is always the same length as what it replaces.
+ Assembling an edit from mnemonics still waits on the assembler.
+- [x] Signature matching, in a sorted text format that reviews in a diff.
+ A library built from a binary with symbols recovers 10 of 11 names in
+ its stripped copy with none wrong. It refuses every coin flip: two
+ signatures that disagree about a hash name nothing, a function shorter
+ than twenty-four instructions is not identified by shape, and import
+ thunks are excluded because four instructions that differ only in an
+ offset match across binaries by coincidence.
+- [x] Signature matching against published libraries: a corpus built from real
+ distribution packages, so a statically linked binary stops being 8,000
+ anonymous functions. `scripts/build-siglib.sh` takes `.deb`, `.rpm` or
+ tarball packages, installed packages by name, or bare `.a` files,
+ extracts the static libraries, and turns every named function in every
+ archive member into one signature with the member it came from recorded
+ beside it. Measured on a stripped `-static` build of `hello.c` against
+ this distribution's own `libc.a`, `libm.a` and `libc_nonshared.a`: 3,228
+ signatures from 2,016 archive members recover 497 of the binary's 908
+ names, 54.7%, with 0 wrong. The oracle is the unstripped copy's symbol
+ table, read as the set of names at each address, because a linker puts
+ `strlen` and `__strlen` on the same byte and either is right.
+
+ The sentence this replaces read "an importer for the a corpus built from
+ real distribution packages": the format's name was lost in an edit, and
+ the format was IDA's FLIRT `.sig`. That importer is declined rather than
+ postponed. Hex-Rays publishes the FLAIR tools and documents the text
+ `.pat` files they emit; it does not publish the byte layout of the `.sig`
+ container, and every description of that layout in circulation is
+ somebody's reverse engineering of the tool, which CONTRIBUTING.md's
+ clean-room rule forbids reading. Even the documented `.pat` half could
+ not *match* anything without reimplementing FLIRT's own CRC16 over the
+ bytes past the leading pattern, and that polynomial is written down in
+ the same reverse engineering and nowhere else. The archives in a
+ distribution's development packages carry the same code under the same
+ names, are what a statically linked binary was built from, and need no
+ reading of anyone's format.
+- [x] Emulation, built on the M4 IR interpreter: `e5r emulate` runs a
+ function with given arguments and reports what came back and what it
+ touched. Nothing escapes the process. Gated against the processor by
+ running the oracle's case table through it.
+- [x] Emulation of selected paths, with a memory model and syscall stubs. All
+ three uses gated against a processor: a decrypted string equal to the
+ bytes the hardware produced, a masked pointer table yielding exactly its
+ three real targets after static analysis settled nothing, and five
+ recovered jump tables confirmed by running the branch for every index.
+ Nothing escapes the interpreter: the stubs are answers, not actions.
+- [x] A query language over the program model, over seven entities with
+ boolean operators, brackets and JSON on the same command. A field that
+ does not exist is a typo and says so rather than matching nothing.
+- [x] Query over dataflow facts. `calls to "memcpy" where arg3 is not bounded`
+ answers, and the value is in the negation: `unconstrained` is a claim
+ about the program, `unknown` is a claim about the analysis, and they are
+ separate verdicts never printed as each other. Every row carries a
+ strength. Still to do: interprocedural bounds, so a wrapper one level
+ deep stops hiding the answer, and relational bounds, so "bounded by the
+ size of the destination" becomes askable.
+
+### M11. Performance
+
+- [x] Published benchmark numbers against `rizin -A` on the fixture corpus,
+ rerun in CI, with a regression budget. 16 binaries, 21x to 3,291x faster
+ and 2.4x to 8.6x less memory, with the three qualifications that ratio
+ needs in the table rather than under it, and the one row e5r loses left
+ in. `docs/benchmarks.md`, `scripts/bench-budget.json`. Ghidra's headless
+ analyzer is wired and opt-in behind `GHIDRA_INSTALL_DIR`, but its
+ decompiler ships x86-64 only, so on this aarch64 host the comparison that
+ would matter most cannot be made.
+- [x] Lazy analysis. A session computes functions, cross references and strings
+ on first use, memoized, so a command no longer has to be told what to
+ switch off. `funcs` on libcrypto goes from 0.086s and 55.3 MB to 0.069s
+ and 50.7 MB.
+- [x] An on-disk analysis cache keyed by content hash, format version and the
+ option bytes each part reads. A hit is 2.7 to 3.1x; a miss costs 25 to
+ 35% more than no cache at all. A key mismatch is a miss and never a
+ partial reuse, and a damaged file is a miss with a warning, checked by
+ corrupting real entries at 1,328 positions with no wrong answer.
+- [x] Memory ceiling. Peak resident falls 47.7% on the largest binary here and a
+ 500 MB one extrapolates to 4.6 to 6.4 GB where every model before put it
+ at 8.0 to 8.7. Interning the block table by range is most of it: 8.47M
+ stored blocks were 2.69M distinct ranges, held once per function that
+ could reach them. Wall time is unchanged and is not claimed as a win.
+- [x] Profiling as a habit, with `scripts/flamegraph.sh` checked in and run
+ rather than merely written. It found that `e5r funcs` spends 12.3% of
+ its time computing content anchors for every function before printing
+ anything.
+
+### M12. Hardening and release
+
+- [x] Mutation fuzzing of every loader and decoder, running in the ordinary
+ test suite rather than behind a nightly toolchain, seeded from the real
+ corpus and bounded by time. It found a panic on its first run: a
+ five-byte file beginning with the ELF magic, where the class byte was
+ read by indexing the slice instead of through the reader. A `cargo-fuzz`
+ setup for longer runs is still to do.
+- [x] Resource caps on every attacker-controlled count, with a documented
+ policy in [`docs/design/limits.md`](../docs/design/limits.md): bound by
+ the file first, then by the cap, check the cursor advanced, and read
+ through the reader. Checked by the mutation fuzzer in the ordinary test
+ suite rather than by review.
+- [x] `#![forbid(unsafe_code)]` on every crate except `e5r-cli`, which is
+ `#![deny(unsafe_code)]` so one audited call can opt in. There is exactly
+ one exception, the memory map, and its justification is in
+ [`docs/design/limits.md`](../docs/design/limits.md).
+- [x] A no-panic gate on every path that reads foreign bytes, 1.26 million cases
+ inside a 12-second budget so it runs every time rather than nightly. It
+ asserts three things and not one: no panic, a per-case ceiling, and that
+ the object which comes back is self-consistent. It found three defects,
+ all the same mistake of adding a number the file chose to an address
+ without checking, and all three are fixed.
+- [x] Static musl builds for Linux, both architectures, built and run: 4.4 MB
+ aarch64 and 5.5 MB x86-64, no interpreter and no shared library, with
+ the x86-64 one executed under qemu and the aarch64 one producing output
+ byte-identical to the glibc build. The recipe needs no musl-gcc, no
+ `cross` and no container, because rust-lld and the self-contained crt
+ objects ship with rustup. macOS and Windows compile here and cannot be
+ linked or run here, and the workflow marks both unverified rather than
+ implying otherwise.
+- [x] Packaging. `cargo install` works and is checked by the workflow rather
+ than asserted: installed into an empty root and run from outside the
+ checkout, where the absent fixtures break nothing. `cargo package
+ --workspace` succeeds for all 14 crates. Signing is Sigstore keyless
+ through the workflow's own identity, so no secret is stored, and it
+ degrades to unsigned checksums rather than failing the release.
+ `install.sh` is the install path, and it has been run: against a real
+ archive served locally, and against a tampered checksum, a missing
+ `SHA256SUMS` and a missing archive, each of which it refuses. The draft
+ Homebrew formula it replaced had never been run at all.
+- [x] `MANUAL.md`, a design document per subsystem under `docs/design/`, and a
+ tutorial that takes a reader from a stripped binary to a named, typed,
+ committed annotation log (`docs/tutorial.md`).
+
+## Quality gates
+
+Green before every merge to main. Each one is a command, not a judgment call.
+
+| Gate | What it checks |
+| --- | --- |
+| G1 | `cargo fmt --check`, `cargo clippy --release -- -D warnings`, `cargo deny` |
+| G2 | `cargo test --release --workspace` |
+| G3 | Decoder parity against `objdump` and `iced-x86` on the fuzz corpus |
+| G4 | Function boundary precision and recall against DWARF ground truth, per fixture |
+| G5 | Determinism: same output hash at 1, 4 and 10 threads, on two runs |
+| G6 | No panic and no timeout on the malformed-input corpus |
+| G7 | Decompiler goto density and recompilability against the recorded baseline |
+| G8 | Benchmark wall time and peak RSS inside the regression budget |
+| G9 | Annotation log merge scenarios fold to the expected state |
+| G10 | Line and branch coverage at or above the floor for the crate |
+| G11 | Mutation score at or above the floor for the crate |
+| G12 | DecBench union and recompile rates at or above the last release, per dataset |
+
+## Test coverage targets
+
+Ghidra has 1,946 test files and 18,999 `@Test` methods. Copying that number is
+the wrong target and it would take a decade. Look at where those tests actually
+are: 615 files cover `Features/Base`, 399 cover the framework, and most of the
+rest are GUI and integration tests for a UI e5r does not have. The decompiler,
+which is the hardest thing in the product, has 15 Java test files, because the
+real decompiler tests are 89 XML datatests driving the C++ engine. Processors
+have 17.
+
+So the count is not the measure. Four things are, and the first of them is backed by an
+oracle outside this repository, which means its numbers cannot be padded by
+writing more assertions about our own behavior.
+
+### Oracle-backed suites
+
+| Suite | What the oracle is | Reference point | e5r target |
+| --- | --- | --- | --- |
+| Decoder parity | `objdump` and `iced-x86` on the same bytes | rizin ships 6,333 named cases and 24,625 lines of assembly vectors | every instruction in the fixture corpus, plus 10^8 randomly generated encodings per architecture, with zero unexplained disagreements |
+| Semantic lift | native execution of the same code | Ghidra's PCodeTest compiles 21 C bodies per target and emulates them | the same C bodies plus our own, executed natively and under the e5r emulator, compared on every observable |
+| Function boundaries | DWARF and PE unwind records | none published | precision and recall per fixture, tracked per release |
+| Decompiler behavior | recorded baseline, reviewed on change | Ghidra 89 datatests, kuna 83 files and 675 assertions | 89 ported cases before the M6 gate opens, then one case per fixed bug, permanently |
+| Type recovery | DWARF types in the fixture that has them | none published | agreement rate per type category, tracked per release |
+| Annotation merge | `git merge` itself | none | every scenario in the design doc, run against real git |
+
+The decoder parity suite is the one that has to be enormous and it costs almost
+nothing to grow, because the oracle generates the expected answers. A disagreement
+is either a bug in our decoder or a known divergence with a written reason, and
+the list of written reasons is checked in.
+
+The semantic lift suite is the part most projects skip, and skipping it is why
+lifters quietly produce wrong p-code for years. Ghidra's approach works: compile
+a C body for the target, run it natively to get the answer, run it under the
+emulator, compare. It is also the only way to test 40 SLEIGH specifications
+without 40 experts.
+
+### Coverage floors
+
+Line coverage measured with `cargo llvm-cov`, enforced per crate, because a
+single workspace number lets a well-tested decoder hide an untested loader.
+
+| Crate | Line | Branch |
+| --- | --- | --- |
+| `e5r-format`, `e5r-arch`, `e5r-sleigh` | 90% | 85% |
+| `e5r-core`, `e5r-ir`, `e5r-db`, `e5r-types` | 85% | 80% |
+| `e5r-analysis`, `e5r-decomp`, `e5r-diff`, `e5r-patch` | 75% | 65% |
+| `e5r-cli` | 60% | 50% |
+
+Parsers and decoders get the high floor because their inputs are hostile and
+their failure mode is silent. The decompiler gets a lower one because a
+structuring pass has combinatorially many paths and the datatests cover what
+matters better than a coverage percentage does.
+
+### Mutation score
+
+Coverage says a line ran. It does not say a test would have noticed if the line
+were wrong, and on a decoder that distinction is the whole game: a table entry
+with the wrong operand size is executed by every test and caught by none.
+
+`cargo mutants` changes one operator, constant or return value at a time and
+reports whether the suite failed. The score is the fraction of mutants killed.
+
+| Crate | Floor |
+| --- | --- |
+| `e5r-format`, `e5r-arch`, `e5r-core` | 85% |
+| `e5r-ir`, `e5r-db`, `e5r-types` | 75% |
+| everything else | 60%, or an exemption with a written reason |
+
+Full mutation runs are slow, so CI mutates only the diff on a pull request and
+the whole workspace on a nightly. Neither Ghidra nor rizin publishes a mutation
+score. Publishing ours, and holding a floor, is a claim about test quality that
+a test count cannot make.
+
+### Fuzzing
+
+Counted by what it finds, not by whether it runs.
+
+- Every loader, decoder and demangler has a target, run in CI against a corpus
+ that is checked in and grows with every crash.
+- The metric is edge coverage plateau per target, recorded per release. A target
+ whose coverage stops growing has either been fully explored, which is worth
+ knowing, or has a harness bug, which is worth fixing.
+- A crash found by fuzzing becomes a unit test with the minimized input before
+ the fix lands.
+
+### Where the tests live
+
+- Unit tests next to the code, for anything with a contract.
+- `tests/` in each crate for behavior that crosses module boundaries.
+- `fixtures/` for the binaries, with a manifest recording the source, the build
+ flags and the ground truth available for each one.
+- `tests/golden/` for the recorded baselines, one file per case, reviewed as part
+ of the change that moves them. A baseline that moves without a reviewer saying
+ why is not a baseline.
+
+
+## Scorecard
+
+The claim is that e5r is better than the incumbents, so it needs numbers that
+can prove it wrong. These get recorded in `docs/scorecard.md` from the first
+release and rerun every release.
+
+| Measure | Against |
+| --- | --- |
+| Time from invocation to first function listing | Ghidra headless, `rizin -A` |
+| Full analysis wall time and peak RSS per fixture | same |
+| Function boundary F1 on stripped binaries | DWARF ground truth |
+| Variable type agreement in decompiled output | DWARF ground truth |
+| Goto density per decompiled function | Ghidra decompiler on the same fixture |
+| Fraction of decompiled functions that recompile | absolute |
+| Byte-identical output across runs and thread counts | absolute |
+| Patch-diff: does the changed function rank first | a set of known CVE build pairs |
+
+Where a number is worse than the incumbent, it stays in the table. A scorecard
+that only reports wins is marketing.
+
+### DecBench
+
+The scorecard above is ours, which means we choose the fixtures and we grade our
+own work. [DecBench](https://decbench.com) is the external check: a public
+benchmark that compiles 39 projects at several optimization levels, decompiles
+every function with every entrant, and scores the output against the original
+source on three axes. 94,575 functions across 803 binaries, with a published
+dataset and a leaderboard.
+
+Its three metrics, none of which we chose:
+
+| Metric | What it compares |
+| --- | --- |
+| Structure (GED) | graph edit distance between the source CFG and the decompiled CFG, 0 being isomorphic |
+| Types | recovered variable types against DWARF |
+| Recompile | recompile the decompiled function with the original toolchain, then compare assembly by Jaccard similarity with linker-dependent operands normalized away |
+
+Union is the fraction of functions where the entrant is perfect on at least one
+of the three.
+
+Where the field stands, from the 2026-08-29 snapshot. Numbers are percent of
+functions, higher is better, on the unoptimized (O0, stripped) set of 34,406
+functions:
+
+| | Union | Structure | Types | Recompile |
+| --- | --- | --- | --- | --- |
+| Hex-Rays | 47.9 | 45.7 | 8.7 | 0.8 |
+| kuna | 46.8 | 44.8 | 8.1 | 3.0 |
+| angr | 45.7 | 41.2 | 12.1 | 0.6 |
+| Ghidra | 32.2 | 29.3 | 7.7 | 0.2 |
+| Binary Ninja | 28.9 | 24.0 | 10.4 | 0.2 |
+| r2dec | 22.1 | 21.5 | 2.3 | 0.1 |
+
+On plain `-O2` with inlining, everything drops: kuna 31.4, Hex-Rays 30.7, angr
+30.3, Ghidra 21.7. On the large-function tail the whole field collapses: kuna
+1.6, Binary Ninja 0.8, angr 0.7, Hex-Rays 0.6, Ghidra 0.4.
+
+Four things follow for this roadmap.
+
+1. **Ghidra is not the bar,** and beating it is not the achievement it sounds
+ like. It sits fourth on the default set, 15 points behind Hex-Rays. "Better
+ than Ghidra" as a decompiler claim means passing 32.2 union and 29.3 structure
+ on the unoptimized set, and that is the M6 exit criterion. The bar that
+ matters is Hex-Rays at 47.9.
+
+2. **Recompilation is wide open.** The best score in the field is kuna at 3.0 and
+ Ghidra manages 0.2, so fewer than one function in thirty rebuilds to
+ equivalent assembly anywhere. Output that actually recompiles is worth more to
+ a patch-diff or a vulnerability workflow than output that reads nicely, and it
+ is the one axis where a new entrant can lead instead of catching up. M6
+ optimizes for it on purpose, which is why the recompilability gate is in G7
+ from the start.
+
+3. **Large functions are the other open axis.** Under 2% for everyone, on a set
+ of 1,987 functions. A large optimized function is what a researcher actually
+ hits in real work, and it is what a structuring pass with no budget discipline
+ gives up on. Scale is already an M11 commitment.
+
+4. **The LLM results are a scale artifact.** Codex scores 57.2 union and Claude
+ Code 56.4 on the 250-function sample set, ahead of every traditional
+ decompiler there. Both score 0.2 on the full 34,406-function set. Per-function
+ attention from a frontier model wins when someone pays for 250 functions, and
+ nobody is paying for 94,575. That argues for a fast deterministic engine an
+ agent drives, which is bet 5. It does not argue that the engine is obsolete.
+
+Tasks:
+
+- [x] Use the DecBench dataset as part of the fixture corpus, and G4 with it.
+ 16 of its 39 projects build here, 592 binaries at three optimization
+ levels, outside the checkout. Boundary recall and precision are measured
+ against `readelf`'s DWARF rather than our own reader, over 95,697
+ ground-truth functions: 1.000 and 1.000 stripped, and 0.766 and 0.937
+ with `.eh_frame` removed as well, which is the number that says what the
+ analysis can do with nothing but code. See
+ [`docs/boundaries.md`](../docs/boundaries.md).
+- [x] A DecBench backend for e5r, as `scripts/decbench_e5r.py`. Out of tree,
+ which DecBench's own documentation permits, so the benchmark checkout
+ stays untouched. Driven by `scripts/decbench.sh`.
+- [ ] Submit to the 250-function sample set as soon as M6 produces output at all.
+ It needs no harness and no open-sourcing: decompile the kit, mail back the
+ zip. An early bad score is a baseline, not an embarrassment.
+- [x] Record the DecBench numbers in `docs/scorecard.md` per release, including
+ the runs where we lose. The first run is one we lose: 23.4 union against
+ angr's 37.0 on the same slice, with the method and the defect analysis in
+ [`docs/decbench.md`](../docs/decbench.md).
+
+## Not building
+
+Each of these is a decision. Reopening one needs a reason that did not exist when
+it was closed.
+
+- **A GUI:** Not in the first year, possibly not ever from this repository. The
+ library API and the JSON schema are designed so someone else can build one.
+- **A debugger:** rizin and gdb do this well and it is a different product. The
+ emulator in M10 runs code paths for analysis; it does not attach to a process.
+- **Ghidra script compatibility:** Running Java is the thing this project exists
+ to avoid.
+- **Exotic architectures ahead of depth:** x86-64 and AArch64 on ELF, PE and
+ Mach-O get finished before anything else gets attention, SLEIGH breadth aside.
+- **Reverse engineering the incumbents' internals:** Clean-room: public
+ specifications, published papers, and the source of permissively licensed
+ projects used within their licenses.
+- **Obfuscation-specific unpackers for named commercial protectors:** The
+ emulation and query surfaces are general; shipping a bypass for a specific
+ product is not the goal.
+
+## Order of work
+
+M0 and M1 are prerequisites for everything. M2's hand-written x86-64 decoder
+comes before the SLEIGH compiler, because the fixture corpus is mostly x86-64 and
+because it sets the shape of the decoder trait the SLEIGH runtime has to fit.
+M3 and M4 are the long middle. M7 can be built in parallel with M3, since anchors
+need only function boundaries and an instruction stream, and getting the storage
+format wrong late is expensive. M6 is the hardest and last of the core work.
+
+The first release worth announcing is M0 through M4 plus M7 and M8: a fast,
+deterministic disassembler with git-native annotations and a JSON surface. That
+is already a tool a researcher would use. The decompiler is what makes it a
+competitor.
diff --git a/crates/e5r-analysis/Cargo.toml b/crates/e5r-analysis/Cargo.toml
new file mode 100644
index 0000000..5274288
--- /dev/null
+++ b/crates/e5r-analysis/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "e5r-analysis"
+description = "e5r — function discovery, control flow, cross references, strings."
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+authors.workspace = true
+
+[dependencies]
+e5r-core = { workspace = true }
+e5r-format = { workspace = true }
+e5r-arch = { workspace = true }
+e5r-types = { workspace = true }
+rayon = { workspace = true }
+serde = { workspace = true }
diff --git a/crates/e5r-analysis/examples/bench.rs b/crates/e5r-analysis/examples/bench.rs
new file mode 100644
index 0000000..ab8754f
--- /dev/null
+++ b/crates/e5r-analysis/examples/bench.rs
@@ -0,0 +1,90 @@
+//! What one command's worth of analysis costs, eagerly and lazily.
+//!
+//! Not a test. It exists so the M11 numbers are measured with the library the
+//! CLI calls rather than inferred from a CLI that has its own per-command
+//! workarounds in it. Peak resident set comes from the kernel, and agrees with
+//! `/usr/bin/time`.
+//!
+//! Usage: bench [cache-dir]
+//!
+//! load parse the header and the symbol table, nothing else
+//! funcs-eager what `e5r funcs` costs today: analyze() with strings off
+//! funcs-lazy a session asked for functions and nothing else
+//! strings-lazy a session asked for strings and nothing else
+//! all everything, which is what `e5r stats` needs
+//!
+//! A cache directory makes the lazy modes read and write it, so running one
+//! twice prints a miss and then a hit.
+
+use std::time::Instant;
+
+use e5r_analysis::{Cache, ContentHash, Options, Session};
+use e5r_format::LoadOptions;
+
+/// Peak resident set in kilobytes, as the kernel saw it.
+fn peak_rss() -> u64 {
+ std::fs::read_to_string("/proc/self/status")
+ .unwrap_or_default()
+ .lines()
+ .find(|l| l.starts_with("VmHWM:"))
+ .and_then(|l| l.split_whitespace().nth(1))
+ .and_then(|v| v.parse().ok())
+ .unwrap_or(0)
+}
+
+fn main() {
+ let mut args = std::env::args().skip(1);
+ let path = args
+ .next()
+ .expect("usage: bench [cache-dir]");
+ let mode = args.next().unwrap_or_else(|| "all".to_string());
+ let cache_dir = args.next();
+
+ let start = Instant::now();
+ let data = std::fs::read(&path).expect("read");
+ let object = e5r_format::load(&data, &LoadOptions::default()).expect("load");
+ let loaded = start.elapsed();
+
+ let opts = Options {
+ strings: mode != "funcs-eager",
+ ..Options::default()
+ };
+
+ let work = Instant::now();
+ let note = if mode == "funcs-eager" {
+ let p = e5r_analysis::analyze(object, &opts);
+ format!("{} functions, {} xrefs", p.functions.len(), p.xrefs.len())
+ } else {
+ let mut session = Session::new(object, opts);
+ if let Some(dir) = &cache_dir {
+ session = session.with_cache(Cache::at(dir), ContentHash::of(&data));
+ }
+ match mode.as_str() {
+ "load" => "nothing asked for".to_string(),
+ "funcs-lazy" => {
+ let f = session.functions();
+ let blocks: usize = f.values().map(|f| f.cfg.blocks.len()).sum();
+ let insns: u64 = f.values().map(|f| f.cfg.insns() as u64).sum();
+ format!("{} functions, {blocks} blocks, {insns} insns", f.len())
+ }
+ "strings-lazy" => format!("{} strings", session.strings().len()),
+ "all" => {
+ let s = session.into_program().stats();
+ format!(
+ "{} functions, {} blocks, {} insns, {} xrefs, {} strings",
+ s.functions, s.blocks, s.insns, s.xrefs, s.strings
+ )
+ }
+ other => panic!("unknown mode {other}"),
+ }
+ };
+ let elapsed = work.elapsed();
+
+ println!(
+ "{mode:<13} load {:>6.3}s work {:>6.3}s total {:>6.3}s peak {:>7} KB {note}",
+ loaded.as_secs_f64(),
+ elapsed.as_secs_f64(),
+ start.elapsed().as_secs_f64(),
+ peak_rss(),
+ );
+}
diff --git a/crates/e5r-analysis/examples/demangle.rs b/crates/e5r-analysis/examples/demangle.rs
new file mode 100644
index 0000000..944ab5f
--- /dev/null
+++ b/crates/e5r-analysis/examples/demangle.rs
@@ -0,0 +1,61 @@
+//! A filter that demangles the symbol names in whatever is piped through it.
+//!
+//! `perf` on this machine does not know Rust's v0 scheme, so a profile of e5r
+//! reads as `_RNvNtCs..._13e5r_analysis3cfg10build_with`. This crate already
+//! depends on the demangler the rest of the tool uses, so the profile can be
+//! made readable without installing anything.
+//!
+//! It belongs in the CLI as `e5r demangle` rather than here. It is an example
+//! because the analysis crate is the one `scripts/flamegraph.sh` profiles, and
+//! an example needs no new command, no new flag and no other crate's consent.
+//!
+//! Usage: ... | cargo run --release -p e5r-analysis --example demangle
+
+use std::io::{BufRead, Write};
+
+/// Whether a byte can appear in a mangled symbol.
+fn symbolic(b: u8) -> bool {
+ b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || b == b'.'
+}
+
+/// Replace every mangled name in one line with what it means.
+fn rewrite(line: &str, out: &mut String) {
+ let b = line.as_bytes();
+ let mut i = 0;
+ while i < b.len() {
+ if (b[i] == b'_' || b[i] == b'?') && (i == 0 || !symbolic(b[i - 1])) {
+ let mut j = i;
+ while j < b.len() && symbolic(b[j]) {
+ j += 1;
+ }
+ // A trailing `.llvm.1234` is LLVM's, not part of the name, and the
+ // demangler rejects the whole symbol if it is left on.
+ let token = &line[i..j];
+ let stem = token.split(".llvm.").next().unwrap_or(token);
+ if let Some((_, name)) = e5r_types::demangle(stem) {
+ out.push_str(&name);
+ i = j;
+ continue;
+ }
+ }
+ // One character rather than one byte: `i` is always on a boundary,
+ // because everything a mangled name is made of is ASCII.
+ let ch = line[i..].chars().next().unwrap_or('\u{fffd}');
+ out.push(ch);
+ i += ch.len_utf8();
+ }
+}
+
+fn main() {
+ let stdin = std::io::stdin();
+ let stdout = std::io::stdout();
+ let mut w = std::io::BufWriter::new(stdout.lock());
+ let mut buf = String::new();
+ for line in stdin.lock().lines().map_while(Result::ok) {
+ buf.clear();
+ rewrite(&line, &mut buf);
+ if writeln!(w, "{buf}").is_err() {
+ return;
+ }
+ }
+}
diff --git a/crates/e5r-analysis/examples/phases.rs b/crates/e5r-analysis/examples/phases.rs
new file mode 100644
index 0000000..ec3a43d
--- /dev/null
+++ b/crates/e5r-analysis/examples/phases.rs
@@ -0,0 +1,292 @@
+//! Where the memory goes, stage by stage and structure by structure.
+//!
+//! Not a test and not wired into the CLI: it exists so the memory numbers in
+//! docs/scorecard.md and in an M11 report are measured rather than guessed.
+//!
+//! A session is what makes this one run rather than four. Each part is forced
+//! in turn and nothing is dropped, so the resident set after a part minus the
+//! resident set before it is exactly what that part retains, and the peak
+//! along the way is what it needed transiently to get there. An eager
+//! `analyze` could only be priced by running it once per option, which prices
+//! four analyses rather than four stages.
+//!
+//! The accounting at the end is the same quantity counted a second way, from
+//! the sizes of what is actually stored. Two independent numbers that agree
+//! are evidence; one number is a claim.
+//!
+//! Usage: cargo run --release -p e5r-analysis --example phases -- [no-scan]
+
+use std::collections::BTreeMap;
+
+use e5r_analysis::program::{Function, Options};
+use e5r_analysis::{Cfg, Session};
+use e5r_core::Addr;
+use e5r_format::{LoadOptions, Object};
+
+/// Current and peak resident set, in kilobytes, from the kernel.
+fn rss() -> (u64, u64) {
+ let s = std::fs::read_to_string("/proc/self/status").unwrap_or_default();
+ let field = |name: &str| -> u64 {
+ s.lines()
+ .find(|l| l.starts_with(name))
+ .and_then(|l| l.split_whitespace().nth(1))
+ .and_then(|v| v.parse().ok())
+ .unwrap_or(0)
+ };
+ (field("VmRSS:"), field("VmHWM:"))
+}
+
+/// Print one stage, and return the resident set so the caller can difference
+/// it against the next.
+fn mark(label: &str, prev: u64, note: String) -> u64 {
+ let (now, peak) = rss();
+ println!(
+ "{label:<18} rss {:>8} KB (+{:>8} KB) peak {:>8} KB {note}",
+ now,
+ now.saturating_sub(prev),
+ peak
+ );
+ now
+}
+
+/// Rust's B-tree holds between 5 and 11 elements per node, plus a parent
+/// pointer, an index and a length. Nodes are counted as three-quarters full,
+/// which is what a map built by ascending insertion settles at; the number is
+/// an estimate and is labelled as one.
+fn btree_bytes(elements: usize, key: usize, value: usize) -> usize {
+ if elements == 0 {
+ return 0;
+ }
+ let per_node = 16 + 11 * (key + value);
+ let nodes = elements.div_ceil(8).max(1);
+ nodes * per_node
+}
+
+/// The accounting: bytes held, broken down by what holds them.
+fn account(functions: &BTreeMap) {
+ let map = btree_bytes(functions.len(), size_of::(), size_of::());
+ let mut names = 0usize;
+ let mut blocks = 0usize;
+ let mut succ = 0usize;
+ let mut calls = 0usize;
+ let mut tables = 0usize;
+ let mut targets = 0usize;
+ let mut provenance = 0usize;
+ let mut nblocks = 0usize;
+ let mut nsucc = 0usize;
+ let mut ncalls = 0usize;
+ let mut insns = 0u64;
+ for f in functions.values() {
+ names += f.name.as_ref().map_or(0, |n| n.capacity());
+ provenance += f.provenance.corroborating.capacity() * size_of::();
+ nblocks += f.cfg.blocks.len();
+ // Four bytes per stored block: the block itself is in the shared
+ // table, counted once below.
+ blocks += f.cfg.blocks.len() * size_of::() + 32;
+ for b in f.cfg.blocks.values() {
+ nsucc += b.successors.len();
+ }
+ ncalls += f.cfg.calls.len();
+ calls += f.cfg.calls.capacity() * size_of::();
+ tables += f.cfg.tables.capacity() * size_of::();
+ for t in &f.cfg.tables {
+ targets += t.targets.capacity() * size_of::();
+ }
+ insns += f.cfg.insns() as u64;
+ }
+ // The shared block table, counted once rather than once per function that
+ // names a block. An entry is the block plus the address it starts at.
+ let table = functions
+ .values()
+ .next()
+ .map(|f| f.cfg.blocks.table())
+ .expect("a program with no functions has nothing to account for");
+ let distinct = table.len();
+ let mut table_bytes = distinct * (size_of::() + size_of::());
+ for b in table.blocks() {
+ // What the allocator hands out, not what the list holds: glibc rounds
+ // a request up to a 16-byte multiple with an 8-byte header and never
+ // gives out less than 32 bytes, so one or two successors cost 32.
+ let want = b.successors.capacity() * size_of::();
+ succ += if want == 0 {
+ 0
+ } else {
+ (want + 8).next_multiple_of(16).max(32)
+ };
+ }
+ table_bytes += succ;
+ let total = map + names + blocks + table_bytes + calls + tables + targets + provenance;
+ let kb = |n: usize| n / 1024;
+ println!();
+ println!("accounted bytes, by what holds them");
+ println!(
+ " function map {:>9} KB {} functions at {} B each, in B-tree nodes",
+ kb(map),
+ functions.len(),
+ size_of::()
+ );
+ println!(
+ " block ids {:>9} KB {nblocks} stored blocks at 4 B each",
+ kb(blocks)
+ );
+ println!(
+ " block table {:>9} KB {distinct} distinct blocks at {} B each, plus {} KB of \
+ successor lists for {nsucc} edges",
+ kb(table_bytes),
+ size_of::() + size_of::(),
+ kb(succ)
+ );
+ println!(
+ " call lists {:>9} KB {ncalls} call sites",
+ kb(calls)
+ );
+ println!(
+ " jump tables {:>9} KB plus {} KB of targets",
+ kb(tables),
+ kb(targets)
+ );
+ println!(" function names {:>9} KB", kb(names));
+ println!(
+ " provenance {:>9} KB corroborating evidence",
+ kb(provenance)
+ );
+ println!(" ---");
+ println!(" total {:>9} KB", kb(total));
+ println!();
+ println!(
+ "per function {} B, per block {} B, per instruction {} B",
+ total / functions.len().max(1),
+ total / nblocks.max(1),
+ total as u64 / insns.max(1)
+ );
+ println!(
+ "{insns} instructions are decoded and thrown away; one decoded Insn is {} B, so keeping \
+ them would cost {} MB",
+ size_of::(),
+ insns * size_of::() as u64 / (1024 * 1024)
+ );
+
+ // How much of what is stored is the same code stored twice. Functions
+ // recovered from independent evidence can cover the same bytes, and a
+ // block map is the largest thing this crate holds, so the answer decides
+ // whether the memory ceiling is a question of representation or of
+ // discovery. A sorted vector rather than a set, because a set of eight
+ // million ranges costs more than the thing being measured.
+ let mut ranges: Vec<(u64, u64)> = Vec::with_capacity(nblocks);
+ for f in functions.values() {
+ for b in f.cfg.blocks.values() {
+ ranges.push((b.range.start().get(), b.range.end().get()));
+ }
+ }
+ ranges.sort_unstable();
+ let distinct = {
+ let mut n = 0usize;
+ let mut last = None;
+ for r in &ranges {
+ if Some(*r) != last {
+ n += 1;
+ last = Some(*r);
+ }
+ }
+ n
+ };
+ // Union of the ranges, which is the code actually reached.
+ let mut covered = 0u64;
+ let mut end = 0u64;
+ for (s, e) in &ranges {
+ let from = (*s).max(end);
+ if *e > from {
+ covered += *e - from;
+ end = *e;
+ }
+ }
+ println!(
+ "{nblocks} blocks are {distinct} distinct ranges covering {} KB of code, so {:.1}x of what \
+ is stored is the same bytes held by more than one function",
+ covered / 1024,
+ nblocks as f64 / distinct.max(1) as f64
+ );
+}
+
+fn main() {
+ let path = std::env::args().nth(1).expect("usage: phases ");
+ let mut at = mark("start", 0, String::new());
+
+ // Read rather than mapped, unlike the CLI, so this line is the whole file
+ // where `e5r` pays only for the pages it touches. Everything after it is
+ // the same either way, which is the part being measured.
+ let data = std::fs::read(&path).expect("read");
+ at = mark("file read", at, format!("{} KB of file", data.len() / 1024));
+
+ let object: Object = e5r_format::load(&data, &LoadOptions::default()).expect("load");
+ at = mark(
+ "load",
+ at,
+ format!(
+ "{} symbols, {} hints",
+ object.symbols.len(),
+ object.function_hints.len()
+ ),
+ );
+
+ // One session, each part forced in turn: nothing is dropped, so every
+ // difference below is what that part keeps.
+ // A second argument switches gap scanning off, because a prologue scan
+ // invents functions that overlap the ones real evidence found, and the
+ // block duplication printed at the end is the number that says whether
+ // that is where the memory goes.
+ let opts = Options {
+ scan_gaps: std::env::args().nth(2).as_deref() != Some("no-scan"),
+ ..Options::default()
+ };
+ let session = Session::new(object, opts);
+
+ let functions = session.functions();
+ let nblocks: usize = functions.values().map(|f| f.cfg.blocks.len()).sum();
+ let insns: u64 = functions.values().map(|f| f.cfg.insns() as u64).sum();
+ at = mark(
+ "functions + cfg",
+ at,
+ format!(
+ "{} functions, {nblocks} blocks, {insns} insns, {} noreturn",
+ functions.len(),
+ session.noreturn().len()
+ ),
+ );
+
+ at = mark("xrefs", at, format!("{} xrefs", session.xrefs().len()));
+
+ let bytes: usize = session.strings().iter().map(|f| f.text.len()).sum();
+ mark(
+ "strings",
+ at,
+ format!(
+ "{} strings, {} KB of text",
+ session.strings().len(),
+ bytes / 1024
+ ),
+ );
+
+ account(session.functions());
+
+ let max_insns = functions.values().map(|f| f.cfg.insns()).max().unwrap_or(0);
+ let max_blocks = functions
+ .values()
+ .map(|f| f.cfg.blocks.len())
+ .max()
+ .unwrap_or(0);
+ println!(
+ "largest function {max_insns} insns, {max_blocks} blocks, so decoding it into a list \
+ costs {} KB on whichever thread has it",
+ max_insns as usize * size_of::() / 1024
+ );
+ println!(
+ "sizeof Function {}, Block {}, Cfg {}, Xref {}, Found {}, JumpTable {}",
+ size_of::(),
+ size_of::(),
+ size_of::(),
+ size_of::(),
+ size_of::(),
+ size_of::(),
+ );
+}
diff --git a/crates/e5r-analysis/src/cache.rs b/crates/e5r-analysis/src/cache.rs
new file mode 100644
index 0000000..ea6487b
--- /dev/null
+++ b/crates/e5r-analysis/src/cache.rs
@@ -0,0 +1,466 @@
+//! An on-disk cache of analysis results, keyed by content and options.
+//!
+//! Derived data, so it lives in the user's cache directory and never beside
+//! the binary: `$XDG_CACHE_HOME/e5r` when that is set, `~/.cache/e5r`
+//! otherwise. Nothing here is authoritative. Two rules outrank speed:
+//!
+//! 1. A key that does not match is a miss. There is no partial reuse, no
+//! "close enough", and no reading of one part of an entry whose other part
+//! was written by a different build.
+//! 2. A corrupt or truncated file is a miss with a warning. It is never a
+//! panic and never a wrong answer, because a cache that can lie is worse
+//! than no cache at all.
+//!
+//! Each part of the analysis is a separate entry under its own key, so a run
+//! that asked only for functions leaves a functions entry, and a later run
+//! that also wants strings reuses the first and computes the second. The key
+//! of a part covers exactly the inputs that determine it: the file's content
+//! hash, the format version, and the options that part reads. Thread count is
+//! deliberately absent, because the answer may not depend on it (gate G5).
+//!
+//! # The file format
+//!
+//! Hand rolled rather than serde, because the decoder has to survive hostile
+//! bytes and every length in it needs to be checked against what is left of
+//! the file. All integers are little endian.
+//!
+//! ```text
+//! magic 8 bytes, "E5RCACHE"
+//! version u32, FORMAT_VERSION
+//! part u8, which analysis part this is
+//! key 16 bytes, the part key
+//! length u64, payload bytes
+//! payload length bytes
+//! checksum u64 over everything before it
+//! ```
+//!
+//! The payload encodings are documented on the functions that write them.
+
+use std::fs;
+use std::io;
+use std::path::{Path, PathBuf};
+
+/// Bumped whenever a payload encoding changes. An old entry then fails the
+/// version check and is a miss, which is the point: a stale decode that
+/// happens to parse is exactly the wrong answer this cache must not give.
+pub const FORMAT_VERSION: u32 = 1;
+
+const MAGIC: &[u8; 8] = b"E5RCACHE";
+const HEADER_LEN: usize = 8 + 4 + 1 + 16 + 8;
+
+/// Which analysis result an entry holds.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Part {
+ /// Functions, their graphs, and the no-return set.
+ Functions,
+ /// The cross reference index.
+ Xrefs,
+ /// Extracted strings.
+ Strings,
+}
+
+impl Part {
+ /// The byte that identifies the part inside a file.
+ fn code(self) -> u8 {
+ match self {
+ Part::Functions => 1,
+ Part::Xrefs => 2,
+ Part::Strings => 3,
+ }
+ }
+
+ /// The filename prefix, so a person can see what is in the directory.
+ fn name(self) -> &'static str {
+ match self {
+ Part::Functions => "funcs",
+ Part::Xrefs => "xrefs",
+ Part::Strings => "strings",
+ }
+ }
+}
+
+/// A 128-bit hash of a file's bytes, the first half of every part key.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ContentHash([u8; 16]);
+
+impl ContentHash {
+ /// Hash a whole file image.
+ pub fn of(data: &[u8]) -> ContentHash {
+ ContentHash(hash128(data))
+ }
+
+ /// Fold in anything else that changed what was loaded.
+ ///
+ /// The object cannot say what load options produced it, and a run with
+ /// `.eh_frame` reading turned off finds different functions in the same
+ /// bytes. A caller that varies the load must mix that variation in here,
+ /// or the two loads share a key and the second gets the first's answer.
+ pub fn with(self, extra: &[u8]) -> ContentHash {
+ let mut buf = Vec::with_capacity(16 + extra.len());
+ buf.extend_from_slice(&self.0);
+ buf.extend_from_slice(extra);
+ ContentHash(hash128(&buf))
+ }
+
+ /// The raw bytes, for a caller that wants to key something of its own.
+ pub fn bytes(&self) -> [u8; 16] {
+ self.0
+ }
+}
+
+/// The key of one cache entry: content, format version and the options that
+/// part depends on, mixed into 128 bits.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Key([u8; 16]);
+
+impl Key {
+ /// Derive a part key from the content hash and the bytes that describe the
+ /// options that part reads.
+ pub fn derive(content: &ContentHash, part: Part, opts: &[u8]) -> Key {
+ let mut buf = Vec::with_capacity(16 + 4 + 1 + opts.len());
+ buf.extend_from_slice(&content.0);
+ buf.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
+ buf.push(part.code());
+ buf.extend_from_slice(opts);
+ Key(hash128(&buf))
+ }
+
+ /// Lowercase hex, which is what the filename uses.
+ pub fn hex(&self) -> String {
+ let mut s = String::with_capacity(32);
+ for b in self.0 {
+ s.push(char::from_digit((b >> 4) as u32, 16).unwrap_or('0'));
+ s.push(char::from_digit((b & 0xf) as u32, 16).unwrap_or('0'));
+ }
+ s
+ }
+}
+
+/// What a lookup found.
+#[derive(Debug)]
+pub enum Lookup {
+ /// A payload whose key, version and checksum all matched.
+ Hit(Vec),
+ /// Nothing usable, and nothing wrong: no file, or a file for another key.
+ Miss,
+ /// A file that exists and does not hold what it claims to. The caller
+ /// treats this as a miss and reports the message.
+ Corrupt(String),
+}
+
+/// A directory of cache entries.
+#[derive(Debug, Clone)]
+pub struct Cache {
+ dir: PathBuf,
+}
+
+impl Cache {
+ /// The conventional location, or `None` when neither `XDG_CACHE_HOME` nor
+ /// `HOME` says where it is. Does not create anything: a cache that cannot
+ /// be found is a miss, not an error.
+ pub fn discover() -> Option {
+ let base = match std::env::var_os("XDG_CACHE_HOME") {
+ Some(v) if !v.is_empty() => PathBuf::from(v),
+ _ => PathBuf::from(std::env::var_os("HOME")?).join(".cache"),
+ };
+ Some(Cache {
+ dir: base.join("e5r").join("analysis"),
+ })
+ }
+
+ /// A cache in a directory the caller names, for tests and for `--cache-dir`.
+ pub fn at(dir: impl Into) -> Cache {
+ Cache { dir: dir.into() }
+ }
+
+ /// Where entries are written.
+ pub fn dir(&self) -> &Path {
+ &self.dir
+ }
+
+ /// The file one entry lives in.
+ fn path(&self, part: Part, key: &Key) -> PathBuf {
+ self.dir.join(format!("{}-{}.bin", part.name(), key.hex()))
+ }
+
+ /// Read one entry.
+ ///
+ /// Every failure short of a decoded, checksummed payload is a miss. The
+ /// distinction between [`Lookup::Miss`] and [`Lookup::Corrupt`] is only
+ /// about whether a person should be told.
+ pub fn get(&self, part: Part, key: &Key) -> Lookup {
+ let path = self.path(part, key);
+ let bytes = match fs::read(&path) {
+ Ok(b) => b,
+ Err(e) if e.kind() == io::ErrorKind::NotFound => return Lookup::Miss,
+ Err(e) => return Lookup::Corrupt(format!("cache read failed for {path:?}: {e}")),
+ };
+ match decode_entry(&bytes, part, key) {
+ Ok(Some(payload)) => Lookup::Hit(payload),
+ Ok(None) => Lookup::Miss,
+ Err(why) => Lookup::Corrupt(format!("ignoring damaged cache file {path:?}: {why}")),
+ }
+ }
+
+ /// Write one entry, replacing whatever was there.
+ ///
+ /// Written to a temporary name and renamed, so a reader never sees half a
+ /// file and an interrupted write leaves the previous entry intact. The
+ /// temporary carries the process id because two e5r runs on the same
+ /// binary are a normal thing to do.
+ pub fn put(&self, part: Part, key: &Key, payload: &[u8]) -> io::Result<()> {
+ fs::create_dir_all(&self.dir)?;
+ let final_path = self.path(part, key);
+ let tmp = self.dir.join(format!(
+ "{}-{}.{}.tmp",
+ part.name(),
+ key.hex(),
+ std::process::id()
+ ));
+ let mut buf = Vec::with_capacity(HEADER_LEN + payload.len() + 8);
+ buf.extend_from_slice(MAGIC);
+ buf.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
+ buf.push(part.code());
+ buf.extend_from_slice(&key.0);
+ buf.extend_from_slice(&(payload.len() as u64).to_le_bytes());
+ buf.extend_from_slice(payload);
+ let sum = hash64(&buf);
+ buf.extend_from_slice(&sum.to_le_bytes());
+ match fs::write(&tmp, &buf).and_then(|()| fs::rename(&tmp, &final_path)) {
+ Ok(()) => Ok(()),
+ Err(e) => {
+ let _ = fs::remove_file(&tmp);
+ Err(e)
+ }
+ }
+ }
+
+ /// Delete one entry. Used when a corrupt file is found, so the next run
+ /// does not report the same damage again.
+ pub fn remove(&self, part: Part, key: &Key) {
+ let _ = fs::remove_file(self.path(part, key));
+ }
+}
+
+/// Check the header and the checksum and hand back the payload.
+///
+/// `Ok(None)` is a file for a different key or version, which is ordinary.
+/// `Err` is a file that claims to be this entry and is not intact.
+fn decode_entry(bytes: &[u8], part: Part, key: &Key) -> Result