unidecompiler-simulator

A decoupled generic IR simulator for unidecompiler


License
AGPL-3.0
Install
pip install unidecompiler-simulator==0.2.3

Documentation

unidecompiler

A small universal bytecode decompiler experiment.

Quick start (PyPI)

For the complete read-only GUI, CLI, simulator, symbolic executor, and all bundled frontend formats, install the meta-package:

python -m pip install --upgrade unidecompiler-all
unidecompiler-gui

To use only the command-line host with selected formats:

python -m pip install --upgrade unidecompiler-cli \
  unidecompiler-plugin-python-pyc \
  unidecompiler-plugin-jvm-class
unidecompiler --help

You do not need to clone this repository or build from source for normal use. The packages require Python 3.11 or newer. Clone the repository only for development, testing, or creating a custom VM frontend.

The project is built around one hard architectural rule: VM frontends are thin submitters, and the core owns recovery.

Purpose

unidecompiler is designed for authorized analysis of VM-SDK and bytecode virtualization protection. Its goal is to provide one frontend-neutral recovery pipeline for proprietary or custom virtual machines, including SDK-protected applications where bytecode is the only practical analysis surface.

The Python, JVM, Lua, .NET CLI, and WebAssembly frontends in this repository are reference implementations and regression coverage. They demonstrate the thin-IR contract; they are not the boundary of the project. A new VM frontend should decode its format and submit neutral bytecode facts while the core owns control-flow recovery, AST construction, diagnostics, and rendering.

Use the project only for software you own or are authorized to analyze.

Install

For normal use, install released packages with pip. You do not need to clone this repository or build from source. Clone the repository only when developing, testing, or contributing to unidecompiler.

For development rules and agent instructions, see AGENTS.md.

At a high level, the pipeline is a semantics-preserving fixed point in core:

VM bytecode -> thin IR -> generic IR / low-level CFG
                              |
                              v
                    CFG structuring (if/while/branch)
                              |
                              v
                    structured FunctionIR refinement
                              |
                +-------------+-------------+
                |                           |
             changed                     stable
                |                           |
                +--> CFG analysis/structuring ↺
                                            |
                                            v
                                  final AST -> pseudocode

The refinement loop is owned entirely by core. Each accepted rewrite is validated against the original control-flow and safety invariants before core re-enters CFG analysis and structuring. If equivalence cannot be proved, the low-level CFG/goto form is retained. The final FunctionDecl AST is produced only after this loop reaches a stable result; backends only render that result and never perform CFG recovery.

CFG recovery is edge-aware. Core keeps concrete incoming and outgoing edges, including parallel edges, with deterministic identities; a reducer must never deduplicate an edge merely because its source, target, or kind matches another edge. Shared CFG analysis snapshots provide dominators, postdominators, frontiers, loop information, and irreducible-entry facts for VM-neutral structuring passes. Phi cleanup and fallthrough-jump removal are accepted only when the exact predecessor edges, exception state, and data-flow values prove the rewrite safe. Otherwise the preservation-floor CFG/goto representation is kept unchanged.

Generic value recovery also preserves observable evaluation order. Stack copies, duplicates, unpacking, calls, and stores retain the value that existed at that bytecode point even when a later local, global, captured value, member, or item is mutated. Store and delete operations are represented explicitly in generic IR instead of being approximated as stack pops. Phi assignments execute as parallel copies, and an ambiguous duplicate predecessor is rejected rather than silently overwritten.

Numeric semantics travel with each generic operation. Frontends may submit canonical operators or common VM-neutral aliases such as shl, shr, rol, and ror, together with signed/unsigned/float domain, bit width, and wrapping or trapping overflow policy. Core rewrites preserve that metadata, and the optional simulator applies it without branching on a frontend or source language. Container kind is likewise retained, so tuple and list literals do not collapse into one representation.

Core also accepts descriptive call-effect summaries. They record reads, writes, return arity, and possible raise/suspend/mutation behavior without executing a callee. Known summaries enable narrowly proven stack-value preservation; unknown calls form conservative barriers for deferred values. Fixed-point pass scheduling and CFG rewrite evidence remain core-owned and diagnosable, including the instruction context available when recovery stops.

