A 1997 DOS game, reverse-engineered and ported in under a day
One person, one AI coding agent (Claude Code: Claude Mythos for the security analysis and the design, Claude Opus 5.5 for the implementation), roughly 30 mostly one-word chat messages. From the question “does PYRO.EXE have any security vulnerabilities?” to a faithful, pixel-exact browser port with every menu, the help screen, the credits and a joystick-to-gamepad mapping. No DOSBox, no emulation, the game logic rewritten from the disassembly.
▶ Play it in the browsermainThe game
PYROMANIA is a Bomberman-style freeware game by Christoph Brzozowski (“Software 32”, 1997):
up to four players at one keyboard, monsters, eight power-ups, six graphic sets, MOD music.
It is a 32-bit protected-mode program on the DOS32 extender, hand-written assembly,
with its assets packed in a custom archive (PYRO.DAT, “VLIB” with an LZ variant called “PACK”).
How detailed was the briefing?
Not very. These three messages, plus a one-line “build it in Rust”, are the entire functional specification Felix wrote:
Everything else (architecture, file formats, timing, which bugs of the original count as bugs, test strategy, review loops) came out of the agent’s own process: a brainstorming phase with multiple-choice questions, a written design spec, and one written implementation plan per stage, each of which Felix approved with a single word.
Every decision Felix actually made
| Decision | Felix’s answer | Asked by |
|---|---|---|
| Approach: emulate, transpile, or convert assets + rewrite logic? | C (convert assets ahead of time, rewrite the logic in Rust) | agent, multiple choice |
| Execute plans with one sub-agent per task, or natively in one session? | native | agent |
| The original has real bugs (e.g. the Flash power-up almost never detonates bombs because it reads the wrong array). Fix or keep? | Fehler korrigieren (Empfohlen): fix clear bugs, keep timing, RNG sequence and quirks | agent, multiple choice |
| Feature branches? | “just keep working on main” | agent |
| Publish it? | “always roll out the current WebAssembly to becker.koeln/ai/pyromania/” | Felix |
| Networking? | “ne netzwerk lassen wir”: no networking | agent offered |
How autonomous was it?
Felix sent about 30 messages in total. Roughly half of them are one-word approvals
(ja, passt, los, jo mach weiter).
Between two approvals the agent typically worked for one to three hours without input:
dispatching research agents, writing a plan, implementing it test-first, running an independent
review, fixing the findings, and deploying.
| Kind of message | Count | Examples |
|---|---|---|
| direction | ≈ 11 | security question, port request, Rust, approach C, “fix bugs”, deploy target, no networking, this page |
| approval / continue | ≈ 15 | “ja”, “passt”, “los”, “das passt”, “fja passt”, “ok mach weiter nun”, “status?” |
| bug report from playing | 1 | “Kann es sein, dass die Intro-Musik zu früh endet/loopt?” (is the intro music looping too early?) |
| feedback | 2 | “es funktioniert hervorragend” (works excellently), “das Spiel fühlt sich schon sehr gut an” (already feels very good) |
The agent recorded every judgement call it made on its own as a ruling with “what it costs if I’m wrong”, and listed all of them at the end of each stage (11 in stage 4, 16 in stage 5). Examples: preserving the original’s double-buffered sprite table so that collisions see positions from two frames ago; reconstructing the 6-bit palette from the 8-bit asset, after checking that the inverse is exact for all 64 levels; reproducing the help screen’s page-turn order from the disassembly rather than from its own earlier (slightly wrong) analysis notes.
The one bug the human found
Felix noticed the menu music restarting too early. The agent checked the MOD files and found that almost every
song ends with a Bxx “jump to order” effect: BOMBT2 plays orders 0–23 once and then loops from
order 6, so the intro is heard only once. The build had rendered one pass to OGG and looped the whole file.
The fix was tools/modloop.py, a small MOD timing simulator (speed/tempo Fxx, Bxx,
Dxx, pattern delay EEx) that computes the loop point. The Nix build asserts that the
computed song length matches the rendered WAV within 0.5 s, and the browser sets
loopStart/loopEnd. BOMBT2 now loops 35.34 s → 150.54 s. While checking the build
output, the agent also noticed that a newly added JS file was missing from the web bundle, which would
have broken the page, and fixed that before deploying.
Timeline
Times are CEST, from the session log and git history. Filled dots are messages from Felix.
“Does PYRO.EXE have any security vulnerabilities?”
Identifies DOS32 v3.3 with an LZ-packed image. Instead of rewriting the unpacker, it runs the game headless in DOSBox-X with autosave and pulls the unpacked 221 KB image out of a savestate’s memory blob. Then radare2 disassembly and a security report: no external attack surface, but classic memory-safety bugs in the file parsers (e.g. a zero length nibble in the LZ decoder copies 65 536 bytes past the buffer).
“Port it to WebAssembly, no DOSBox, no emulation.”
Rust, approach C, then “jau / ja / ja / ja / passt” through the brainstorming questions.
Design spec committed: pyro-assets (converter), pyro-core (deterministic game logic, no web deps), pyro-web (wasm-bindgen shell), Nix flake, five stages.
Switches the model from Claude Mythos to Claude Opus 5.5 for the implementation: “probiers noch mal weiter” (carry on).
Asset converter for all eight chunk formats (PIC, BLK, MAP, SPR, FNT, SMP, MOD, TDM), each with bounds-checked parsing and tests against the real PYRO.DAT. 14 commits in 20 minutes. An Opus review and a background security review find three crash or security bugs, which are fixed (see QA).
Builds a screenshot harness: DOSBox-X in Xvfb, scripted with xdotool, captured with xwd/ImageMagick. These captures become the ground truth for pixel tests.
Core with indexed 320×200 rendering at 70 Hz, VGA palette fades, title sequence (757 frames), the menu fire effect, the main menu, and the WASM shell with Web Audio. Pixel-exact against DOSBox. The review finds music staying silent forever if you start with Esc; fixed.
Four parallel research agents map the round’s call graph, round flow, player logic and bombs into annotated pseudocode with addresses. The agent asks whether to fix the original’s bugs.
Plan waiting for approval. Felix is AFK.
“status?” · “das passt”
The playable round: Mode X 320×240 at 60 Hz, bombs and chain reactions, kicks, extras, teleporters, the clock, TIME OUT, pause, follow-up rounds. The review runs a 600 000-frame random-input stress test and finds a stuck audio duck.
“Works excellently. Please always deploy it to becker.koeln/ai/pyromania/.”
scripts/deploy.sh: refuses to deploy with uncommitted changes, runs nix build .#web, rsyncs with checksums, writes version.txt. It ran after every change from then on.
Two Opus research agents reverse the monster AI (0x9906) and the winner screen (0x6d5b). Then: three monster IQ levels, Flash, flamethrower, PICK UP, lightning in CASTLE, the winner screen. The review finds the round music bleeding one second into the winner screen; fixed.
“jo mach weiter”
Three parallel research agents: option menus and the PYRO.CFG format, key setup and the joystick, help, credits and ending (including a DOSBox run that confirmed a hang in the original).
GAME and EXTRA OPTIONS, SOUND SETUP, SAVE CONFIG (the 137-byte PYRO.CFG format, Base64 in localStorage), key redefinition with the original’s swap and “key in use” logic, gamepads as the original’s joystick, the help screen with its pixel-dissolve text, the credits with the author’s burning portrait, the PYRO.TDM text screen, restart.
“Is the intro music looping too early?” Fixed and deployed four minutes later.
A sub-agent records new DOSBox references and settles two open questions from stage 4 on the original (the monster walk frames per direction, and monsters drifting off-grid from round 2). Three new pixel tests are exact on the first run; a counter-test with shifted timing fails, as it should.
The Opus review finds gamepads shifting to other players when one disconnects, plus a calibration dead end. Both fixed test-first.
“No networking.” · “Fix those two small things too.”
One small fix deployed. The second “issue” from the reviewer turned out to be original behavior: the credits routine flushes the BIOS key buffer at 0xd0c, so it stays as is.
“Can you document the whole story?” → this page.
The method
The agent ran the same loop for every stage, using a skill library (“superpowers”) for process discipline:
Reverse engineering: “wörtlich” (verbatim)
The plans declare the analysis documents binding: “take this pseudocode line by line, with all constants”. That is why the port reproduces things a hand-written remake would never get right:
- Timing: 70 Hz in Mode 13h menus, 59.94 Hz in Mode X rounds, one logic step per vsync, no timer.
- Randomness: the original’s Park-Miller RNG (
0x13be8) and its exact call order. Even the help screen’s pixel dissolve consumes random numbers in the original’s planar VGA order (plane 0–3, then row, then column). - Palette fades: they stop one step short, so the help screen is 15/16 as bright as its picture. The port matches.
- Collisions: the original double-buffers its sprite table and swaps at end of frame, so hit tests see positions from two frames ago. Rebuilt.
- Menus: the cursor resets after every submenu; a held ESC can skip two menu levels; MONSTER IQ “GENIUS” can only be set through the config file. All kept.
Test oracles
- Pixel references: DOSBox-X captures of the original (logo, menu, round start, options, help, credits), compared to the port’s render pixel by pixel. Monsters are matched against all 16 walk frames, because their facing depends on the RNG seed.
- Real data: converter tests run against the actual
PYRO.DAT. - Determinism: same seed and same inputs give the same frames, which makes timeline tests like “the ending takes 5 913 frames ±2” possible.
- Browser acceptance: Playwright in Chromium. Keys are pressed with down/up and a 100 ms hold. The canvas is read via
toDataURL, because screenshots can lag behind the canvas.
Toolbox
| Area | Tools |
|---|---|
| Agent | Claude Code CLI. Claude Mythos for the security analysis, the brainstorming, the design spec and the first plan; Claude Opus 5.5 from the start of the implementation on, with Opus as reviewer. 16 sub-agents (research and review). Skills for brainstorming, plans, TDD and executing plans. Engram as persistent memory across context compactions (the session was compacted 4 times). |
| Reverse engineering | DOSBox-X (headless, savestate memory dump), radare2, Python for table and string extraction straight from the program image |
| Ground truth | DOSBox-X + Xvfb + xdotool + xwd + ImageMagick (“shoot.sh” script runner) |
| Build | Nix flake (devShell, .#assets, .#web, nix run), Rust stable, wasm32-unknown-unknown, wasm-bindgen |
| Audio | libopenmpt’s openmpt123 renders MOD to WAV, oggenc encodes; Web Audio for music and 8 SFX channels with the original’s channel mask and volume table |
| Quality | cargo test, node --test, clippy with -D warnings, rustfmt, Playwright MCP |
| Deployment | scripts/deploy.sh: Nix build → rsync over SSH to a NixOS/nginx static vhost, plus version.txt |
The agent ran about 660 shell commands, 85 browser automation calls and 23 memory operations in the session.
QA: what the reviewers found
At the end of each stage the agent packaged the whole diff and handed it to a fresh Claude Opus instance that had not written any of the code. The package included the plan, the spec and the list of the agent’s own rulings. Findings rated critical or important were fixed in one pass, each fix starting with a failing test. Minor findings were listed for Felix.
| Stage | Finding | Severity | Outcome |
|---|---|---|---|
| 1 · converter | A crafted level header overflows a u16 and panics (MAPB FF FF …) | important | fixed, test |
| 1 · converter | A tiny sprite file of about 4 MB of zeros makes the sheet packer allocate 275 GB and abort | important | size limits, test |
| 1 · converter | Path traversal through archive entry names (from the background security review) | security | names restricted to plain file names |
| 2 · web shell | Starting with Esc or Shift leaves the AudioContext suspended, so there is no music for the whole session | important | resume on next gesture, test |
| 2 · build | Any README edit rebuilt all assets and the WASM | important | flake file sets |
| 3 · round | Pause in the same frame as round end leaves the audio ducked to 25 % forever. Found by a 10 × 60 000-frame random-input stress test. | important | fixed as the original does it, test |
| 3 · round | ESC during TIME OUT ignored, unlike the original | important | fixed, test |
| 3 · round | Firefox on Linux: P1’s bomb key (right Ctrl) plus P2’s bomb key (Q) is Ctrl+Q, which quits the browser | important | documented; keys can be remapped since stage 5 |
| 4 · winner | Round music plays about 1 s into the winner screen | important | fixed, test |
| 5 · gamepads | When a pad disconnects, the remaining pads shift onto other players | important | slot-stable mapping, test |
| 5 · gamepads | Calibration box can never be left if the pad vanishes | minor → upgraded | falls back to “JOYSTICK NOT DETECTED!” |
| 5 · ending | “An early key press during the credits is lost” | reviewer wrong | the original flushes the key buffer at 0xd0c, so the port was already right |
No review found a critical issue. All tests, clippy and rustfmt were green at every stage boundary.
Bugs that never shipped
Bugs of the 1997 original, fixed on purpose
Felix chose “fix clear bugs, keep the feel”. Each fix is marked // Korrektur: in the code (24 places).
The game’s timing, RNG sequence and quirks stay untouched. Some of them:
- Flash was supposed to detonate bombs in its path, as the manual says, but read the wrong array and almost never did.
- When P2–P4 ran out of Flash charges, the game locked player 1’s bomb key instead of their own.
- Monsters kept their step counter across rounds and could walk off the 16-px grid from round 2 on. Confirmed in DOSBox.
- A key press in the last phase of the credits was never read from the BIOS buffer, so the fade restarted every frame and the program hung on a grey screen. Confirmed in DOSBox, see below.
- The credits’ ember filter wrote up to 11.6 KB past its 64 KB buffer.
- The default joystick sensitivity produced a dead zone of 0, so the player walked constantly.
- The key-name table calls both 0x15 and 0x2C “Z” (there is no “Y”), and calls PrtScr “/ KEYPAD”.
- Placing hidden extras could loop forever on a full map. The quicksort read before its array. Player 4 used the wrong sprite base.
Bugs the port could have inherited, but didn’t
The security report listed unchecked LZ match lengths, unchecked allocations and file-controlled table indices
in PYRO.EXE. The Rust converter parses the same formats with explicit bounds and size limits, and the
reviews above stress-tested exactly that with crafted inputs. The browser never parses PYRO.DAT at all,
only the pre-converted PNG/JSON/OGG files. The saved config from localStorage is length-checked,
magic-checked and clamped field by field.
Screenshots
Left: the original in DOSBox-X. Right: the port’s core rendering the same frame. These pairs are the actual pixel tests in crates/pyro-core/tests/reference.rs.










PYRO.TDM, rendered as HTML with a CP437 → Unicode map, followed by a blinking C:\>Honest notes
- Gamepads are covered by unit tests only. No real controller was attached during development.
- In the last stage several tests were written in the same step as the code, so the agent didn’t see them fail first. Its ledger says so.
- Reviewers can be wrong. One stage-5 finding contradicted the disassembly, and the agent kept the original behavior after checking.
- The analysis notes contained mistakes too, e.g. the order of page turning in the help screen. Where note and disassembly disagreed, the disassembly won, and the rulings record that.
- “Under a day” is wall-clock time including waiting for approvals and hours with the human AFK, not a benchmark.