JESVS

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_MapGen in 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:

  1. When a fix doesn’t change the outcome, diff the full architected side effect — frame contents, flags, PC — not just “did the event fire”.
  2. 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.
  3. 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 4AFC can hide.
  4. 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.

← volver a posts
↑