In the last post, I talked about testing Laser Vault, my emulator for the laserdisc arcade games of the 1980s. That post was about strategy: what “correct” means when the game is the original program, where the risks are, and how autoplay ended up as the best test I had. This post goes underneath all that. It’s about how the thing actually works, and about the instruments I built to look inside it.

The above image is what it looks like when caught in the act by your family doing all this. My only sane response there was to echo Tony Stark from Iron Man: “Let’s face it, this is not the worst thing you’ve caught me doing.”
Okay, I jest. In actual fact, I was being photographed here for retro game engineering stuff. I think I decided to go with the “convicted felon who would rather be anywhere else” look for the photo-op.
There are two audiences for this post. If you’re a tester who likes reading code, this is the technical track from the last post, with the code showing. If you’re simply curious how a 1983 arcade machine can run on a modern computer, and what it takes to make a forty-year-old program believe it’s still in its cabinet, I’ve tried to make every piece of it followable without a background in either C or assembly. There aren’t many write-ups that go into this sort of detail about the laserdisc games, so I wanted to get it down while it’s fresh.
All the code in this post is from the project, which is open source, or from the throwaway probes I describe along the way.
What an Emulator Actually Is
An arcade board is a small computer built for one job. It has a processor, some ROM chips holding the game’s program, a little RAM, and a handful of other chips: one to make sound, one to draw text and sprites, latches that the buttons are wired to. The processor doesn’t know any of that. All it knows is addresses. It reads from an address and gets a byte back. It writes a byte to an address and something happens. Which chip answers at which address is decided by the wiring on the board, and that wiring is called the memory map.

A laserdisc game adds one more machine: a consumer laserdisc player, sitting in the cabinet, connected to the board by a cable.

The board sends it commands, much like pressing buttons on a remote, and reads back its status. The player’s video goes to the monitor, usually with the board’s own graphics mixed over it.

So an emulator is software that pretends to be all of those parts at once:
- A processor that runs the original program from the original ROMs, one instruction at a time.
- A memory map that routes each read and write to the correct pretend chip.
- The chips themselves: sound, graphics, timers, the latches the controls are on.
- A laserdisc player, which takes the game’s commands and decides which frame of the video to show.
- A clock that keeps all of them in step.
The game can’t see any of this. Remember: it only sees bytes at addresses. If every byte it reads is the byte the real board would have given it, at the moment the real board would have given it, the game has no way of knowing it isn’t in its cabinet. That’s the whole job. The rest of this post is about how hard “every byte, at the right moment” turns out to be.
A Board Is a Switch Statement
Here’s the memory map of Dragon’s Lair, as Laser Vault implements it. This game used a Z80 processor chip.

When you look at this piece of arcade history, you can think of the circuit board as a bustling miniature city, with different districts handling different tasks. Over on the far left, you will notice a smaller, elevated daughterboard, which is often referred to as a piggyback board. This structure houses the EPROM chips, which are those components bearing the white stickers. You can think of this section as the permanent library or archive of the system, holding all the read-only memory that contains the actual programmed instructions for the game.
To locate the Z80 microprocessor, which serves as the active intellect or mayor orchestrating the entire system, just let your eyes travel just to the right of that elevated library. In the lower-middle section of the main green board, you will spot a very distinct, long, black rectangular chip that sits vertically. It’s noticeably larger and longer than the surrounding sea of smaller logic components.
Incidentally, if you ever do get your hands on a cabinet, you can dismantle these things for yourself.

