JESVS

50 fps or It Didn't Happen: State of the Art on a Bare-Metal Raspberry Pi

Some demos are content. Some demos are benchmarks. And then there is State of the Art — Spaceballs, December 1992, first place at The Party — which is both, and which has spent thirty-four years quietly failing emulators that thought they were done.

Today it runs on BMAmiga, our from-scratch A500, end to end: every scene, every cut, the full 2:36, ending on its credit screen — at a constant, locked 50/50 fps, with the emulator core peaking at 12 ms of the 20 ms PAL frame budget. If you missed the introduction to the project: BMAmiga is a bare-metal machine. The Pi’s firmware loads kernel8.img, and the next thing that executes is a 68000 interpreter. No Linux, no scheduler, no compositor — nothing between the metal and Agnus. This post is about why that machine now plays the hardest demo of the OCS era the way an A500 with a trapdoor expansion did.

Every screenshot below is a real frame out of the emulator — Denise’s line replay, copper effects and all, not a capture from original hardware.

The dancer, mid-motion, with the demo’s signature color-echo outlines

Why this demo is the boss fight

If you want to know what SOTA does to an emulator, disassemble it. We did — bootblock and resident code, live out of chip RAM. The list of things it doesn’t use is longer than the list of things it does.

It never uses the OS. After the bootblock checksum fixup, trackdisk.device is never called again. The demo bit-bangs CIAB’s PRB ($BFD100) for motor/select/step and polls CIAA’s PRA ($BFE001) for ready and TRK0 — the loader is the floppy driver. Your emulator’s CIA GPIO, both of them, has to be right down to the timing of the step pulses, because there is no ROM code in between to smooth over your mistakes.

