Three Bugs in a Trench Coat: How Captive Crash-Landed BMAmiga
There is a special kind of quiet that falls when an emulator bug finally dies. No fanfare — just a landing sequence that plays all the way through, a station screen where there used to be a red wall of hex, and a three-week-old debugging dossier that suddenly becomes a historical document.
Captive (Mindscape, 1990 — Antony “Ratt” Crowther’s dungeon crawler in space) now lands on planets in BMAmiga, the bare-metal Amiga emulator this blog keeps chronicling. Getting there took twenty-three instrumented bench flights, one reference emulator conscripted as a witness, more dead ends than I care to admit — and, in the end, four bugs hiding inside roughly forty bytes of 68000 code. This is that story.
The scene of the crime
BMAmiga boots Kickstart, runs Workbench, plays most of the library — but Captive has been its final boss for a while. Last time, the game forced us to emulate the 700 bytes of empty space between floppy sectors before it would even load. This time the title screen worked, the intro worked, you could fly your dungeon-crawler-spaceship around the galaxy… and the moment you tried to land on a planet, the game died. Deterministically. Same crash, same registers, every run.
Except it didn’t die randomly — it died precisely, which is the emulation-debugger’s dream and nightmare at once. And the game — being a Crowther production — didn’t just hang. It showed its own crash screen: a red hex dump of its registers and the code that killed it, printed by the game itself. When your program-under-test ships with a built-in crash reporter, you pay attention to it.
One crucial control experiment: FS-UAE runs the same captiven.adf and
lands fine. Same disk image, same machine configuration. So the game is
legal, the disk is legal, and the fault is ours. Somewhere in BMAmiga’s
68000, Agnus, Paula or CIA heart, a lie is being told.
Everything we proved innocent first
The next several rounds were an exercise in systematic exasperation. Disk debugging on a bare-metal Pi with no debugger means you build the instruments you need: a serial shell, park dumps that snapshot the last 96 disk-register writes, DMA-arm rings, per-instruction PC watches, filtered memory-write watches with ring buffers. We laced the emulator with them and flew the landing again and again. Each round, something new was proven byte-for-byte identical to the reference:
- The MFM encoding of every track: textbook AmigaDOS, verified payload- exact against the ADF for every possible capture rotation.
- The timing and alignment of every disk read: each capture buffer
started
4489 55xx— a sync mark, with the track number itself encoded in the second word. All 192 arms of the landing load, full, complete. - The file walk: the loader mounts the disk directory, finds
fed_MapGenin a 32-entry name table we dumped and diffed against the ADF — perfect — and reads its 26-block chain to a clean EOF. - The delivered data, phase-matched: we caught FS-UAE’s debugger during its own loading screen and saved its RAM. The last decoded block in its buffer was identical to ours, byte for byte.
- The CIA chip-select decode, the depacker (an RNC-style LZSS whose output we verified as valid 68k code), the memory map…
Round after round of innocence. Which, in retrospect, was the point: when everything the machinery touches is clean and the game still dies, the bug lives in the one component you can’t diff against a reference by dumping memory — the CPU core itself.
Bug #1: An illegal instruction that wasn’t
The game’s red crash screen included a memory dump around the crash PC. Transcribed and disassembled, it revealed something beautiful — the landing overlay contains a CPU detection routine:
619C pea $61A8(pc) ; push handler address
61A0 move.l (sp)+,$10 ; ... into the ILLEGAL-instruction vector
61A6 illegal ; 4AFC — deliberate trap into supervisor mode
61A8 movem.l d0-d7/a0-a7,-(sp); the handler
...
61B8 movec cacr,d0 ; 68020+ instruction: traps on a 68000
That’s a classic trick: use ILLEGAL as a trapdoor into a supervisor
handler, then probe a 68020-only instruction. If MOVEC traps, you’re on
a 68000 — the handler re-vectors itself and continues down the
plain-vanilla path. Protection code loves this, because a sloppy CPU
core will mis-execute exactly here.
Ours did. Our decoder dispatched line-4 instructions with
switch (op & 0xFFC0), so the case 0x4AC0 for TAS swallowed the whole
0x4AC0–0x4AFF range — including 0x4AFC, the architecturally-ILLEGAL
encoding. We executed it as TAS with an immediate operand: consumed the
mask word of the handler’s own first MOVEM, and landed execution
mid-instruction on a 0xFFFF — which is a Line-F opcode. The exception
vector 11 at exactly crash_pc we’d been staring at for three weeks was
the fingerprint of this fallthrough, misread by us as “the game’s planted
trap”.
The fix: a proper effective-address legality table for the whole line-4 family — TAS, TST, NEGX, CLR, NEG, NOT take data-alterable addressing only; every other encoding raises vector 4, exactly like the silicon.
Bug #2: The exception frame that lied
The illegal-EA fix was correct — the trap now fired, our tests proved the handler ran. And the game still crashed. Identically.
This was the round that taught the hardest lesson of the saga: when a
fix doesn’t change the outcome, distrust the fix’s neighborhood, not the
theory. The trap fired — but what did it stack? Our exception()
pushed the post-opcode PC ($61A8) instead of the architecturally correct
address of the offending instruction ($61A6). On a real 68000,
illegal/privilege/line-A/line-F exceptions stack the opcode’s own
address. Two bytes of difference — and the game’s handler chain, which
walks that stacked frame, derailed right back into the same crash.
A one-line fix (spc = inst_pc for vectors 4/8/10/11) plus a regression
test that asserts the stacked PC, not just the vector. The test we didn’t
write the first time would have caught the bug the first time.
Bug #3: The trace-driven machine (the big one)
Now the game got further — the exception ring showed the ILLEGAL trap,
then the MOVEC trap, both correct — and then derailed through an odd PC
into the same 0xFFFF. Disassembling the full handler revealed what we
were really up against. After saving context, the routine does this:
61CA movem.l $2(pc),d0-d7/a0-a6 ; restore registers FROM ITS OWN CODE BYTES
61D0 ... ; hand-build a new exception frame
6208 move.l a7,$24 ; frame pointer -> $24 = the TRACE VECTOR
620C ori.w #$A71F,sr ; S | IPL7 | T *** T! ***
It switches on the 68000’s trace flag. On real hardware, with T set, every instruction raises a trace exception through vector 9 — and vector 9 now points at the game’s hand-built frame. The landing sequence runs inside a self-hosted, trace-driven virtual machine: every guest instruction bounces through game code that single-steps, dispatches, and steers execution. Copy protection as an operating system.
BMAmiga’s core had a one-line confession in its header: “no trace-on-every-instruction (T bit is honoured only for RTE)”. A deliberate gap, documented, harmless for every game we’d ever run. For Captive it was fatal: the trace machine never started, the flow wandered into the extension words, and the game slammed into its crash screen.
Implementing T properly meant matching the exact quirks — a newly-set T
skips one instruction before the first trace; SR-modifying instructions
trace only if T was already set; the exception stacks the upcoming
instruction’s PC with the pre-exception SR. We ported the semantics
straight from WinUAE’s MakeFromSR and wrote tests for each corner.
(Our first test even encoded ANDI to SR when we meant ORI — the opcodes
fought back until the end.)
With the T-bit in, the crash screen died. The game printed “Landing successful” — and then sat there. Loading forever. DF0 chattering track 105. Sixteen thousand disk-register writes per second.
Bug #4: Paula’s patience
The new park dump showed the trace machine running (vec-9 storms through
the stub code — beautiful), the game looping on one DMA arm:
25 words, sync $8914, cylinder 0 head 1 — the map read after the
landing file, matching the FS-UAE trace’s parameters exactly.
Two facts closed the case. First: sync word $8914 appears nowhere in
track 1’s bit stream — I checked all 101,344 bit offsets; zero matches.
Second: the reference never matches it either. Its trace shows the read
armed, no sync line, and the game moving on — because Paula with WORDSYNC
set simply waits. No words move, no interrupt fires, until the game’s
own timeout aborts the read and proceeds.
Our disk controller had a “helpful” shortcut: no sync found → fire DSKBLK immediately, pretending the read completed. The game read 25 words of stale garbage, failed its check, and re-armed. Sixteen thousand times per second. The fix was to do what the silicon does — nothing. A no-sync arm now parks the DMA in a waiting state that delivers nothing and interrupts no one, abortable by the next DSKLEN write. The game times out, exactly like on real hardware, and walks on.
The landing completed. Station and all.
What the ordeal taught us
A few of these will outlive the project:
- When a fix doesn’t change the outcome, diff the full architected side effect — frame contents, flags, PC — not just “did the event fire”.
- Only phase-matched snapshots may be compared. Half our dead ends were crash-state-vs-landed-state memory diffs of cells that legitimately change per game phase.
- Protection code is a CPU-core conformance suite. Illegal encodings
as trapdoors, MOVEC CPU probes, trace-driven context machines — the
corners a casual core leaves out are precisely the ones this software
exercises. We’re building the 65,536-opcode sweep now, so no next
4AFCcan hide. - Emulate the hardware’s non-actions too. A invented “completion” turned a benign timeout into a livelock. When in doubt: do what the silicon does — nothing.
And a note for the marketing department (me): an emulator isn’t done when the happy path works. It’s done when it can refuse a sync word 101,344 times in a row without complaining. That’s the bar Captive set, and BMAmiga clears it now — booting Kickstart, landing dungeon crawlers, and running on a Raspberry Pi with no OS underneath.
Captive: los 700 bytes de vacío
covers the first battle with this game; Chasing the
Beam covers a display-side hunt from the
same bench. The full 23-round case file lives in the repo as
captive_help_request.md — written before the last fix landed, because
explaining a bug completely is often the last step before killing it.