PYROMANIA (1997) · DOS32 · x86 assembly → Rust · WebAssembly

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 browser
≈ 21.7 hfirst prompt to last fix, including ≈ 6 h with the human AFK
68commits on main
15.5klines of Rust (+ ~1k lines JS)
358automated tests (323 Rust, 35 JS)
3.8klines of reverse-engineering notes with addresses
16sub-agents: 11 research, 5 independent reviews
24bugs of the original fixed and marked in code
1bug the human found by playing

The 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:

„kannst du mir bitte sagen ob ich in der PYRO.EXE irgendwelche sicherheitslücken habe? zur untersuchung bist du hier auf nix, du kannst dir alle pakete holen die du brauchst.“
“Can you tell me whether PYRO.EXE has any security vulnerabilities? You’re on Nix here, fetch whatever packages you need.” model: Claude Mythos
„könntest du bitte nach der analyse das in ein webassembly spiel portieren, so dass wir es im browser ohne dosbox zocken können und ohne emulation?“
“After the analysis, could you port it to a WebAssembly game so we can play it in the browser without DOSBox and without emulation?”
„jop, alles aus dem assembly. und die annahme ist richtig mit der flake. erstmal wie das original im multiplayer, später können wir netzwerk nachrüsten“
“Yep, everything from the assembly. And the assumption about the flake is right. Multiplayer like the original first, we can add networking later.”

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

DecisionFelix’s answerAsked 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?nativeagent
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 quirksagent, 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 networkingagent 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 messageCountExamples
direction≈ 11security 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 playing1“Kann es sein, dass die Intro-Musik zu früh endet/loopt?” (is the intro music looping too early?)
feedback2“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.

Oct 15 · 23:39Felix

“Does PYRO.EXE have any security vulnerabilities?”

23:40–00:05agent

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).

23:46Felix

“Port it to WebAssembly, no DOSBox, no emulation.”

00:05–01:09Felix

Rust, approach C, then “jau / ja / ja / ja / passt” through the brainstorming questions.

00:58agent

Design spec committed: pyro-assets (converter), pyro-core (deterministic game logic, no web deps), pyro-web (wasm-bindgen shell), Nix flake, five stages.

01:48Felix

Switches the model from Claude Mythos to Claude Opus 5.5 for the implementation: “probiers noch mal weiter” (carry on).

01:39–01:59stage 1

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).

02:26agent

Builds a screenshot harness: DOSBox-X in Xvfb, scripted with xdotool, captured with xwd/ImageMagick. These captures become the ground truth for pixel tests.

03:10–03:33stage 2

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.

04:45–05:34stage 3 research

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.

05:34–11:27

Plan waiting for approval. Felix is AFK.

11:27Felix

“status?” · “das passt”

11:30–12:14stage 3

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.

13:40Felix

“Works excellently. Please always deploy it to becker.koeln/ai/pyromania/.”

13:44agent

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.

13:46–16:47stage 4

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.

19:08Felix

“jo mach weiter”

19:09–19:41stage 5 research

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).

19:45–20:22stage 5

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.

19:54Felix

“Is the intro music looping too early?” Fixed and deployed four minutes later.

20:19–20:48QA

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.

20:48–20:54review

The Opus review finds gamepads shifting to other players when one disconnects, plus a calibration dead end. Both fixed test-first.

21:08Felix

“No networking.” · “Fix those two small things too.”

21:10agent

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.

21:19Felix

“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:

research agents → annotated pseudocode→ written plan with rulings & “review focus”→ human approval→ TDD: test fails → code → test passes→ commit + ledger line→ fresh-context Opus review→ fix pass, each with a failing test first→ deploy

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:

Test oracles

Toolbox

AreaTools
AgentClaude 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 engineeringDOSBox-X (headless, savestate memory dump), radare2, Python for table and string extraction straight from the program image
Ground truthDOSBox-X + Xvfb + xdotool + xwd + ImageMagick (“shoot.sh” script runner)
BuildNix flake (devShell, .#assets, .#web, nix run), Rust stable, wasm32-unknown-unknown, wasm-bindgen
Audiolibopenmpt’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
Qualitycargo test, node --test, clippy with -D warnings, rustfmt, Playwright MCP
Deploymentscripts/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.

StageFindingSeverityOutcome
1 · converterA crafted level header overflows a u16 and panics (MAPB FF FF …)importantfixed, test
1 · converterA tiny sprite file of about 4 MB of zeros makes the sheet packer allocate 275 GB and abortimportantsize limits, test
1 · converterPath traversal through archive entry names (from the background security review)securitynames restricted to plain file names
2 · web shellStarting with Esc or Shift leaves the AudioContext suspended, so there is no music for the whole sessionimportantresume on next gesture, test
2 · buildAny README edit rebuilt all assets and the WASMimportantflake file sets
3 · roundPause 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.importantfixed as the original does it, test
3 · roundESC during TIME OUT ignored, unlike the originalimportantfixed, test
3 · roundFirefox on Linux: P1’s bomb key (right Ctrl) plus P2’s bomb key (Q) is Ctrl+Q, which quits the browserimportantdocumented; keys can be remapped since stage 5
4 · winnerRound music plays about 1 s into the winner screenimportantfixed, test
5 · gamepadsWhen a pad disconnects, the remaining pads shift onto other playersimportantslot-stable mapping, test
5 · gamepadsCalibration box can never be left if the pad vanishesminor → upgradedfalls back to “JOYSTICK NOT DETECTED!”
5 · ending“An early key press during the credits is lost”reviewer wrongthe 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:

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.

Main menu, original and port side by side
Main menu with the fire effect. Logo and menu box match pixel for pixel; the fire is random, so it is excluded from the test.
Help screen, original and port side by side
Help screen, page 0. The whole frame is identical, including the 15/16 palette fade.
GAME OPTIONS menu, original and port
GAME OPTIONS (stage 5)
Round start, original and port
Round start in GET READY: 320×240 Mode X. Walls, status bar and monster sprites are compared.
Logo screen, original and port
Title sequence, LOGO2 hold phase
Credits first line, original and port
Credits: “THANK YOU FOR PLAYING”
Sequence of DOSBox frames showing the original's credits freezing on a grey screen
A bug of the original, captured by a research agent in DOSBox-X: a key pressed during the last credit line restarts the white fade every frame, and the program never reaches DOS. The port consumes the key and ends normally.
Original round with monsters walking
DOSBox capture used to verify the monster walk frames per direction (≈ 1 350 movements analysed)
Port: player 1 key setup with LEFT KEY set to B
Browser acceptance test: LEFT KEY of player 1 redefined to “B”
The PYRO.TDM text screen rendered as HTML in the browser
After EXIT TO DOS: the original 80×25 ANSI screen PYRO.TDM, rendered as HTML with a CP437 → Unicode map, followed by a blinking C:\>

Honest notes