Every accepted region rewrite records a proof entry in FunctionIR.metadata with its rule, concrete block/edge identities, CFG snapshot key, and raw bytecode context. Rejected candidates record the rule and rejection diagnostics. The shared CFG validator also checks edge targets, contiguous parallel-edge ordinals, exception-edge provenance, and entry consistency before a rewrite is admitted. These records are analysis evidence, not frontend control-flow instructions; they let a remaining goto be traced to a specific proof boundary without weakening the preservation fallback.

Hosts may observe decompilation through the optional progress argument on DecompilerEngine. It emits immutable, frontend-neutral events with separate current-artifact and batch coordinates. Existing frontends remain compatible; frontends that can prove finer-grained work units may additionally implement decode_with_progress() or lift_with_progress(). Progress is observational only, is never stored in IR or metadata, and observer failures are isolated from decompilation. The CLI keeps progress disabled by default; bare --progress enables TTY auto mode and --progress always forces it. The single-line bar is written only to stderr, so pseudocode and AST JSON on stdout remain machine-readable. Events may also carry an optional VM-neutral item_label for the currently processed, frontend-proven function, method, code object, or other stable work item. Hosts prefer that label and fall back to the event message when it is absent. A frontend must omit it when no stable name or proven offset exists; when an offset is proven, a neutral fallback such as function@<offset> is appropriate. This label is display-only and must never enter IR, metadata, CFG, AST, or recovery decisions.

File and project exports are host functionality provided by the separate unidecompiler-export package. It writes recovered pseudocode documents and generates frontend or GUI-plugin starter projects without adding file-system I/O or template-generation responsibilities to the core engine.

CLI exports mirror the GUI host behavior: -o/--output writes one result, --output-dir writes one collision-safe file per successful artifact, and the template/export-template command generates a VM frontend or GUI-plugin starter project. Template destinations are created atomically and are never overwritten. Frontend templates can explicitly enable the optional data-only simulator adapter and AI development kit; both are off by default. AI input files are validated for regular-file status, size, and likely credentials before they are copied into a generated project.

VS Code navigation metadata is opt-in: pair --output with --vscode-metadata path.unidec.json to write a sidecar after the pseudocode. It contains a UTF-8 text hash, UTF-16 pseudocode ranges, and minimal instruction facts only. It does not duplicate pseudocode or export source paths, AST, IR, CFG, diagnostics, recovery data, or frontend-private objects. Ordinary CLI and GUI pseudocode exports do not produce sidecars.

unidecompiler template --interactive (short form -i) provides a guided version of the same template export. It only collects host-side template settings and does not affect the decompiler engine or recovery pipeline.

Package Architecture

unidecompiler is an embeddable core library. It has no command-line entry point, no bytecode format parser, and no dependency on a concrete frontend. The repository is a Python package workspace: each distributable component lives under packages/ and can be installed independently.

  • unidecompiler: generic IR, lifting, analysis, structuring, and backends.
  • unidecompiler-cli: optional command-line host.
  • unidecompiler-gui: read-only PySide6 workbench.
  • unidecompiler-gui-sdk: stable, Qt-neutral API for trusted GUI plugins.
  • unidecompiler-export: host-side pseudocode and starter-project exporters.
  • unidecompiler-simulator: optional bounded executor for recovered generic IR.
  • unidecompiler-symbolic: bounded symbolic executor for recovered generic IR.
  • unidecompiler-simulation-host-python: trusted Python runtime host for applications that provide unresolved functions.
  • unidecompiler-plugin-*: independently installable frontend adapters.
  • unidecompiler-all: complete-installation meta-package.

The CLI and other hosts discover installed adapters through the unidecompiler.frontends Python entry-point group. An embedding application can instead create a FrontendRegistry from an explicit plugin collection.

Current Architecture

The decompiler is split into three layers:

  1. External VM frontend plugins parse bytecode formats and submit neutral thin IR.
  2. Core lifts thin VM steps, effects, hints, regions, CFG-like control flow, and recoverable structures.
  3. Backends render the recovered generic IR into pseudocode.