It streams the demo off the disk while playing it. Raw MFM, DSKSYNC $4489, slow mode (ADKCON $4000), chunked DSKLEN, the DSKLEN $FFFF double-write FIFO flush — and then the single hottest loop in the entire production: a DSKBYTR poll (btst #1,$dff01f), hit 212,000 times per traced frame window. Thirty-six tracks of rotoscoped animation come off the floppy continuously, through a sequencer that never stops. Sector-level floppy emulation — the kind that serves 512-byte blocks — cannot even boot this disk. You need the bit stream.

VBL is never used. Its vector still points into Kickstart like a loaded gun that’s never fired. The master sequencer hangs off the level-3 autovector (copper/blitter interrupts), dispatching scene routines by a hook counter (961 copper hooks before the engine even takes over) and chaining work to level 1 by forcing INTREQ $8004 — a software interrupt as a scheduler handoff. Get the interrupt priority, the autovector dispatch, or the INTREQ write-vs-dispatch race wrong by one instruction and the demo quietly desyncs, hangs, or shows you a white screen and nothing else.

And underneath it all: hires and lores 4-plane scenes flipping BPLCON0 $C200/$4200, double-buffered bitplanes, copper lists rebuilt in chip RAM every frame, four Paula channels driving sampled loops straight from DMA (no tracker, no raster IRQs), and code that lives in the trapdoor slow-RAM at $C00000+. It is a tour of the whole chipset with the guard rails removed.

The hand sequence — two-bit duotone, copper-managed

The method: measure, then diff against the masters

We didn’t guess at any of this. The bootblock was disassembled with a capstone-based tool; the staged loader was captured by snapshotting chip RAM N frames in; a register-coverage probe counted every custom-chip write the emulator didn’t recognize. And whenever a device misbehaved, we diffed its semantics against WinUAE and Amiberry source — thirty years of hard-won corner cases, sitting right there in the repo as a read-only reference.

The coverage run came back clean: zero dropped custom-register writes during the demo. But the diff found two real divergences, and one of them is a beautiful trap.

DSKBYTR is a latch, not a register. In WinUAE, the low 8 bits of the raw MFM word persist until the next DMA word reloads them; on read, only the 0x8000 flag self-clears. We were clearing the whole latch and keeping 16 data bits. SOTA’s loader polls those data bits repeatedly between DMA words and expects them stable — exactly the kind of code that works on hardware, works on WinUAE, and falls over on a young emulator. Fixed in disk.c; SOTA’s 1800-frame run came back bit-identical.

DSKLEN arms without DSKEN. The bootblock writes DSKLEN with disk DMA disabled in DMACON, then polls INTREQ’s DSKBLK bit. Our disk code rejected the whole arm on the missing enable — so the block-complete interrupt never fired, and the bench sat at a white screen at 2:20 into the demo. WinUAE arms the countdown unconditionally and gates only the memory writes. Now we do too: the countdown runs, DSKBLK fires, and not one byte lands in chip RAM until DMA is properly enabled.

One more twist before the demo would boot reliably: the embedded disk had to go in after the resets with power-up semantics — drive present, no /DSKCHG edge. A live-insert edge forces a CHNG-clear step loop that shifts the bootblock loader’s timeline just enough to lose the INTREQ-write-vs-level-1-dispatch race. A real A500 booted with the disk already in the drive sees no edge either. The emulator now has both behaviors, each where it belongs: power-up inserts at boot, real edges from the F12 menu’s live swaps.

The wall that wasn’t the renderer

With the demo booting and stable, the bench Pi gave us the real headline problem: 30 fps in the dancer sections. Our serial heartbeat told the story in one line — rend 24 of emu 30: the renderer was eating 24 ms of a 20 ms frame.

Here’s the measurement that made the cause unmistakable: the same core, on the host with a cacheable framebuffer, paints those full-motion scenes in 0.1 ms. The bench took 24 ms. That’s a 250× ratio on identical code. You don’t profile your way through a 250×; you stop reading functions and start reading page tables.

The bench image runs on Circle — the same platform layer BMC64 uses — and Circle’s page tables map the GPU carveout, which is exactly where VideoCore allocates the framebuffer, as Device nGnRE: uncached, unbuffered. Every single paint store was a round trip to DRAM at full memory-latency. A full-motion SOTA scene writes essentially every pixel of a 752×574 display every frame, through mid-line copper changes our Denise replays segment by segment. Do the arithmetic and you get 15–25 ms of pure waiting — which is precisely what the heartbeat said.

So CKernel::Initialize now walks TTBR0 itself at boot — 64 KB granule, the L2 entry covering 512 MB, down through L3’s 8,192 × 64 KB pages — finds the physical pages behind the framebuffer, and remaps them Normal cacheable, borrowing the attribute bits from the kernel image’s own descriptors so the MMU settings stay consistent with the rest of the map. The painting path already did a per-row cache clean to publish finished rows to the scanout — the same discipline our standalone kernel always used — except now there was actually something in the cache worth cleaning.

rend went from 24 ms to 1. Not 10% off. Not a tuning pass. Two orders of magnitude, from one page-table walk at boot.

The receipts

Demoscene culture demands them, so:

  • Constant 50/50 fps through every loop of the demo — 193 of 195 one-second heartbeats at exactly 50, the two outliers ±1 at window boundaries.
  • Emulator core peaking at 12 ms of the 20 ms PAL budget — half the frame still in hand on a Pi that is also running USB input, an OSD, and a web console.
  • make test: 0 failures across the CPU, chipset, host, boot13, USB, SD and IDE suites.
  • PPM byte-identity against the pre-change tree on every screenshot path we track (Workbench 1.3 and 3.1, SOTA), plus a zero-delta bus-arbitration assertion walk. We did not buy this speed with a single cycle of accuracy.

Halftone vortex against the checkerboard — every dot placed by the blitter

The dancer against the navy dither field

Black silhouettes on the cyan-to-yellow gradient field

The purple interlude — motion traces and layered shadow

Run it yourself

The demo is freeware, as demoscene productions are. The disk we verified against is the original release — SKID-tagged bootblock included — and it’s the one we recommend:

  • State of the Art on Pouët — the canonical page, with mirrors and twenty years of commentary.
  • Direct download (DMS) from the amigascene archive — byte-identical to the ADF this whole investigation ran on. Any DMS unpacker turns it into an ADF (xdms, XADMaster, or the tiny tools/dms2adf.py in our tree).

What you won’t get from us is a Kickstart ROM — it’s copyrighted material and it never lives in this repo. Bring your own; the demo is happiest in a 1.2/1.3-era 512K-chip + 512K-trapdoor machine, and runs under Kickstart 1.3 and 3.1 in our builds.

What’s next

BMAmiga isn’t on general release yet — it boots Workbench 1.3 and 3.1, plays Captive from an emulated Gayle IDE drive, streams demos off raw-MFM floppies, and now holds 50 fps through the most abusive production the OCS ever shipped, on hardware with no OS to blame or thank. Everything above was done with cycle-exact bus arbitration — real DMAL slots, the blitter’s bus time replayed slot by slot — because the next items on the list only get harder: CPU-fed bitplanes for the Captive-class effects, slow-RAM bus contention, and the climb toward the AGA machines.

The thesis of this whole project is that an Amiga you can’t feel isn’t an Amiga. A locked 50 on the demo that punished every shortcut is the first big payment on that promise. Watch this space — the machine is coming.

All frames in this post were rendered by the BMAmiga core and captured from its framebuffer: the hires units, the mid-line copper replay and the DSKBYTR-fed streaming are all live emulator output.

← volver a posts
↑