Skip to content

About

Byte-matching decompilation of X-COM: Apocalypse (DOS, 1997, Watcom C / DOS4GW) to document the original engine for OpenApoc. Not a port. AI-assisted with Claude Code, every match verified by byte comparison.

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Repository files navigation

ReverseApoc

ReverseApoc is a byte-matching decompilation of X-COM: Apocalypse (Mythos Games / MicroProse, 1997, DOS). The goal is readable C source that, compiled with the original Watcom toolchain, rebuilds the shipped executables byte for byte. The project exists to help the OpenApoc team understand the original logic, mechanics and bugs, the enemy AI in particular. It is not a port: there is no plan to make the game run natively on modern systems. It is also a proof of concept for AI-assisted reverse engineering with Claude Code, where every result is checked by a deterministic byte comparison.

Important

This repository contains no game assets or code bytes. No original executables, no disassembly, no data files and no compiler binaries are committed. You need your own copy of the game (Steam or GOG) and, for matching, your own copy of the Watcom compiler. Disassembly (asm/) and build output (build/) are regenerated locally from your files and are gitignored. See docs/legal.md.

Progress

UFO2P.EXE progress UFO2P.EXE functions matched UFO2P.EXE fuzzy match

TACP.EXE progress TACP.EXE functions matched TACP.EXE fuzzy match

Progress mosaic

The progress page shows, for each EXE, its three figures (each with a one-line definition), its percent bar and a strip of one tick per game and thunk function in address order, then this mosaic. Once config/mechanics.toml maps the OpenApoc checklist items to the original code, a Mechanics coverage section lists each item and tier with two figures: must match (the functions the item needs, stopping at hub functions many items share) and full closure (every game or thunk function its call tree reaches). A tier reads matched only when status.csv says every must-match function is.

The six badges (per EXE its strict, functions and fuzzy figures, each coloured by its own value), the mosaic, the progress page and the raw numbers (progress.json, as percent, functions_percent and fuzzy_percent) are regenerated on every push to main, together with the combined progress-shield.json, progress-shield-functions.json and progress-shield-fuzzy.json endpoints (kept for consumers, not shown here) and the objdiff-shaped report.json, and published to GitHub Pages. They come from committed tables: the status tables that reverseapoc gate writes (config/ufo2p/status.csv, config/tacp/status.csv; never edited by hand) plus each EXE's functions.csv and units.csv. Each EXE, and both combined, has three figures:

  • strict, the headline and the only one proven by byte comparison: matched bytes of game and thunk functions over all game and thunk bytes plus the uncovered bytes (thunks count as code); the combined figure comes from summed bytes, not an average;
  • functions, the function ratio: matched game and thunk functions over all game and thunk functions, a count, not a size;
  • fuzzy, objdiff's closeness with partial credit: each game and thunk function's best objdiff score (100% when matched, its best when partial, 0 otherwise) weighted by its bytes, over the same bytes as strict; report.json carries the same number as fuzzy_match_percent.

The mosaic has one cell per function, grouped by EXE and unit and sized by bytes:

  • matched: byte-identical to the original, proven by objdiff, the relocation check and the whole-EXE SHA-1;
  • partial: an attempt scored above 0% in objdiff but is not byte-identical yet; it counts for nothing in the strict percentage or the function ratio (only the fuzzy figure gives it credit);
  • unmatched: no gate-written matched or partial row; the rebuilt EXE still carries the original bytes of the function, kept by the in-place splice;
  • library: the Watcom runtime library, not decompiled and outside all three figures; data-in-code is excluded too. Game functions on the owner-signed assembler list (config/asm-functions.csv: hand-written assembly, which cannot match from C) are drawn and counted with the library, outside all three figures, in the badges, the progress page, progress.json and report.json; decomphub.progress.json still counts them as game code;
  • uncovered: bytes inside a unit span that no functions.csv row of any kind covers (runtime and data-in-code rows count as covering), mostly alignment filler between functions. They are in the denominator. The 3-byte tail of UFO2P's code that lies outside every unit is not counted.

Each unit with room for a label carries one: a game unit its unit name (the name its C file takes under src/ufo2p/ or src/tacp/, such as u_00089980), a library unit the name of its largest runtime function (such as __int7, the FPU emulator). A name too long for its unit is cut short and ends with an ellipsis, as long as at least six of its characters still fit. Small units have none.