The current frontend pipeline is:

  1. Decode the VM bytecode with the frontend's format decoder.
  2. Convert each decoded instruction into a VMBytecodeStep.
  3. Attach neutral operands, opcode classes, hints, and effect-table results.
  4. Submit the complete step stream through lift_vm_step_function.
  5. Let core produce full, partial, or unsupported generic IR.

After lifting, core may run a fixed-point recovery refinement pass. It can conservatively simplify structured FunctionIR expressions and statements, then re-run generic CFG analysis and structuring whenever a rewrite changes the recoverable shape. This repeats until no safe rewrite remains. This is a core phase, not a frontend or backend extension, and it never replaces an unproven recovery with guessed source structure.

Simulation Architecture

Simulation is an optional consumer of recovered generic IR. It is deliberately decoupled from both core recovery and frontend bytecode execution:

frontend -> core generic IR <- simulator <- CLI / GUI / embedding host

The core does not depend on the simulator, and the simulator does not execute frontend bytecode, VM opcodes, effect tables, or frontend-private decoded models. A frontend may optionally provide a data-only simulation adapter for function lookup and runtime facts. Language-specific lookup, such as Lua names or JVM class/method names, remains owned by that frontend.

The simulator returns structured results for completion, return values, exceptions, unsupported operations, limits, cancellation, and execution trace. See packages/unidecompiler-simulator/README.md for the public library API and frontend query formats.

The optional symbolic executor follows the same ownership boundary. It receives only recovered generic IR and opaque frontend target queries through the host; it never interprets frontend bytecode. The CLI exposes it as unidecompiler symbolic, with JSON symbolic-input declarations and concrete parameter values:

unidecompiler symbolic sample.pyc --function choose \
  --symbolic '{"value":{"sort":"int"}}' --concrete '{}'

The Workbench's Symbolic tab uses the same opaque target listing and presents bounded path constraints, models, return or raise outcomes, and concrete CFG edge IDs. Unsupported IR, solver uncertainty, limits, and cancellation remain visible structured results.

Supported frontend families follow this model:

  • Python .pyc
  • JVM .class
  • Lua chunks
  • .NET CLI assemblies
  • WebAssembly modules

Repository Layout

  • packages/unidecompiler/: embeddable core package, using a standard src/ layout.
  • packages/unidecompiler-cli/: optional CLI host package.
  • packages/unidecompiler-gui/: read-only desktop workbench package.
  • packages/unidecompiler-gui-sdk/: versioned data contracts for GUI plugins.
  • packages/unidecompiler-export/: host-side pseudocode and starter-project exporters.
  • packages/unidecompiler-simulator/: bounded generic IR execution library.
  • packages/unidecompiler-symbolic/: bounded symbolic execution over generic IR.
  • packages/unidecompiler-simulation-host-python/: trusted Python runtime host shared by applications.
  • packages/unidecompiler-plugin-*/: independently installable frontend packages.
  • packages/unidecompiler-all/: complete-installation meta-package.
  • unidecompiler-emojivm-frontend-case/: complete custom-VM frontend analysis and simulator example.
  • unidecompiler-gui-test-plugin/: Qt-free GUI SDK plugin example.
  • opcode_projects/source/<project>: source stress projects.
  • opcode_projects/generate/<project>: generated stress project outputs.
  • simulator_projects/: source fixtures and expected results for generic-IR simulation.
  • docs/: supporting design notes.

The stress corpora are local working data and are scanned by path rather than imported as Python test packages. opcode_projects validates decompiler recovery; simulator_projects validates execution of recovered IR and the frontend adapter boundary.

Installation And Use

The unidecompiler-all meta-package provides the complete CLI, GUI, GUI plugin SDK, simulator, symbolic executor, and all frontend plugins with one command:

python -m pip install unidecompiler-all

Install a published command-line setup with the formats you need:

python -m pip install unidecompiler-cli \
  unidecompiler-plugin-python-pyc \
  unidecompiler-plugin-jvm-class

Run unidecompiler --help to see CLI options. Plugins are discovered through Python entry points, so installing another frontend adds its input formats without changing the core or host application.

