Some of the games I loved on my 386 never got a source release. The companies moved on, the toolchains died, and what remains is a couple of hundred kilobytes of 16-bit machine code on a floppy image. For years I assumed that really understanding one of these games - not just running it in DOSBox, but opening it up, seeing how it ticks, and maybe even improving it - was a job for someone with infinite patience and a decade of spare time.
I no longer believe that. Over the last few months I have been digging into a 1991 DOS flight simulator (Chuck Yeager's Air Combat, a childhood favorite) as a long-running side project.
Some approaches work far better than I expected, some are dead ends, and the difference is worth writing down. My tools: C#/.Net and my knowledge of how to push pixels onto a screen using the CPU. In this round, AI was also a big help when it came to the tedious work of analyzing a binary executable piece by piece.
What works: your own CPU emulator, in-process
The first surprise: emulating an old real-mode x86 CPU is not a heroic undertaking. It is a very approachable piece of engineering, and having your own emulator - in your own language, in-process, under your debugger - changes everything.
The whole machine state of a real-mode PC is charmingly small:
-
Memory is an array of bytes. One megabyte, flat,
new byte[0x100000]. Segmented addressing (segment * 16 + offset) looks scary in old books but is one line of code. - The register file fits in a screenful. AX/BX/CX/DX, the index and stack registers, segment registers, FLAGS, IP. Done.
- The instruction set is finite and mostly regular. A fetch-decode-execute loop plus a few hundred instruction handlers gets you to "the game boots". The 8086 has no caches, no pipelines, no protection rings to model - every instruction is just a small pure function over that byte array.
// The heart of the whole thing is not much more than this:
while (running)
{
byte opcode = mem[cs * 16 + ip++];
switch (opcode)
{
case 0x8B: /* mov r16, r/m16 */ ...
case 0xE8: /* call rel16 */ ...
// ... a few hundred friends ...
}
if (pendingInterrupt && interruptsEnabled) DeliverInterrupt();
}
Around the CPU you need a small cast of devices:
- The timer (PIT). A counter that raises IRQ0 at a programmable rate. Games of the era live and die by this interrupt - it drives their frame pacing and their sense of time.
- The keyboard controller. Scancodes in a queue, IRQ1, port 0x60.
-
The VGA adapter. This is the biggest device, but it is well documented: a 256-entry palette DAC, and for this game an unchained, planar variant of the classic Mode 13h - 320×200 with four bit-planes, where the Sequencer's Map Mask register selects which planes a write lands in, and pixel
xlives in planex & 3. Emulating it means modeling a handful of I/O ports and the planar memory layout; rendering means folding four planes back into one linear image. -
DOS itself - mostly
INT 21hfile I/O - can be faked at the interrupt boundary with a few dozen functions. No real DOS needed.
None of this is research; all of it is documented (FreeVGA, the Intel manuals, Ralf Brown's interrupt list). The reward for doing it yourself instead of using an off-the-shelf emulator is enormous: every byte of memory, every port write, every interrupt delivery is your object model, one function call away from your analysis code. You can put a callback on "any write into video memory" and ask which code wrote this pixel? This single question is worth the entire emulator.
The picture above shows my Avalonia UI running the emulator (which is a CLI tool). This is what my emulator looks like after 3 months of tinkering.
A modern CPU also makes brute force respectable: my plain C# interpreter replays about 30 million emulated instructions per second single-threaded. A full 15-minute gameplay session - several billion instructions - replays in a couple of minutes.
What works even better: deterministic recording
The second pillar, and honestly the load-bearing one: make the emulation perfectly deterministic, then record sessions.
Old games are nearly deterministic already. The nondeterminism comes from a short, findable list of sources:
- the timer interrupt arriving "whenever", relative to the executing code;
- keyboard/mouse input arriving "whenever";
- the RNG being seeded from the wall clock or timer phase;
- anything that reads a hardware counter mid-computation.
So you hunt these down and pin them. Interrupts are not delivered "whenever" - they are delivered at an exact, counted instruction boundary. Input events are not injected in real time - they are stamped with the exact instruction count at which they fire. Once every source of variation is indexed against the instruction counter, a gameplay session becomes a small file: the initial state plus a list of (instruction_index, event) pairs.
Replaying that file reproduces the run bit for bit. Same billions of instructions, same memory image at the end, same final framebuffer hash. Every time, on any host.
And here is the beautiful part: playing the game becomes writing a test suite. Fly a mission, eject, watch the parachute from the external camera, wander through the menus - save the session, and you have a regression test that exercises exactly those code paths forever after. My test battery is currently a set of such recordings (now 159 and counting); any change to the emulator (or to the lifted code, below) must reproduce every recording's final state hash exactly. A single wrong flag bit in one instruction handler shows up as a diverged hash within seconds. It is the strongest safety net I have ever had in any project, and it costs nothing but a few bytes of disk space.
The strategy on top: "lifting", supported by decompilation
With a deterministic emulator and a recording battery, you can do something that would otherwise be reckless: replace pieces of the original machine code with native, readable code - one function at a time - and prove each replacement correct.
The workflow looks like this:
- Profile. Count executed bytes per function across the recordings; sort. The hot spots are always fewer than you fear - polygon fillers, line drawers, the flight model integrator, the mission state machine.
- Decode. Disassemble the function; use a decompiler (Ghidra) as a map, but treat the bytes as the ground truth. Write down its exact contract: every register it reads and writes, every global, every byte of stack it touches, its exact instruction count for each path.
- Lift. Implement the same effect in C#, installed as a trap at the function's entry inside the emulator.
- Shadow. For every call during replay, run both: the genuine 8086 code and the C# prediction, and compare the complete effect - every written byte, every output register, even the "garbage" the routine leaves below the stack pointer. Millions of live calls with zero mismatches, on real gameplay data.
- Certify. Finally, replay the whole battery with the lift active and demand the end-to-end result stays bit-identical to the pristine run.
The emulator hosts the game; the game gradually becomes native code; and at every step the original binary itself referees the correctness of your understanding. There is no "I think this is what it does" - either the hash matches or it doesn't. A couple of hundred functions in, the harness has caught wrong assumptions I would never have found by staring at disassembly: an undocumented flag quirk, a routine whose "obvious" output register was actually dead, a comparison whose equality case short-circuits differently.
The game's executable was triple-packed - a linker-level compressor on top of an EXEPACK-style layer on top of the actual code. My first instinct was to identify the packers and reimplement the decompression. The better move turned out to be embarrassingly direct: run the unpacker stubs in the emulator and dump the memory image after they finish. The packer authors already wrote a perfect decompressor; it was sitting right there in the first kilobytes of the file. An afternoon of work instead of a week of format archaeology.
What fights back: friction points
Not everything is smooth sailing. A catalogue of the things that actually cost me time:
Interrupts versus lifting. When you replace a 5,000-instruction span with one native call, the timer interrupt that would have arrived in the middle of that span now has nowhere to land. You can defer it to the end of the span - but this game samples its tick counter at one precise spot in the frame loop, and a tick delivered on the wrong side of that read changes the frame's delta-time and forks the entire subsequent trajectory. Getting a policy for this that provably preserves behavior (and knowing when it can't) was the single hardest design problem of the project - much harder than any individual function.
The hand-written assembly of the era. Compiler-generated code is pleasantly boring. But the hot paths were written in assembly, and those developers did not respect anyone's calling convention: functions passing arguments in whatever registers were handy, two languages' conventions (C and Pascal) mixed in one binary with opposite argument orders, routines that mutate their caller's stack slots.
Self-modifying code (SMC). The renderer patches instruction operands in place - a color byte here, a jump displacement there - as its normal mode of operation. One step further, the projection routine is generated at runtime into a buffer, parameterized by the current zoom scale. You cannot "just disassemble" code that doesn't exist until the game is running. (The emulator saves you again: watch writes into code regions, catch the generator in the act, and prove the generated code is a closed family of templates.)
Executable code hiding in data. A third of the video-memory writes in a session came from code that is not in the executable at all - it lives compressed inside the asset archives and is loaded like any other resource. Mission files carry little x86 routines implementing their victory conditions. Sound drivers are loadable modules. The "binary" you must understand is scattered across the whole game data.
The math of a machine with no FPU. There is not a single floating-point instruction in the image. Everything is fixed-point: angles as binary fractions of a circle, slopes in Q14, sine via lookup tables (with off-by-one sized tables and out-of-range reads that are bounded-wrong on the 8086 but crash a naive port). Multiplication was expensive, so you find shift-add sequences and precomputed tables everywhere; division was dangerous, so you find guards that do double duty - my favorite: the flight model's ±80° pitch clamp turns out to be the divide-by-zero protection for the trigonometry behind it. Misread one of these tricks and your port is subtly, maddeningly wrong.
The payoff: before and after
Why go through all of this instead of just enjoying the game in DOSBox? Because once the emulator understands the renderer well enough to lift it, you can do more than reproduce it - you can refine it. The lifted drawing functions see the game's drawing commands above the 320×200 quantization: polygon vertices, spans, circles, glyphs, before they are crushed onto the coarse grid. Feed those same commands to a modern software rasterizer - true-color, anti-aliased, at 4× the resolution - and you get the original scene, the original geometry, the original palette... just cleaner.
The same scene, seconds apart, switching renderers live in the emulator:
Before - the original renderer (320×200, upscaled):
After - the refined renderer (same geometry, rendered host-side at 1280×960: 4× the resolution at 1:1 pixels, aspect-corrected for the 4:3 screen the original 320×200 mode was physically displayed on):
Before/after pair of screenshots of the same scene: once through the original renderer, once through a "refined" renderer that draws the original game's geometry at high resolution - the payoff of the whole exercise.
Look at the tail fin, the canopy frame, the landing gear struts, the shadow under the fuselage. It is the picture the 1991 renderer was always describing, finally drawn with the pixels it never had.
Why did this become feasible?
The ingredients that make it feasible today, in order of importance:
- Massive help of AI: I used Claude Code with the latest models. What would have taken years before now takes weeks or months (realistically, considering the token consumption limits).
- CPU cycles are free. Emulating a 16 MHz machine at thousands of times real speed makes replay-everything, verify-everything workflows practical.
- Determinism turns gameplay into tests. This is the idea I'd keep above all others - remove the randomness, index every input, and the game verifies your understanding of it, continuously.
- In-process beats off-the-shelf. An emulator you own is an analysis instrument, not just a player.
- The old tricks are learnable. SMC, fixed-point math, mixed conventions - each one is a speed bump, not a wall, once you can watch the code run instruction by instruction.
The hidden treasures are still there in these old binaries - dormant features, cut content, elegant tricks nobody has seen in thirty years. The tools to dig them out fit in a hobby project now. If there is a game from your past whose source was never released: it is more within reach than you think.



Top comments (0)