The strict and functions figures, per EXE, are committed for DecompHub (a percent and a ratio; it has no fuzzy track): Progress data. That file changes only when a gate EXE/UNIT run passes.

How an agent session matches a function

uv run reverseapoc drive runs unattended matching nights. Each attempt at one function is one fresh Claude Code session in a locked-down checkout, and nothing the session claims counts: the driver compiles and compares the bytes itself. In plain terms, Claude writes a recipe (the C), the original compiler bakes it, and only an exact copy of the original counts.

%%{init: {"flowchart": {"wrappingWidth": 260}}}%%
flowchart TD
    A["Step 1: preflight<br/>Docker, CLI checked<br/>clean git tree<br/>asm regenerated"] -->|a check fails| R["REFUSED<br/>nothing starts"]
    A -->|all checks pass| B["Step 2: pick a function<br/>easiest first<br/>Ghidra draft as hint"]
    B --> C["Step 3: context pack<br/>target asm<br/>neighbours and draft<br/>compiler rules"]
    C --> D
    subgraph S["Step 4: one Claude Code session, guardrails on"]
        D["Edit the C<br/>of its one unit file"] --> E["Step 5: attempt check<br/>up to 25 per attempt"]
        E --> F["Watcom C 10.6<br/>compile in container"]
        F --> G["objdiff score<br/>relocation check<br/>splice into EXE copy<br/>whole-EXE SHA-1"]
        G -->|not identical yet| D
    end
    G -->|session ends| H["Step 6: driver check<br/>file audit, C89 lint<br/>full gate"]
    H -->|byte-identical| M["MATCHED<br/>one commit with<br/>explicit paths"]
    H -->|not identical| P["PARTIAL<br/>best C kept as notes<br/>unit reset"]
    H -->|write outside the claim| X["Guardrail stop<br/>strays restored<br/>night ends<br/>nothing committed"]
    P -->|under 3 attempts| C
    P -->|3 attempts| L["Difficult list<br/>retried next pass"]
    classDef matched fill:#008300,stroke:#005a00,color:#ffffff
    classDef partial fill:#eda100,stroke:#a86f00,color:#0b0b0b
    classDef stop fill:#52514e,stroke:#2b2a28,color:#ffffff
    class M matched
    class P,L partial
    class R,X stop
Loading
  1. Preflight. Before anything starts, the driver checks that Docker answers and the build container is in sync, that the Claude CLI is in the tested range, and that the git tree is clean, so every later change can be traced to a session. It compiles the Python tooling once (sessions never write cache files) and regenerates the disassembly of both EXEs. Any failure is a refusal: nothing runs.
  2. Pick a function. The picker takes small, easy functions first, alternating UFO2P and TACP, at most two per unit, and skips anything already matched or on the difficult list. Ghidra adds a rough C draft for each pick as a hint (some picks have none).
  3. Build the context pack. One file per function with what a session needs: the target disassembly, the neighbouring functions, the Ghidra draft and the Watcom coding rules.
  4. One Claude Code session. The driver starts a fresh claude -p session with the pack and the watcom-decomp skill. Guardrail hooks deny almost everything: the session may edit only its one unit file (for example src/ufo2p/u_00089980.c), add a header or write its notes, and it may run only three commands: attempt context (re-read the pack), attempt asm (read the target disassembly) and attempt check. A canary run proves the hooks deny before any night starts.
  5. attempt check. Inside the container, the unit is compiled with the original Watcom C 10.6 compiler and flags (-4r -s -fpi). objdiff scores the new function against the original, a relocation check confirms every reference lands on the same target, and the function is spliced into a copy of your own EXE, whose SHA-1 must equal the retail file's. The session reads the result, edits the C (a type, the statement order, a loop's shape) and checks again, at most 25 times per attempt.
  6. The driver checks the work. When the session ends, the driver audits every file it touched (a write outside the claim stops the night), lints the unit as C89 and runs the full gate in the container. Only the gate's exit 0 makes a match.