The CLI keeps stdout suitable for pipelines. Use -o/--output for one result or --output-dir for a collision-safe batch export; destinations and errors are reported on stderr. Progress is disabled by default, --progress enables TTY auto mode, and --progress always forces the single-line bar on stderr. Install unidecompiler-export with the CLI when using host-side exports and starter projects:

python -m pip install unidecompiler-cli unidecompiler-export
unidecompiler template frontend MyVM -o ./my-vm \
  --author "Your Name" --description "A VM frontend" \
  --requirements "Decode and lift the VM" --suffix .vm --version 1

Add --simulation to include the optional data-only simulator adapter, or --ai-guidance with explicit interpreter/sample files and entry facts to add the static-analysis kit. Both options are disabled by default; generated directories are created atomically and are never overwritten.

The optional simulator command executes a selected function from the recovered generic IR. The frontend chooses how the function query is resolved:

unidecompiler simulate sample.pyc \
  --function bubble_sort \
  --args '[[5, 1, 4, 2, 8]]'

Calls made by the selected function that are not present in the lifted module can be handled by an explicit runtime file:

unidecompiler simulate sample.pyc \
  --function bubble_sort \
  --args '[[5, 1, 4, 2, 8]]' \
  --environment runtime.py \
  --show-host-output

The runtime file is trusted host Python code selected by the user. It is not a sandbox and is loaded by the application-host package, not by core, the simulator, or a frontend. The simulator itself receives only data-only call requests and validated runtime values.

The symbolic command explores multiple feasible paths through a recovered function. Install unidecompiler-all (or unidecompiler-cli together with unidecompiler-symbolic) and declare symbolic parameters as a JSON object:

unidecompiler symbolic sample.pyc \
  --frontend python-pyc \
  --function choose \
  --symbolic '{"value":{"sort":"int"}}'

--concrete supplies JSON values for parameters that should remain concrete, while --max-paths, --max-steps, --max-loop-unroll, --max-call-depth, and --solver-timeout-ms bound exploration resources. Inputs support bool, int, real, and fixed-width bitvec sorts (the latter also requires bit_width). Results are JSON by default; add --format text for a concise report. Each path includes its constraints, model, return or raise outcome, and concrete CFG block/edge trace. A top-level status of completed means exploration finished; unsupported, solver_timeout, *_limit, cancelled, and invalid_request are explicit non-success outcomes.

For the desktop workbench, install the GUI and all bundled frontend packages:

python -m pip install 'unidecompiler-gui[all-formats]'
unidecompiler-gui

The GUI is read-only. Its decompiler workflow uses the public DecompilerEngine facade, while its optional Simulation tab uses the separate public simulator API. Select a recovered artifact to discover targets from the registered frontend, enter a JSON argument array, optionally choose a trusted runtime.py, and press Run to inspect the result and execution trace. The GUI does not implement language-specific target lookup or simulation semantics.

The Symbolic tab uses the same frontend-owned target list. Select a target, review the discovered parameter sorts, optionally enter a concrete JSON object, adjust path, step, loop, and solver limits, and press Explore. The result table shows one row per feasible path; selecting a row reveals constraints, a model, returns or raises, and the CFG edges taken. The GUI only renders the public SymbolicResult; it does not access Z3 state, simulator frames, frontend decoders, or private IR objects.

The GUI also provides a read-only Structure / Hex view. When a frontend can prove an instruction's exact absolute byte range in the opened artifact, the core exposes that neutral ByteRange provenance and the GUI highlights the corresponding bytes. Logical VM offsets are kept separate from artifact byte offsets; when a range cannot be proven, the GUI deliberately does not guess. This view never edits, re-encodes, or executes the original bytes.

VS Code navigation extension

UniDecompiler supports the UniDecompiler Pseudocode VS Code extension. Install it from the Visual Studio Code Marketplace, then export pseudocode with its optional navigation sidecar. Keep the .pse file and the adjacent .unidec.json file together so the extension can resolve pseudocode locations back to proven bytecode instruction facts:

unidecompiler sample.pyc \
  --output sample.pse \
  --vscode-metadata sample.pse.unidec.json

The same paired export is available from the GUI's Export pseudocode with VS Code metadata... action. Ordinary CLI and GUI pseudocode exports remain unchanged and do not create a sidecar unless this option is explicitly chosen.