When the Z80 reads an address, this function decides what it gets back:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
static uint8_t mem_read(void *ctx, uint16_t addr) { CineBoard *b = ctx; if (addr < 0xA000) return b->rom[addr]; if (addr < 0xC000) return b->ram[addr & 0x7FF]; if (addr >= 0xE000) return 0xFF; // output-only area switch (addr & 0x38) { case 0x00: // sound chip data; its I/O ports A and B are the DIP switches if (b->ay_select == 14) return b->dsw1; if (b->ay_select == 15) return b->dsw2; return ay8910_read(&b->ay, b->ay_select); case 0x08: return read_joystick_port(b); case 0x10: return read_system_port(b); case 0x20: return b->laserdisc.read_status(b->laserdisc.ctx, b->cycles); default: return 0xFF; // nothing connected } } |
That’s the board. Everything below A000 is the program, from the four ROM chips. A000 to BFFF is RAM. Notice the addr & 0x7FF: the board only has 2 KB of RAM, but it answers across 8 KB of addresses, so the same 2 KB appears four times over. That’s called mirroring, and it’s there because the board’s designers saved chips by not decoding every address line. A game is entitled to rely on it, so an emulator has to do it too.
Above C000, the board only looks at three of the address lines, which is what addr & 0x38 picks out. C008 is the joystick. C010 is the coin and start buttons. C020 is the laserdisc player’s status.
And then there’s a lovely bit of 1983 economy. The sound chip, a General Instrument AY-3-8910, has two general-purpose I/O ports on it. Cinematronics wired the DIP switches to them. So to read its settings, such as how many coins a game costs or whether lives are unlimited, the game selects sound chip register 14 or 15 and reads it. Nobody would design it that way today, and nobody would guess it from the outside. It’s exactly the sort of thing you only learn by reading the code that does it.
Here’s the joystick port it reads at C008:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
static uint8_t read_joystick_port(CineBoard *b) { const CineInputs *in = &b->inputs; uint8_t v = 0xFF; if (in->up) v &= ~0x01; if (in->down) v &= ~0x02; if (in->left) v &= ~0x04; if (in->right) v &= ~0x08; if (in->sword) v &= ~0x10; // Bits 5-7: Space Ace's skill-level buttons (unused in Dragon's Lair). if (in->cadet) v &= ~0x20; if (in->captain) v &= ~0x40; if (in->ace) v &= ~0x80; return v; } |
It starts at FF, every bit set, and clears a bit for each control that’s pressed. The switches are active low: a pressed button connects its line to ground. If you read the last post, this is the other side of the disassembly I showed there, where the game’s first move with this byte is to flip every bit. Get that one detail backwards and the game thinks every button is held down at once, from the moment it powers on.
Every board in Laser Vault has a function like this, and they’re the most bug-prone code in the project. Not because they’re complicated, but because they’re facts with no way to derive them. Each line is a claim about how a circuit board was wired in 1983, and the only way to check it is to see whether the game agrees.
The Heartbeat: Counting Cycles
The processor is the easy part to describe. It’s a loop: fetch the instruction at the program counter, do what it says, count how many clock cycles that took, repeat. The board wraps that loop and adds the things that happen on their own schedule. Here’s Dragon’s Lair‘s:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
void cine_run(CineBoard *b, uint64_t cycles) { uint64_t end = b->cycles + cycles; while (b->cycles < end) { if (b->cycles >= b->next_irq) { // The timer raises the interrupt line and holds it until the CPU // responds. The data bus floats high (FF), which in interrupt // mode 0 reads as RST 38h, the same address mode 1 uses. b->cpu.irq = true; b->cpu.irq_data = 0xFF; b->next_irq += IRQ_PERIOD; } b->cycles += (uint64_t)z80_step(&b->cpu); if (b->cpu.irq_taken) { b->cpu.irq_taken = false; b->cpu.irq = false; } } } |
The Z80 runs at 4 MHz. Every 131,072 cycles, a timer on the board interrupts it, about 30.5 times a second. That interrupt is the game’s heartbeat. It’s what counts down the time windows in the move list from the last post, which is why those windows were measured in terms of “about 30 a second.”
The important word in all of this is counted. Nothing in the emulator asks the computer what time it is. Time is b->cycles, a number that only goes up when the processor does work. The laserdisc player, the sound chip and every timer are all handed that same number. That’s what makes the emulator deterministic: the same ROMs and the same presses at the same cycles give the same game, every time, on every machine. Almost everything else in this post depends on that.
When Counting Goes Wrong
Counting sounds foolproof. It isn’t. M.A.C.H. 3 and Us vs. Them run on a Gottlieb board with an Intel 8088.

When you move from the Cinematronics architecture to the boards Gottlieb designed for their laserdisc games, you’re witnessing a fascinating evolutionary leap in arcade technology. While Dragon’s Lair relied on the older, albeit highly reliable, eight-bit architecture, Gottlieb decided to build their system around the Intel 8088. This is the very same processor that served as the beating heart of the original IBM Personal Computer.
You can think of this shift much like a city upgrading its central transit hub. The Z80 was a fantastic local train director, perfectly capable of reading joystick inputs and telling a laserdisc player which chapter to seek. However, games like M.A.C.H. 3 and Us vs. Them required something more intensive. They weren’t just playing video files; they were generating independent computer graphics, such as targeting reticles and enemy fighters, and overlaying them directly on top of the moving laserdisc footage.

To achieve that seamless illusion of flying a fighter jet through a real photographic landscape, the system needed the wider data lanes and increased processing overhead that the 8088 provided. This board architecture represents that ambition, moving away from simple branching video narratives and inching closer to the kind of complex, multi-layered processing we eventually came to expect from modern computers.
The heartbeat of this bad boy is the video signal: an interrupt at the start of every field, 59.94 times a second. That rate doesn’t divide evenly into the processor’s clock, so the board has to work out, for each field, which cycle it starts on.
My first version did that by taking the current cycle, working out which field it was in, and scheduling the next one. That’s a rounding calculation, and once in a while, depending on exactly where the last instruction ended, the rounding came out one short. The “next” field was the one we were already in, and the processor got a second interrupt about fifty cycles after the first.
You would never see that in the picture itself. Us vs. Them showed no sign of it at all. M.A.C.H. 3 stopped dead with “DISC ERROR”, because that game counts fields to keep itself in step with the disc, and a field that happened twice put its count one ahead of the disc’s. The fix was to stop deriving the field from the cycle and count fields instead:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
// The cycle field n starts on: 59.94 fields a second. static uint64_t field_start(uint64_t n) { return n * GOTTLIEB_CPU_HZ * 1001 / 60000; } ... if (b->cycles >= b->next_field) { ... b->cpu.nmi = true; b->fields++; b->next_field = field_start(b->fields + 1); } |
Field n starts at a cycle computed from n alone, so no amount of rounding can ever make two of them land on the same field.
Two games on the same board, the same bug, and only one of them noticed. That’s a good reason to keep every game you have in your test runs, not just the one you’re working on. The strictest game is the one that finds your bug. I learned quickly that the most painful games to emulate were the ones telling me the most.
Testing the Processors
The processor is the one part of an emulator with a real specification and real test suites, written by people who compared the output against physical chips. Laser Vault has four processor cores of its own, the Z80, the 6502, the 6809 and the 8088, and three of them have independent tests. Each test is a small C program in the repository that runs the suite against the core.
The Z80: Pretending to Be an Operating System
ZEXDOC and ZEXALL, by Frank Cringle, are the classic Z80 tests. They run every instruction over a huge range of inputs, compute a checksum of the results, and compare it with a checksum recorded on a real Z80. The catch is that they’re CP/M programs. They expect an operating system underneath them, at least enough of one to print their results.
So the test harness fakes exactly as much of CP/M as the programs use, which, to my surprise, turns out to be very little:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
memory[0x0005] = 0xC9; // RET, so a CALL 5 returns after we handle it memory[0x0006] = 0x00; // top of memory: 0xF000 (little-endian) memory[0x0007] = 0xF0; Z80 cpu; z80_init(&cpu, (Z80Bus){.read = mem_read, .write = mem_write, .in = io_in, .out = io_out}); cpu.pc = 0x0100; for (;;) { if (cpu.pc == 0x0005) { // CP/M system call if (cpu.c == 2) { putchar(cpu.e); } else if (cpu.c == 9) { for (uint16_t a = z80_de(&cpu); memory[a] != '$'; a++) putchar(memory[a]); } fflush(stdout); } else if (cpu.pc == 0x0000) { // program finished break; } cycles += (unsigned long long)z80_step(&cpu); } |
In CP/M, a program asks the operating system for something by calling address 5, with a function number in register C. Function 2 prints a character and function 9 prints a string. So the harness watches for the processor arriving at address 5, does the printing itself, and lets the RET it planted there send the program back. Jumping to address 0 means the program is done. That’s an entire operating system in a dozen lines, as far as these programs can tell.
Both suites pass, all 67 tests each, which takes about 47 billion (!) emulated clock cycles and a little over a minute. ZEXALL even checks two flag bits that Zilog never documented, which real Z80 software occasionally depends on by accident.
This is a pattern worth stealing for any test harness: find the smallest surface the thing under test actually touches, and fake exactly that. Not a whole CP/M. Two function calls and a return instruction.
The 6502: A Test That Traps Itself
Klaus Dormann’s 6502 functional test (for the Data East and Gottlieb boards) works differently. It’s a 64 KB memory image that runs every documented instruction in every addressing mode. When a check fails, it stops by jumping to itself, forever. When everything passes, it does the same thing, but at one particular address. So the harness only has to notice when the processor stops moving:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
for (;;) { uint16_t pc = cpu.pc; cycles += (unsigned)m6502_step(&cpu); if (cpu.pc != pc) continue; // Stuck on one instruction: the test has finished, one way or the other. char text[32]; m6502_disassemble(&cpu, pc, text); if (pc == SUCCESS) { printf("passed, after %llu cycles\n", cycles); return 0; } ... |
If it gets stuck anywhere else, the address tells you exactly which test failed, and the listing that comes with the test explains what that test checks. It passes after 96,241,367 cycles.
The 8088: Ten Thousand Tests per Instruction
The 8088 runs Dragon’s Lair II, Us vs. Them and M.A.C.H. 3.

The chip’s test suite is the most impressive of the three. Daniel Balsom’s SingleStepTests were recorded from a real 8088: for each instruction, ten thousand runs, each with random registers and random memory, and the exact state of the chip afterwards. The harness sets up the “before”, runs one instruction, and compares against the “after”.
But there’s a subtlety, and it’s a good testing lesson. Intel’s documentation says that some instructions leave some flags “undefined.” The real chip does set them to something, and the recordings captured whatever that was. But no program can rely on undefined flags, and matching them exactly would mean reproducing the chip’s internal circuitry. So the harness masks them out:
|
1 2 3 4 5 6 7 8 9 10 |
// The flags each instruction leaves undefined (Intel's documentation, as // the tests' metadata records them). reg is the ModRM reg field, or -1. static uint16_t undefined_flags(int op, int reg) { const uint16_t A = I8088_AF, O = I8088_OF, S = I8088_SF, Z = I8088_ZF, P = I8088_PF, C = I8088_CF; if ((op >= 0x08 && op <= 0x0D) || (op >= 0x20 && op <= 0x25) || (op >= 0x30 && op <= 0x35)) return A; switch (op) { case 0x27: case 0x2F: return O; case 0x37: case 0x3F: return O | S | Z | P; ... |
Then there’s one more wrinkle. When a division fails, the 8088 calls interrupt 0, which pushes the flags onto the stack. Those flags include the undefined ones. So the undefined bits turn up in memory, too, and the harness has to mask those two bytes of the stack as well. A comparison that’s too strict reports bugs that don’t exist. A comparison that’s too loose misses bugs that do. Getting the oracle exactly as strict as the specification, and no stricter, took as much care as the processor itself.
In the end, all 322 test files pass. Along the way, the tests found genuine oddities of the real chip that no documentation I had access to mentioned. Put a REP prefix in front of a signed division, which no sane program does, and a real 8088 negates the answer. My emulator does too now.
The 6809, which runs Badlands, and the COP421 microcontroller in Thayer’s Quest have no such suite yet. They’re tested only by the games running on them.
The Player in the Cabinet
The laserdisc player is the part that makes these games different from every other arcade machine, and it’s the part with the least documentation. Laser Vault splits it in two. The bottom half is a simple model of any player: where the laser is, and whether it’s playing, paused or searching.
|
1 2 3 4 5 6 |
typedef enum { LDP_STOPPED, // disc not spinning; no picture LDP_PAUSED, // holding a still frame LDP_PLAYING, // advancing one frame every 1/fps seconds LDP_SEARCHING, // moving the laser to a new frame } LdpState; |
A search isn’t instant on a real player, and games notice how long it takes. The model follows how the Pioneer LD-V1000 actually does it: once per video field it jumps half the remaining distance, then creeps up on the target the last couple of frames:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
double ldp_search_time(int from, int to) { int fields = 0, pos = from; while (pos != to) { int remaining = to - pos; if (remaining > 0 && remaining <= 2) { pos += 1; // creep up on it fields += 2; continue; } if (remaining > 0) pos += remaining / 2 > 1 ? remaining / 2 : 1; else pos += remaining / 2 < -2 ? remaining / 2 : -2; fields++; } return fields * FIELD_SECONDS; } |
Halving the distance each step means the search time grows with the logarithm of the distance: about a fifth of a second for a thousand frames, and not much more to cross the whole disc.
The top half is the protocol: the particular language each model of player speaks. Laser Vault speaks five: Pioneer’s LD-V1000 and PR-8210, Sony’s LDP-1000 and its sibling the LDP-1450, and Hitachi’s VIP9500SG. Some take a byte at a time over a parallel connection, some take bytes over a serial line, and the PR-8210 takes pulses on a wire, with the gaps between them carrying the bits.
Watching the Conversation
The LD-V1000 is the easiest to watch, because its commands are exactly like a remote control.

To search, the game types the frame number one digit at a time, then presses SEARCH. Here’s the start of a game of Dragon’s Lair, from the headless tracer I’ll show in a moment. Two coins at 40 and 41 seconds, start at 43:
|
1 2 3 4 5 6 7 8 9 10 |
43.044 s player <- 3F 0 43.094 s player <- 0F 1 43.127 s player <- 2F 4 43.177 s player <- 8F 2 43.210 s player <- 2F 4 43.260 s player <- F7 SEARCH 43.261 s player is searching at frame 306 (status 50) 43.494 s player is paused at frame 1424 (status D0) 43.544 s player <- FD PLAY 43.544 s player is playing at frame 1424 (status 64) |
0, 1, 4, 2, 4, SEARCH: go to frame 1424. The player searches for about a quarter of a second, pauses on the frame, and the game says PLAY.
And here’s what happens a few seconds later, at the first room, if nobody touches the controls:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
52.336 s player <- F7 SEARCH 52.336 s player is searching at frame 1884 (status 50) 52.570 s player is paused at frame 3561 (status D0) 52.637 s player <- FD PLAY 52.637 s player is playing at frame 3561 (status 64) 52.639 s display [0000495 ] [ 00] 57.475 s player <- 3F 0 57.525 s player <- 4F 3 57.558 s player <- 5F 9 57.591 s player <- 6F 6 57.642 s player <- 4F 3 57.692 s player <- F7 SEARCH |
Frame 3963. If you read the last post, that’s the death scene for outcome 08, the outcome for pushing right too early. Doing nothing at all gets you the same fate. You can read the game’s decisions right off the cable.
When the Player Is Too Good
The two hardest player bugs were both about timing, and in both the emulator was, in a sense, too perfect.
Super Don Quix-ote asks the player for the current frame number so it can time its prompts. The player replies with five digits, one per status read. In my first version, the reply started one exchange too late. The game took the status byte before the reply as the first digit, so every frame number it read was nonsense. It didn’t crash. It didn’t complain. It simply never showed a prompt, because by its arithmetic the moment never came. One exchange, a sixtieth of a second, was the difference between a working game and a quietly broken one.
Us vs. Them was the opposite problem. After asking for a search, the game watches for the player’s picture to drop out, as proof that the player took the command, and then for the picture to come back. A real PR-8210 takes a second or more to find a frame. Laser Vault‘s player found it so fast that the picture had already come back before the game started watching. As far as the game could tell, the player had ignored the command, so it sent it again, forever. The fix makes the emulated player worse on purpose:
|
1 2 3 4 5 6 7 |
// After asking for a search, the game waits for the player's picture to // drop out (5807 bit 5) as a sign that the player took the command, then // for it to come back. A real PR-8210 takes a second or more to find a // frame; the player model finds it in a fraction of that, often before the // game starts watching, and the game would try again for ever. So, as // Hypseus does, the picture counts as lost for a second after each SEEK. #define SEARCH_FIELDS 60 |
“Faster” isn’t a free improvement when the other side of the conversation was written for something slow. Plenty of real systems have this bug: a mock that answers instantly hides a race that the real, slower service exposes. Here it was the other way round, and the original program was the one that exposed it.
Data Hidden in the Sound
My favorite player discovery is in M.A.C.H. 3. It’s a flying game, and its targets are moving objects in the film. But how does the board know where a target is in the picture at any moment? The answer: the disc tells it. One of the disc’s sound tracks isn’t sound at all. It’s a data channel, carrying a 1 KB block every 53 frames that says where the targets are. The board has a decoder that fills a buffer from it, and the game reads that buffer a byte at a time. It also checks every block against its own frame count, and if they drift apart, it stops with “DISC ERROR …. STAY PUT!” That’s where the double interrupt I mentioned earlier gave itself away.
The community’s copies of the disc can’t carry that channel as sound, so it comes as a separate file of decoded blocks, which Laser Vault feeds to the game at the right frames. The target data comes to nearly a megabyte, many times more than all the board’s ROM chips put together, so the disc carried it itself. And it’s a good reminder that the disc wasn’t just a video. It was storage.
The Headless Tracer
That trace output came from cine-trace, the tool I used more than any other. It runs Dragon’s Lair or Space Ace with no window, no video and no sound, as fast as the computer will go, and prints what the game does: every command it sends the player, every change in the player’s state, every change to the score display. A minute of game time takes a fraction of a second.
Here’s its main loop, trimmed a little:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// Run in slices of 1 ms, reporting changes between them. uint64_t total = (uint64_t)(seconds * CINE_CPU_HZ); while (board.cycles < total) { // Each button is held from its press time for its length. for (int i = 0; i < press_count; i++) *presses[i].button = false; for (int i = 0; i < press_count; i++) { double t = seconds_now() - presses[i].at; if (t >= 0 && t < presses[i].length) *presses[i].button = true; } uint64_t slice = CINE_CPU_HZ / 1000; cine_run(&board, slice); ldv1000_update(&player, board.cycles); if (ldp.state != shown_state) { shown_state = ldp.state; printf("%8.3f s player is %s at frame %d (status %02X)\n", seconds_now(), ldp_state_name(ldp.state), ldp.frame, player.status); } if (memcmp(shown, board.digits, sizeof shown) != 0) { memcpy(shown, board.digits, sizeof shown); print_digits(); } } |
There’s nothing clever in it, and that’s the point. It’s the same board and the same player the real program uses, with the window taken away. A play-through becomes a command line:
|
1 |
cine-trace lair.zip --seconds 60 --press coin1@40 --press coin1@41 --press start1@43 |
Because the emulator is deterministic, that command prints the same thing every time, on every machine. So a trace is a test. Run it before a change and after, and compare. If the change wasn’t meant to affect Dragon’s Lair, the two outputs had better be identical, byte for byte. When I added Super Don Quix-ote, which shares the LD-V1000 code, that’s exactly how I checked I hadn’t disturbed the other games: the traces for every Dragon’s Lair and Space Ace ROM set came out identical before and after.
You don’t need a test framework to have a regression test. You need a deterministic system and a way to make it print what it did. Two runs and a diff is a regression suite.
Probes: Instruments You Throw Away
The tracer lives in the repository because it’s useful forever. Most of the instruments I built didn’t, and shouldn’t. When I was working out a new game, I’d build a probe: a small, disposable program that links against the emulator’s libraries and prints whatever I needed to know that day.
Here’s one, lightly tidied: the probe I wrote for the last post to capture Dragon’s Lair‘s move list. It runs the board, puts in two coins and presses start, and prints the list in RAM whenever it changes:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
int main(int argc, char **argv) { if (!cine_load_roms(&board, argv[1])) return 1; ldp_init(&ldp, 23.976, 0); ldv1000_init(&player, &ldp, CINE_CPU_HZ); cine_reset(&board, cine_ldv1000_link(&player)); uint8_t shown[48] = {0}; while (board.cycles < (uint64_t)(60 * CINE_CPU_HZ)) { double t = (double)board.cycles / CINE_CPU_HZ; board.inputs.coin1 = (t >= 40 && t < 40.2) || (t >= 41 && t < 41.2); board.inputs.start1 = t >= 43 && t < 43.2; cine_run(&board, CINE_CPU_HZ / 1000); ldv1000_update(&player, board.cycles); if (memcmp(shown, board.ram + 0xE0, 48) != 0) { memcpy(shown, board.ram + 0xE0, 48); printf("%7.3f s frame %5d:", t, ldp.frame); for (int e = 0; e < 3; e++) { printf("\n A%03X:", 0xE0 + e * 16); for (int i = 0; i < 16; i++) printf(" %02X", shown[e * 16 + i]); } printf("\n"); } } return 0; } |
It’s thirty lines. And the first time I ran it, it showed nothing useful at all: the list never filled in. The game was sitting in its attract mode with one credit, because Dragon’s Lair costs fifty cents and I’d only put in one coin. The probe had found a bug in the probe. Second coin added, and there was the list, writing itself an entry at a time, the timers counting down.
The probes for other games grew more elaborate. The one for Esh’s Aurunmilla could print the board’s text layer as ASCII, so I could read the screen in a terminal. The one for Badlands could fire the gun at one exact disc frame, to find where a gunfight’s window opened and closed. The one for M.A.C.H. 3 could stop at a given instruction address and dump the registers. Each did one job, and each was thrown away when the game was working.
A Disassembler in an Afternoon
Reading the game’s code needs a disassembler. The 6502, 6809 and 8088 cores in Laser Vault have one built in, for tracing. The Z80 core doesn’t, so for the last post, to show Dragon’s Lair‘s code straight from the ROM file, I wrote a tiny one in Python. The Z80’s instruction set looks chaotic, but it has a hidden order: split an opcode byte into its bit fields and most of it decodes itself. Here’s the idea at its core:
|
1 2 3 4 5 6 7 8 9 10 11 |
r = ["B", "C", "D", "E", "H", "L", "(HL)", "A"] alu = ["ADD A,", "ADC A,", "SUB ", "SBC A,", "AND ", "XOR ", "OR ", "CP "] def decode(op): x, y, z = op >> 6, (op >> 3) & 7, op & 7 if x == 1 and op != 0x76: # 01yyyzzz: copy register z into register y return f"LD {r[y]},{r[z]}" if x == 2: # 10yyyzzz: arithmetic on A and register z return f"{alu[y]}{r[z]}" ... |
Bits 7 and 6 say what family the instruction is in. For family 1, bits 5 to 3 name the destination register and bits 2 to 0 the source, so 64 opcodes come out of one line. Family 2 is arithmetic: bits 5 to 3 pick the operation, bits 2 to 0 the register. That’s another 64. The other half of the opcode space, plus the prefixed instructions, take a page or two more. The whole thing is about a hundred lines, written in an afternoon, and never committed anywhere.
Treat instruments as cheap. If a question would take an hour to answer by reading, and thirty lines of code would answer it in a second, write the thirty lines, and don’t feel you have to keep them. The repository holds the product. The probes are how you learned what the product needed to be.
Copying the Machine
The last post described how autoplay beats Dragon’s Lair: when a set of moves opens, it copies the whole machine, tries each move on the copy, and runs the copy ahead to see whether Dirk loses a life. That only works if a copy behaves exactly like the original, which the determinism gives us. But there’s a trap in “copy the whole machine,” and it’s a very C-shaped trap.
Here’s what a machine is, as far as autoplay is concerned:
|
1 2 3 4 5 6 |
// A copy of everything that decides what the game does next. typedef struct { CineBoard board; Ldp ldp; Ldv1000 player; } Machine; |
In C, you can copy a struct with a single =. Every byte comes across: the RAM, the processor’s registers, the cycle count, the player’s state. The trouble is that some of those bytes are pointers. The board holds a pointer to the player it talks to. The processor holds a pointer to the board its reads go to. Copy the struct, and the copy’s processor still reads from the original board’s memory, and the copy’s board still sends commands to the original player. The copy looks independent, and isn’t. Run it ahead six seconds, and you’ve just played six seconds of the real game, out of sight.
So every copy is followed by rewiring:
|
1 2 3 4 5 6 7 |
// Point a copied machine's parts at each other instead of at the originals'. static void connect_machine(Machine *m) { m->player.ldp = &m->ldp; m->board.laserdisc = cine_ldv1000_link(&m->player); m->board.cpu.bus.ctx = &m->board; } |
And the copy needs a few more changes before it’s safe to use. It plays with lives that run out, even if the real game has unlimited lives switched on, because losing a life is how the copy reports that a move was fatal. It makes no sound, and it absolutely must not touch the original’s sound buffers. It starts with all the controls released.
Planning runs on its own thread, a few moves ahead of the real game. So before pressing a planned move, autoplay checks that the real game is still in the state the plan expected. It does that with a checksum of everything that decides what the game does next:
|
1 2 3 4 5 6 7 8 9 10 |
static uint32_t state_checksum(const CineBoard *b) { uint32_t h = 2166136261u; // FNV-1a for (size_t i = 0; i < sizeof b->ram; i++) if (i != 0x010 && i != 0x011) h = (h ^ b->ram[i]) * 16777619u; const uint8_t *cpu = (const uint8_t *)&b->cpu; for (size_t i = 0; i < offsetof(Z80, bus); i++) h = (h ^ cpu[i]) * 16777619u; for (int i = 0; i < 8; i++) h = (h ^ (uint8_t)(b->cycles >> (8 * i))) * 16777619u; return h; } |
All of RAM, the processor’s registers and the cycle count. Two bytes of RAM are left out on purpose: A010 and A011, where the game keeps its own copy of the DIP switches. The planning copy has a different lives setting from the real game, so those two bytes always differ, and including them would make every plan look wrong. If the checksums don’t match, the plans are thrown away and made again.
Every exclusion from a comparison is a decision someone should be able to explain. Those two bytes have a comment saying exactly why they’re skipped. An unexplained exclusion is how a check quietly stops checking.
Three Ways to Read a Game’s Mind
The last post told the stories of autoplay game by game. Here’s what three of them look like in code, because the shapes are so different.
Esh’s Aurunmilla: Lie at the Port
The simplest approach doesn’t touch the game at all. It changes what the game reads from its controls. Each scene in Esh’s Aurunmilla has a record in ROM, the game keeps a pointer to the current one at E408, and byte 15 of the record is the right move. So when the game reads its joystick port, autoplay adds that move to whatever the player is holding:
|
1 2 3 4 5 6 7 8 9 10 11 |
static void add_autoplay(const FunaiBoard *b, FunaiInputs *in) { unsigned phase = ram_byte(b, SCENE_PHASE); unsigned scene = ram_byte(b, SCENE_POINTER) | ram_byte(b, SCENE_POINTER + 1) << 8; if (phase < 1 || phase > 3 || ram_byte(b, SCENE_PRESS_TAKEN)) return; if (scene < 0x2000 || scene + 0x20 > sizeof b->rom || (b->fields & 4)) return; uint8_t want = b->rom[scene + 0x15] & 0x3F; if (phase == 1 && (b->rom[scene + 0x11] & want)) return; // wait for the move to mean the move in->up |= (want & 0x01) != 0; ... } |
That (b->fields & 4) is the “nervous player” from the last post. The game only counts a new press, so autoplay presses for four fields and lets go for four, over and over, through the window. And the phase == 1 line handles scenes where the same control means something else if you press it in the first part of the window. Each line in that function is a rule the game taught me.
Badlands: Follow the Script
Badlands plays from a script in ROM, and the game keeps a pointer to the command it’s on. Autoplay reads the command, and if it’s a shot worth taking, fires as the window opens:
|
1 2 3 4 5 6 7 8 9 10 |
static void add_autoplay(const KonamiBoard *b, KonamiInputs *in) { unsigned command = (unsigned)(b->ram[SCRIPT_POINTER] << 8 | b->ram[SCRIPT_POINTER + 1]); unsigned frame = (unsigned)(b->ram[SCRIPT_FRAME] << 8 | b->ram[SCRIPT_FRAME + 1]); uint8_t op = rom_byte(b, command); if (op != 0x06 && op != 0x15 && op != 0x16) return; if (op == 0x06 && !rom_word(b, command + 15)) return; // the shot scores nothing unsigned opens = rom_word(b, command + 5); if (frame >= opens && frame <= opens + 1) in->fire = true; } |
The line with the comment “the shot scores nothing” is the whole story of the points table from the last post, in one line of C: a scene where shooting scores no points is a scene where shooting kills you.
Dragon’s Lair: Try It and See
Dragon’s Lair doesn’t say which outcome is good, so autoplay has to try. Here’s the heart of the trial, the part that decides whether a move was taken, ignored or fatal:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
uint64_t release = m->board.cycles + (uint64_t)(PRESS_SECONDS * CINE_CPU_HZ); bool seen = false; set_input(&m->board.inputs, input, true); while (m->board.cycles < release) { uint64_t slice = release - m->board.cycles; run_until(m, m->board.cycles + (slice < SLICE_CYCLES ? slice : SLICE_CYCLES)); seen = seen || (m->board.ram[INPUT_FLAGS + input] & INPUT_SEEN); } set_input(&m->board.inputs, input, false); if (!seen) return IGNORED; ... while (m->board.cycles < end) { if (lives(&m->board) < start_lives) return DIES; ... |
Look at what it watches. Whether the game saw the press is read from the very flag bytes at A040 that the disassembly in the last post showed being set. Whether Dirk died is read from the lives digit on the score display. Autoplay never looks at the picture. It reads the same RAM the game reads, and the same display a player would.
The IGNORED result was a late addition, and an important one. The game doesn’t read the joystick at every moment, even with a window open. Without that check, a press the game never saw looked like a press that changed nothing, and autoplay would cheerfully decide the move was useless.
The three approaches sit on a spectrum: from changing an input, to reading the game’s plan, to simulating the future. Use the simplest one the game allows. I only built the simulator for the one game that left no other choice.
Making a Chip Talk
One last piece, because it’s the most beautiful code in the project. Us vs. Them has a General Instrument SP0250 speech chip on its sound board, and the last post told how getting it working revealed that M.A.C.H. 3 never speaks at all. Here’s how the chip works.
The SP0250 doesn’t play recordings. It models a human vocal tract. The game sends it a frame of fifteen bytes, and the chip makes a short stretch of sound from it, then asks for the next. The sound starts either as a buzz of pulses, for vowels and other voiced sounds, or as random noise, for sounds like “s” and “sh.” Then it goes through six filters in a row, each of which resonates at one frequency, the way the shape of your throat and mouth makes some frequencies ring. The frame’s bytes say how loud, what pitch, buzz or noise, and how each filter is tuned. This is called linear predictive coding, and it’s the same idea behind the Speak & Spell.
Here’s a sample of sound being made:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// The noise generator, a 15-bit shift register, runs all the time. sp->lfsr ^= (uint16_t)((sp->lfsr ^ (sp->lfsr >> 1)) << 15); sp->lfsr >>= 1; int16_t z0; if (sp->voiced) z0 = sp->pcount == 0 ? sp->amp : 0; // a pulse each period else z0 = (sp->lfsr & 1) ? sp->amp : (int16_t)-sp->amp; // Each filter adds its last two outputs back in, scaled by its // coefficients (16-bit arithmetic, as in the chip). for (int i = 0; i < 6; i++) { Sp0250Filter *flt = &sp->filter[i]; z0 = (int16_t)(z0 + ((flt->z1 * flt->f) >> 8) + ((flt->z2 * flt->b) >> 9)); flt->z2 = flt->z1; flt->z1 = z0; } int out = z0 >> 6; // 13 bits down to the DAC's 7 |
The noise is a shift register: a 15-bit number that, shifted and folded back on itself, produces a sequence that sounds random but repeats only every 32,767 steps. The buzz is a single pulse at the start of each pitch period and silence for the rest. Then six times over, each filter adds a scaled copy of its last two outputs to the sound passing through it. That’s the resonance: a little of the past fed back into the present. Ten thousand times a second, this loop turns fifteen bytes into speech.
The (int16_t) casts matter. The chip did its arithmetic in 16 bits, and when a sum overflowed, it wrapped round. A modern computer would happily keep the extra bits. Doing that would make a sound the chip never made, which is to say, wrong, however tidy it is.
Faithful means faithful to the limits, too. A recreation that’s better than the original is a different thing, and the people who remember the original will hear the difference.
Heuristics for Building a Testable Emulator
If you take nothing else from this post, take these. Most of them apply well beyond emulators.
- Make time a number. If nothing reads the wall clock, everything is repeatable, and every run is a test.
- Count, don’t derive. A schedule computed from rounding will one day round the wrong way. A counter can’t.
- Keep the window optional. If the program can run with no window, no video and no sound, it can run a thousand times faster, and a minute of play becomes a fraction of a second of test.
- Print the conversation. The most useful output was never the picture. It was the commands crossing between the game and the player.
- Diff the traces. A deterministic program that prints what it did gets a regression suite for free.
- Make your oracle exactly as strict as the specification. Mask what’s genuinely undefined, explain every mask, and mask nothing else.
- Watch for “too good.” A faster or more perfect component can break a client that was written for a slower one.
- Build probes freely and throw them away. The repository is for the product; the probes are for learning.
- When you copy something that holds pointers, check what the copy is pointing at.
Insert Coin

When I started Laser Vault, I thought the hard part would be the processors. They turned out to be the best-tested part of the whole thing, because people who came before me had already built the oracles: ZEXALL, Klaus Dormann’s test and ten thousand recordings per instruction from a real 8088. The hard part was everything nobody had written down: a sound chip doubling as a DIP switch reader, a field interrupt that fired twice, a player that searched too fast, a data channel hiding in a sound track.
For every one of those, the game itself was the test that found it. A forty-year-old program, which knows nothing about emulators, turned out to be the most demanding and most honest test suite I had. It never told me what was wrong. It just refused to work until it was right.
And that, really, is what this kind of testing is: building enough instruments to explore and understand why. Notice how this is very much human and tooling working together.