Result What it means What the driver does
MATCHED The C compiles to the original bytes: objdiff 100%, the relocations agree, and the spliced EXE keeps the retail SHA-1. One commit with explicit paths (the unit, status.csv, decomphub.progress.json, progress/attempts.jsonl), marked Matched-By: reverseapoc drive. It counts in the progress figures.
PARTIAL The C scores above 0% in objdiff but is not byte-identical yet. The best C is kept under build/ with its score, the unit is reset, and the function goes back in the queue. After 3 attempts it moves to the difficult list for a later pass. It counts for nothing in the strict percentage or the function ratio; the fuzzy figure gives it partial credit.

On the first pilot night (run 20261026-005209), 40 sessions gave 3 MATCHED and 37 PARTIAL; the closest partials reached 87.5% and 87.2%. At the end of every night the driver rebuilds both EXEs, checks their SHA-1s and re-runs the gate on every match before it commits the attempt log.

Details (flags, the refusal reasons, the canary, reset rules and the console): docs/workflow.md.

Supported versions

Only the DOS release is supported. Steam and GOG ship byte-identical game executables, so either store works (Steam is preferred, GOG is the fallback).

File Role Size (bytes) md5 Scope
UFOEXE/UFO2P.EXE Main game program, module ufo24 1,705,790 d2e874a76344cb9c48afce57357aab31 Target
TACEXE/TACP.EXE Tactical program, module tac4 3,161,594 644382a6caa787cff613c50a170b5152 Target
UFOEXE/SMKP.EXE Smacker video player 271,436 2d7933f255348a60536d811391b461ba Deferred
XCOMAPOC.EXE 16-bit launcher 18,171 867f7997fae1714313b873f15359c4fb Out of scope

SHA-1 values go into the hash manifest in Phase 2. SETUP.EXE (Borland C++, 1991) is out of scope. The disc image that both stores ship (cd.iso on Steam, CD/XCOM.BIN on GOG, byte-identical) contains the original v1.00 builds: UFO2P.EXE 1,702,206 bytes with CRC32 0x4749FFC1, the value OpenApoc's extractors expect, and TACP.EXE 3,170,298 bytes. The installed files above are a later build. OpenApoc reads from the disc image, so its offsets describe the CD build; this project targets the installed build first and records both (docs/toolchain.md).

A Windows 95 release also existed. On what it contains, OpenApoc community member FilmBoy84 recalls:

Yeah, IIRC the Win9x version is just an installer wrapper. Game beneath is identical and just uses DOS 7.x that's part of Win9x instead of DOS 6.2x that was the last packaged version of MS-DOS

(OpenApoc Discord, FilmBoy84, 2026-10-15, message link)

This is a recollection, not a verified fact. Hashing a retail Win9x executable against the DOS one is welcome future work.

Approach

ReverseApoc uses a hybrid solution: our own matching oracle, plus decomp.wtf's "fork mode" as the agent layer. (Phase 6 update: spike 006 INVALIDATED fork mode (plan 06-01), so the agent layer is our own (D-03, D-04); see the notes below.)

The oracle (ours, assembled from reusable parts):

  • The original Watcom C 10.x wcc386, supplied by you, runs under wibo inside a Docker image.
  • A Python configure.py generates a ninja build with per-file compiler flags.
  • A Python LE splitter reads the executables. Matched functions are spliced in place into a copy of the original EXE, and a whole-EXE SHA-1 check gates every build. The DOS/4GW Professional kernel stays an opaque blob taken from your own file.
  • An OMF-to-ELF converter lets objdiff diff Watcom objects and produce progress reports.

The agent layer:

  • decomp.wtf v0.10.6 in fork mode, pinned and lightly patched, installed in WSL2 and run as a separate tool (a subprocess, never imported). Fork mode accepts a local repo that builds with configure.py + ninja and produces objdiff.json. It judges candidates with objdiff and brings function claims, MCP tools, guardrails and a dashboard. (Not used in Phase 6: spike 006 INVALIDATED fork mode (06-01). The agent layer is our own (D-03, D-04), with no MCP server (D-44); agents call the Bash-allowlisted attempt wrapper. decomp.wtf is re-evaluated for Phase 7.)
  • Our driver re-verifies the bytes before any commit. An agent never judges its own match.
  • If objdiff cannot diff the converted objects cleanly (a Phase 5 spike), the fallback is our own minimal agent layer: an MCP server, claims and guardrails, Claude Code only. (Phase 6 update: objdiff diffs the converted objects (spike 005 VALIDATED), but spike 006 INVALIDATED fork mode, so Phase 6 builds its own agent layer anyway: the driver, the guardrail hooks and the context pack, with no MCP server (D-44); agents call the Bash-allowlisted attempt wrapper.)