Pseudocode can be exported from the File menu. Export pseudocode writes the currently selected result to one text file. Export all pseudocode writes every open result that has pseudocode to a directory, using sanitized source basenames and numeric suffixes for collisions. Results without pseudocode are reported as skipped; source paths are never copied into output filenames. Export pseudocode with VS Code metadata... and Export all pseudocode with VS Code metadata... are separate opt-in actions. They write the same pseudocode files plus adjacent .unidec.json sidecars.

The GUI plugin SDK is installed automatically with unidecompiler-gui. Plugin authors can install it directly when developing against the public, Qt-neutral API:

python -m pip install unidecompiler-gui-sdk

See docs/GUI_PLUGIN_DEVELOPMENT.md for the plugin manifest and API contract.

GUI SDK Plugins

GUI SDK plugins are application-layer extensions, separate from VM frontend plugins. They run as trusted in-process Python code and use only immutable snapshots plus host requests. They cannot access Qt widgets, ModuleIR, FunctionIR, decoded bytecode, frontend adapters, simulator frames, or stacks.

Create a plugin with a root plugin.toml:

[plugin]
id = "example.workspace-inspector"
name = "Workspace Inspector"
version = "1.0.0"
api = "1"
entry = "workspace_inspector:register"

[python]
requires = []

Install the SDK for development and install the plugin directory from the GUI:

python -m pip install unidecompiler-gui-sdk

Use context.commands and context.panels to register declarative commands and read-only panels. Installation, update, enable/disable, and removal take effect after restarting the GUI. Plugin dependencies are checked but never installed automatically. The complete API and lifecycle are documented in docs/GUI_PLUGIN_DEVELOPMENT.md.

GUI Plugin Example

The repository includes unidecompiler-gui-test-plugin/, a complete trusted GUI plugin that uses only unidecompiler_gui_sdk. Install that directory from Plugins -> Manage plugins -> Install local folder, then restart the GUI. It adds a read-only workspace panel and commands for refreshing document data and discovering simulation targets. The example does not import Qt, core internals, frontend decoders, or simulator implementation classes.

Custom VM Example

unidecompiler-emojivm-frontend-case/ demonstrates how to add a custom VM without changing the core. It includes a VM format note, a sample artifact, a reference runner, a frontend plugin, and a trusted runtime environment:

unidecompiler-emojivm-frontend-case/
├── chal.evm
├── emojivm
├── runtime.py
└── unidecompiler-plugin-emojivm/

The frontend can be registered through the public registry API:

from pathlib import Path
from unidecompiler import DecompilerEngine

case = Path("unidecompiler-emojivm-frontend-case")
engine = DecompilerEngine.discover()
engine.register_frontend_directory(case / "unidecompiler-plugin-emojivm")
result = engine.decompile_bytes(
    (case / "chal.evm").read_bytes(),
    filename="chal.evm",
    frontend_id="emojivm",
)
print(result.status)
print(result.pseudocode.text if result.pseudocode else "<no pseudocode>")

For generic-IR simulation, use the case's runtime.py through PythonFileEnvironment. The runtime is trusted host code and is not a sandbox. The case README contains the full registration, simulation, and reference-runner workflow. Its test files are local development material and are excluded from delivery.

Development

Repository cloning is required only for development. Create and activate a Python 3.11+ virtual environment, then install the workspace packages in editable mode:

.venv/bin/python -m pip install build -e packages/unidecompiler \
  -e packages/unidecompiler-gui-sdk \
  -e packages/unidecompiler-simulator \
  -e packages/unidecompiler-symbolic \
  -e packages/unidecompiler-simulation-host-python \
  -e packages/unidecompiler-cli \
  -e packages/unidecompiler-gui \
  -e packages/unidecompiler-plugin-lua \
  -e packages/unidecompiler-plugin-python-pyc \
  -e packages/unidecompiler-plugin-jvm-class \
  -e packages/unidecompiler-plugin-dotnet-cli \
  -e packages/unidecompiler-plugin-wasm

Build the core package independently:

.venv/bin/python -m build packages/unidecompiler