The order of work follows Abahbob's rule: get a byte-identical rebuild of raw bytes first, decompile second.

Projects we build on

Project License What we use
KKND-Decomp CC0-1.0 LE reader, in-place page splice with SHA-1 gate, function discovery tools
Theme Hospital decomp GPLv3 omf2elf.py, the configure.py + ninja + objdiff pattern; the reference for a 100% matched Watcom 10.6 DOS/4GW game
NHL95PC No licence file; used with the owner's permission, with attribution tools/reverseapoc/search.py adapts the declaration pattern and the type-move loop of its tools/permute.py, the block search of tools/scope_decls.py and the seeded shuffles of tools/permute_decls.py; tools/reverseapoc/classify.py adapts the register pattern of its tools/match/classify_diff.py
retrokit Apache-2.0 Headless Ghidra LE loader, Watcom calling conventions, function export, MCP server (external tool) (not used: Phase 5 runs Ghidra headless in the Docker image with the lastonesky loader)
decomp.wtf MIT (archived 2026-10-15) Agent harness, claims, guardrails, dashboard; used in fork mode as a separate tool (not used in Phase 6: spike 006 INVALIDATED fork mode; the agent layer is our own, D-03/D-04)

Files copied from Theme Hospital keep their copyright headers. That is why tools/ is GPLv3.

Toolchain summary

  • Compiler: Watcom C 10.6 wcc386 (C, not C++) under the default rule -4r -s -fpi. The runtime library is 10.6 byte for byte; on game code 10.5, 10.6 and 11.0 stay tied after the two Phase 5 leaf matches, and later matches are logged to separate them (see docs/toolchain.md).
  • Extender: DOS/4GW Professional, bound into each EXE (LE format, two objects, no debug info).
  • Analysis: Ghidra 12.1.4 with the lastonesky ghidra-lx-loader, headless in the Docker image.
  • Diff and reports: objdiff, behind an OMF-to-ELF converter.
  • Tooling: Python 3.14 with uv, everything build-related in Docker.

Details, evidence and pinned versions: docs/toolchain.md.

Getting started

Bring your own files. Nothing below downloads the game, and the compiler download is a separate opt-in command (see the end of this section).

  1. Copy .env.example to .env and point APOC_STEAM_FOLDER (or APOC_GOG_FOLDER) at your install.
  2. uv run reverseapoc ingest copies the executables into the gitignored orig/ and checks their hashes.
  3. docker compose build
  4. docker compose run --rm build uv run pytest
  5. git config core.hooksPath .githooks enables the tracked commit-msg hook, which adds a Co-Authored-By trailer to commits made under Claude Code (including GSD's own commits and merges). Override the value with git config reverseapoc.coauthor "Name <email>".

docker compose run --rm test runs the test suite in the container in one step. To get the original Watcom compilers, run uv run reverseapoc fetch-watcom and then uv run reverseapoc install-watcom (opt-in, at your own responsibility); they land in the gitignored third_party/watcom/. Sources and details: Getting the Watcom compilers.

Workflow and ceremonies

Sources and motivation

Date Author Post
Feb 2023 Abahbob Starting a decomp project
2025-05-20 (archive date) Macabeus Development Journey on Game Decompilation Using AI
2025-05-20 Macabeus Using AI for Matching Decompilation of an Entire Module
2025-07-26 Macabeus Building an AI-Powered VS Code Extension for Decompilation
2025-11-04 Chris Lewis Using Coding Agents to Decompile Nintendo 64 Games
2025-11-27 Chris Lewis The Unexpected Effectiveness of One-Shot Decompilation with Claude
2025-12-06 Simon Willison Summary of Lewis's one-shot work
2025-12-23 Chris Lewis Finding Jingle Town: Debugging an N64 Game without Symbols (blog index)
2026-02-16 Chris Lewis The Long Tail of LLM-Assisted Decompilation
2026-03-10 Macabeus Can LLMs Really Do Matching Decompilation? I Tested 60 Functions
2026-05-17 Chris Lewis Snowboard Kids 2 is 100% Decompiled (blog index)
2026-06-21 Chris Lewis Snowboard Kids 2 is Recompiled (blog index)
2026-07-07 Macabeus Fable 5: Does the smartest LLM decompile better?
2026-08-17 Macabeus Starting a Decompilation Project from Zero: Claude Code and 51% of a 2001 GBA Game
2026-08-26 Chris Lewis Decompiling a Nintendo 64 Game in 84 Days
2026-10-02 Chris Lewis Introducing Snowboard Kids: Recompiled (blog index)

The Substack archive gives the same date for Macabeus's first two chapters, so chapter 1 may be older.

Reference repositories

  • AnyPS5: README layout, progress mosaic and badge pattern (a porting tool, not a decomp).
  • sm64 and pokered: SHA-1 tables and the bring-your-own-files convention.
  • augustus: a reimplementation of Caesar III, not a decomp; README structure only.
  • awesome-game-decompilations, awesome-decomp-games and PCGamingWiki's list of unofficial ports: where to look for comparable projects.
  • NHL95PC: Watcom 10.0 DOS game, compiler pinned by library matching.
  • Blood-RE: matching decomp built with Watcom 10.6 and TASM 3.1.
  • f15se2-re: F-15 Strike Eagle II, the only MicroProse DOS reconstruction found (Microsoft C 5.1).
  • fnv_decomp: Fallout: New Vegas, matched function by function with MSVC 2005/2008 by about 50 volunteers' AI agents through a claim server (fnvdecomp.com; agent rules at f3decomp.com/agents.md). What we take from it: research note.
  • OpenApoc: the reimplementation this project serves.
  • OpenXcom: reimplementation of UFO: Enemy Unknown and Terror from the Deep, not Apocalypse.

No Watcom matching decomp of an X-COM title turned up in the lists searched. ReverseApoc is the closest found, not a claimed first.

Tools and skills

Verdicts in short; the full table with reasons, install method and status is in docs/tools.md.

Area Use Later Skip
Analysis Ghidra, GhidraMCP (Later) REA BSim
Diff and matching objdiff coddog (idea reimplemented now), mizuchi, decomp_settings m2c, decomp-permuter, asm-differ, Kappa (two ideas ported)
C source tools ast-grep, clang-format
Loops Nigel, /goal, autoresearch (pattern) Ralph loop (prototype) Bilevel-Autoresearch
Planning GSD, superpowers, OpenSpec spec format (Phase 8) OpenSpec tool, SpecKit
Understanding Understand-Anything, Groma
Style and review i-have-adhd (chat and comments) Ponytail, pstack, thermo-nuclear review, cpp-pro, Mindrally cpp, Qt agent-skills
Retrieval jina embeddings, Voyage (optional), SQLite + numpy pgvector, RxDB

Directories and tracking

  • recomp.fyi: listed through a .recomp.json at the repo root; it reads an objdiff report.json artifact daily for the strict percentage (matched_code_percent).
  • decomphub.com: submitted through its reviewed form at https://app.decomphub.com/submit once the repo is public (no file format, no badge).
  • decomp.dev: closed to new projects in October 2026, so progress is self-hosted on GitHub Pages.
  • Decompedia: community wiki on decompilation, a place to be listed later.
  • decomp.me: shareable scratches for hard functions (it has wcc10.5, wcc10.6 and wcc11.0). Used sparingly; we do not flood it with agent scratches.

AI use

AI tools, Claude Code in particular, are used openly in this project, from research to decompilation attempts. No AI output is trusted on its own: every match is verified by byte comparison (objdiff, relocation checks and the whole-EXE SHA-1) run by the build, not by the agent. Prior reverse engineering by the OpenApoc community (its data extractors and ongoing Ghidra-based work) is credited wherever it is reused.

License

The tooling under tools/ is GPLv3, because it includes modules from the Theme Hospital decomp with their headers kept. The decompiled C under src/ reproduces copyrighted game logic for interoperability and research and is published to support OpenApoc; no rights in it are claimed and no assets ship with it. The behaviour specs under specs/ (Phase 8) are CC0-1.0, written in our own words for clean-room reimplementation; see docs/clean-room.md. See docs/legal.md.

Contributing

Use GitHub issues for bugs, questions and proposals. Please never attach game files, executables or disassembly to an issue or pull request.

You can also spend your own AI plans on matching nights. This is how.

What you need

  • Your own copy of the game. uv run reverseapoc ingest copies the executables into orig/ and checks their hashes. Nothing copyrighted ships with this repo.
  • The Watcom C 10.x compiler, which you supply yourself (see Getting started).
  • Docker and uv.
  • Whatever plans or keys you choose to spend: a Claude Code plan, the Codex CLI on a ChatGPT plan, the Antigravity CLI on a Google AI plan, an OpenRouter key. You pay for what you use. Nobody else's account is involved.

Setup

  1. Follow Getting started on your own clone, then run docker compose run --rm test.
  2. Run npx @opengsd/gsd-core@1.18.0 --claude --local and git config core.hooksPath .githooks once per clone.
  3. A drive runs only in the main checkout of a clean clone. Do not run two drives at once, and write nothing under the checkout while one runs.

Canaries

Each arm proves its guardrails once, with one real session, before its first night. The arms are in config/driver/backends.toml.

  • A Claude arm: uv run reverseapoc drive --canary --night-model claude-haiku-5-5.
  • A hosted or non-Anthropic arm adds its egress opt-in: uv run reverseapoc drive --canary --night-model deepseek-flash --allow-egress deepseek-flash.
  • A Codex or Antigravity arm will not run in a night until its canary has passed on your installed CLI (build/driver/canary/<arm>.json).

A budget-sized drive

Say what you want to spend, in your plan's own terms, with --arm-budget NAME=BUDGET. BUDGET is <p>%week, <p>%5h, $<usd> or <n>credits. The driver turns it into a session cap from config/driver/quota.toml. The cap is never above the arm's max_sessions. Quote a $, or your shell will eat it.

Always look at the dry run first. It spends nothing:

uv run reverseapoc drive --dry-run --arm claude-haiku-5-5=10 --arm-budget claude-haiku-5-5=5%week \
  --arm deepseek-flash=1 --arm-budget 'deepseek-flash=$10' --allow-egress deepseek-flash \
  --max-sessions 200

It prints one line per budget: the budget, the quota one session takes (with its source and date), the sessions it buys and the matches to expect at the measured rate. Here: 166 Haiku sessions (about 8.5 matches) and 33 DeepSeek sessions (about 13, from a small sample). The same command without --dry-run runs the night. The figures are rough; see Quota per session. Claude Code users can ask the drive-budget project skill to build the command from a plain request.

Egress

A non-Anthropic or hosted arm sends disassembly of the game off your machine, to that provider. The driver refuses such an arm unless you name it with --allow-egress ARM. Opt in only if you accept that. A local model (qwen-local) sends nothing.

How results come back

  • A match is a commit the driver makes on your clone, after the container gate proved it byte for byte. Open those commits as a pull request.
  • CI and the owner re-run the gate on every function. Bytes prove a match, never an agent's word.
  • Credit goes in the commit trailers: Matched-By names the driver (and a hosted arm's model), Co-Authored-By names each model that held the function, and the commit author is you.

Rules that apply to you

  • Never commit game files, disassembly or build output (orig/, asm/, build/, third_party/).
  • Never edit orig/, the hash manifests under config/ or the build scripts to make a check pass.
  • A matched function stays byte-identical: no restructuring, no inline assembly to force a match.
  • Claude Code is the agent runtime here. Codex CLI and Antigravity CLI may run only as reverseapoc drive sessions. Commit no config for other runtimes (no .codex/, .gemini/ or AGENTS.md).
  • No secrets in tracked files. Keys live in your user environment.

The full rules are in CLAUDE.md.

About

Byte-matching decompilation of X-COM: Apocalypse (DOS, 1997, Watcom C / DOS4GW) to document the original engine for OpenApoc. Not a port. AI-assisted with Claude Code, every match verified by byte comparison.

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages