Acid-test ROM toolkit
Synthetic Jaguar ROMs that exercise specific hardware corners -- blitter modes, GPU/DSP cross-talk, beam chasing, OP scenarios, IRQ delivery, HLE-vs-real-BIOS divergence -- and report PASS / FAIL to the host via a fixed RAM signature.
Why
- Reproducible perf benchmarks that don't depend on commercial ROMs (which we cannot ship). Each acid test is small (typically <8 KB), open-source, and exercises a single feature so we can attribute regressions cleanly.
- Bug-finding under stress. Commercial games hit wide combinations of features but only the combinations they happen to use. Acid tests exhaustively walk a feature axis (every pixsize, every phrase/non-phrase, every Z-mode) and catch divergence between fast and accurate blitters, between our implementation and the hardware reference, and between successive emulator versions.
- Documenting reality. We expect many tests to fail today --
the emulator is deliberately not cycle-accurate, the OP timing is
loose, bus contention is unmodelled, and HLE BIOS doesn't match
real BIOS in many places. Each failing test is a checked-in
description of a known accuracy bug, which is more useful than
prose in
docs/TODO.
Status
Early but live. Framework runs; per-test PASS/FAIL with diagnostic
codes; per-test perf-counter delta dumps when built with
BENCH_PROFILE=1 (the default for make acid). vasm assembler is
optional -- if absent, the assemble step is skipped with a warning
and only the runner harness is built.
140 / 143 tests PASSing across 12 categories (per test/acid/BASELINE.txt,
2026-08-03). The 67 / 72 figure that used to live here was stale --
the suite grew via a 70+-test sub-agent sweep (553f2bd) and further
additions since, and nobody had reconciled the headline count against
BASELINE.txt afterward. Failures are intentional documentation of
known emulator gaps.
| Category | Tests | Pass | Open issues surfaced |
|---|---|---|---|
| memory | 10 | 10 | — |
| timing | 13 | 13 | — |
| irq | 7 | 7 | — |
| blitter | 35 | 35 | — (includes the smoke test, blitter/zzz_smoke.jag) |
| gpu | 18 | 18 | — |
| dsp | 21 | 21 | — |
| op | 11 | 11 | — |
| bus | 5 | 2 | 3 tests document the synchronous-blitter / no-bus-contention-model gap (see below) |
| hle | 6 | 6 | — |
| quirks | 11 | 11 | — |
| stress | 3 | 3 | — |
| perf | 3 | 3 | — |
The 3 bus/* failures are expected and by design, not a regression:
bus_refresh_steals, bus_cpu_starves_blitter, and
bus_blitter_starves_cpu each assert that a 68K/blitter access pattern
measurably steals cycles from the other bus master under contention.
The blitter still runs as a synchronous, non-interleaved operation
(BlitterMidsummer() completes before the next 68K instruction), so
there is no cycle-level overlap for these tests to observe -- see the
per-test header comments in test/acid/tests/bus/ for the exact
detail-code contract each one is waiting on.
The bugs this section used to list as open (DIVL zero-divide trap not
reaching its handler; blitter/bcompen_basic, blitter/copy_simple,
blitter/pattern_fill encoding gaps; dsp/dsp_mac_accumulator as a
FAIL placeholder) are no longer reproducible -- BASELINE.txt records
all of them PASSing (dsp_mac_accumulator itself no longer exists as
a test, superseded by the current dsp/* coverage). They were fixed
incrementally without this section being updated; the only currently
-failing tests are the 3 bus/* contention tests documented above.
How we got from 33% → 93% PASSing in one review round
Initial PR snapshot showed 33/72 PASS. Copilot review caught two fundamental encoding mistakes that masked dozens of test failures as "real emulator bugs":
- TOM_INT1 byte order: I had the IRQ enable mask in the high byte; per JTRM Rev 8 p.16 the enables are bits 0-4 (the low byte; bits 8-12 are the pending-clear bits). Fixing this recovered every IRQ-delivery test.
- Blitter command bit positions: I'd been writing
$0001C000thinking the high nibble was the LFU select, but the actual layout (JTRM Rev 8 pp.73-75, same as src/tom/blitter.c) puts SRCEN at bit 0, DSTEN at bit 3, and the LFU function at bits 21..24. My encoding was completely bogus. Fixing this recovered all the blitter mode tests. - JERRY PIT writable vs readable addresses: $F10000/$F10002 are the writable JPIT1/JPIT2 setup regs; $F10036/$F10038 are readback aliases. Writes to the readback aliases don't arm the timer.
Big take-away: an acid suite is only as good as its test code, and getting the register encodings exactly right matters more than the volume of tests. Worth keeping in mind for the next batch.
Layout
test/acid/
README.md -- this file
Makefile -- assembles tests/*.s into .jag ROMs (vasm)
run.c -- harness: dlopen core, load ROM, read signature
include/
jaguar_header.s -- minimal Jaguar cart header + entry vector
acid_test.s -- pass/fail signature macros
tests/
blitter/ -- blitter mode matrix
gpu/ -- GPU coprocessor
dsp/ -- DSP coprocessor
op/ -- Object Processor
timing/ -- VC/HC/PIT/halfline rate
irq/ -- VBlank, JERRY PIT, GPU/DSP IRQ delivery
bus/ -- 68K + GPU + Blitter concurrent access
hle/ -- HLE BIOS vs real BIOS divergence
memory/ -- RAM/ROM/mirror/endianness/access widths
quirks/ -- documented hardware quirks + commercial hacks
stress/ -- heavy concurrent workloads (AvP-style)
perf/ -- predictable cycle-stress workloads
How a test reports its result
Tests write a four-word "acid signature" block at fixed RAM offset
$100000 (1 MB into main RAM, well clear of the 68K vector table at
$0..$3FF, BIOS workspace, cart-mode stack at $4000, and typical
RAM-loaded executable region).
$100000 ACID_RESULT $12345678 = pass
$DEADBEEF = fail
$00000000 = not-run-yet (test crashed or never wrote)
$100004 ACID_DETAIL test-specific code (sub-test ID)
$100008 ACID_OBSERVED value the test actually got (on FAIL)
$10000C ACID_EXPECTED value the test was looking for
The runner reads main-RAM via retro_get_memory_data(SYSTEM_RAM)
after running N frames and prints PASS / FAIL / NOT-RUN-YET with
diagnostics.
Exit code: 0 for pass, 1 for fail/not-run, 2 for harness error.
Per-test perf summary
When the core is built with BENCH_PROFILE=1 (the default for
make acid), the runner snapshots a set of perf counters before /
after each test and dumps the delta:
[PASS ] tests/timing/vc_per_frame.jag
perf: timing_jaguar_execute_calls=600 timing_halfline_callbacks=314400
That tells us at a glance:
- the test ran for 600 retro_run cycles (10 emulated seconds at 60 Hz)
- the halfline callback fired 314400 times = exactly 524 per frame (NTSC: VC sweeps 0..524 inclusive, but our HalflineCallback is invoked once per transition, hence 524 not 525)
If a future change makes the halfline rate jump to e.g. 1048800 (1748 per frame, what the bug would look like if events fired on both edges), this number will catch it immediately even if no test explicitly checks for it.
Counters surfaced in the per-test summary today:
| Counter | Source | Expected (NTSC default) |
|---|---|---|
timing_jaguar_execute_calls |
JaguarExecuteNew entry |
1 per retro_run() |
timing_halfline_callbacks |
HalflineCallback entry |
524 per frame (NTSC) |
timing_vblank_irqs |
TOM video-int raise | 1 per frame |
timing_jerry_irqs |
JERRY PIT IRQ raise | 0 unless game enables PIT |
timing_gpu_irqs_to_68k |
TOM PIT-→68K raise | 0 unless game enables TOM PIT |
blitter_calls |
BlitterMidsummer2 entry |
game-dependent |
blitter_outer |
blitter outer-loop iter | game-dependent |
blitter_inner |
blitter inner-loop iter | game-dependent |
blitter_phrase_reads |
source/dest phrase loads | game-dependent |
blitter_phrase_writes |
dest phrase stores | game-dependent |
Add new counters in the file that increments them (PERF_COUNTER
is file-scoped) and append the name to kPerfCounters[] in
test/acid/run.c to surface in the summary.
Building & running
vasm (motorola syntax) is the assembler. Three options:
Option A — local source build (current default). Build from the
prb28 GitHub mirror (the upstream sun.hasenbraten.de site is
sometimes unreachable):
git clone --depth 1 https://github.com/prb28/vasm.git /tmp/vasm
cd /tmp/vasm && make CPU=m68k SYNTAX=mot
sudo install vasmm68k_mot /usr/local/bin/
Option B — Docker image (recommended for CI). A couple of ready-made Jaguar-toolchain images vendor vasm + vlink + vbcc:
toarnold/jaguarvbcc-- Docker Hub: https://hub.docker.com/r/toarnold/jaguarvbcc/Leffmann/vasm-- GitHub: https://github.com/Leffmann/vasm
Wire either into a CI job that volume-mounts the repo and runs
make acid inside the container; the image already has vasmm68k_mot
on $PATH.
Option C — alternative assembler. rmac (Reboot's modern fork
of Atari's smac) also assembles 68K motorola syntax for Jaguar,
though our test sources currently target vasm idioms. See
https://www.commodore-news.com/news/item/13087/en/desktop for
context on the wider Jaguar/Atari toolchain landscape.
Then from the repo root:
make acid # build core + tests + run
make -C test/acid clean # clear build artifacts
Or for one specific test:
make BENCH_PROFILE=1 TEST_EXPORTS=1 # build core
make -C test/acid acid_run # build harness
test/acid/acid_run \
./virtualjaguar_libretro.dylib \
test/acid/tests/timing/vc_per_frame.jag \
600 # 600 frames
Writing a new test
Template:
include "include/jaguar_header.s"
include "include/acid_test.s"
org $802000
entry:
ACID_INIT
; ... your test code ...
; PASS: fall-through to ACID_PASS
; FAIL: ACID_FAIL detail,observed,expected
ACID_PASS
Macros:
| Macro | Effect | Clobbers |
|---|---|---|
ACID_INIT |
clear signature block to NOT-RUN-YET | d0, a0 |
ACID_PASS |
write PASS magic, halt forever | d0 |
ACID_FAIL d,o,e |
write FAIL + 3 diagnostic words, halt | d0 |
ACID_FAIL accepts any operand move.l accepts (registers OR
#imm):
ACID_FAIL #5,#$DEAD,#$BEEF ; all immediates
ACID_FAIL d3,d5,d4 ; all from registers
ACID_FAIL #1,d2,#0 ; mixed
The runner runs your test for 600 emulated frames by default
(10 seconds at 60 Hz NTSC). If your test needs longer, pass an
explicit count: acid_run <core> <rom.jag> <num_frames>.
Test categories (planned)
This is the long-form roadmap. Tests land incrementally; each
landing PR fills in part of one category. [OK] = at least one
test landed, [--] = none yet.
smoke/ [OK]
The tests every test depends on. If anything here fails, the rest of the suite is meaningless until smoke passes again.
zzz_smoke.s[PASS]-- ACID_INIT + ACID_PASS, no real work
Future:
- "M68K reset PC matches cart entry vector" (verifies HLE init)
- "Vector table is filled (no PRNG garbage at $100)"
timing/ [OK]
Frame-pacing and counter-rate tests. High priority -- the Doom 1.5-2x speed regression (issue #131) lives in this category.
vc_advance.s[PASS]-- VC must change at allvc_per_frame.s[PASS]-- VC sweeps once per frame, ~60 frames/sec
Future:
- HC advance rate within a scanline (matches HP halfline period)
- VBlank rate matches NTSC 60 Hz / PAL 50 Hz exactly
- VC field-bit (#11) toggles between fields
- JERRY PIT divider rate
- TOM PIT divider rate
- Halfline IRQ delivery jitter (target: <1 halfline)
- Frame-tear test: VC poll-loop catches the right cycle to update palette mid-frame
Original docs/TODO items relevant here: "Fix VC behavior to
match what a real Jaguar does" (still open per Shamus' notes),
"Cycle accuracy for GPU/DSP/OP/Blitter".
irq/ [OK]
Interrupt delivery from each subsystem. irq_ack_handler() returns
vector 64 for ALL hardware IRQs, so we patch vector 64 and watch a
shared flag.
vblank_delivery.s[NOT-RUN-YET]-- VBlank IRQ should bump a counter; currently the IRQ raises in TOM (timing_vblank_irqscounter ticks) but the 68K handler at vector 64 doesn't fire. Real bug surface -- either the IPL ack path or our vector-64 patch is wrong.
Future:
- JERRY PIT timer 1 / timer 2 IRQ delivery
- TOM PIT IRQ delivery
- DSP IRQ -> 68K via JERRY external
- GPU IRQ -> 68K
- IRQ priority cascade (higher takes over lower)
- Nested IRQs
- IRQ ack timing after the handler RTEs
Original docs/TODO items: "DSP code needs to be rewritten"
(historical; some of that flowed through), "Need to emulate bus
contention" (affects IRQ ack timing).
blitter/ [OK]
Blitter mode matrix. The biggest accuracy axis we have -- two paths
(fast blitter_generic and accurate BlitterMidsummer2) that
should produce bit-identical output but often don't.
zzz_smoke.s[PASS]-- placeholder; no blitter touchedcopy_simple.s[NOT-RUN-YET]-- 8-phrase round-trip copy; partially executes (blitter_calls=1, inner=2) then crashes
Future:
- One copy test per pixsize (1, 2, 4, 8, 16, 32 bpp)
- Phrase mode vs pixel mode at each pixsize
- Z-buffer modes (zmode 0..7)
- Gouraud shading (GOURD)
- Z-interpolation (GOURZ)
- SRCSHADE
- BCOMPEN bit pattern compositing (used for font rendering)
- DCOMPEN data compare (transparent color)
- BKGWREN (write background color)
- LFU functions (16 source/dest combos)
- Wide blits (multi-phrase rows)
- Tall blits (multi-line)
- Clipping (CLIPA1)
- Step modes (XADDPHR, XADDPIX, XADD0, XADDINC)
- A1 vs A2 source/dest swap
- Fast vs accurate blitter divergence: run each test twice, compare results bit-for-bit
Original docs/TODO: "Blitter needs fixing", "Need to propagate
blitter fixes in the A1 <- A2 direction to the A1 -> A2 direction".
op/ [--]
Object Processor scenarios.
Future:
- STOP object terminates list correctly
- Bitmap object render at every pixsize
- Scaled bitmap (HSCALE, VSCALE)
- Branch object (conditional, on YPOS / VC)
- GPU-interrupt object
- OP-list cycle detection
- REFLECT / RMW / TRANS modifiers
- Palette indexing (CRY vs RGB)
- OP timing budget per halfline
Original docs/TODO: "Need to fix timing in the OP. As it is now,
it gives a false impression of how much it's capable of."
gpu/ [--]
GPU RISC instruction coverage + 68K-side register access.
Future:
- One test per GPU opcode (~64 of them)
- Register file access from 68K via $F02100..
- GPU IRQ to 68K
- GPU stop / restart
- GPU-Blitter handshake (program GPU to issue blits, poll BUSY)
- DIVQ semantics
- IMACN accumulator
- Branch conditions
dsp/ [--]
Same shape as GPU but DSP-specific.
Future:
- All DSP opcodes
- 40-bit MAC accumulator (we have
src/jerry/dsp_acc40.h; needs cycle-accurate test) - DSP IRQ delivery
- I2S sample-clock (SCLK) rate matches configured divider
- Audio sample buffer fill rate (catches buffer over/underrun symptoms before they reach the user)
- DSP <-> 68K mailbox
- DSP <-> GPU memory access through TOM bus
Original docs/TODO: "DSP code needs to be rewritten".
bus/ [--]
Bus contention / arbitration. We don't model bus contention today, so these tests will mostly fail by design until we do -- which is exactly the point.
Future:
- 68K + GPU concurrent main-RAM read race
- Blitter + 68K concurrent main-RAM access
- Memory bandwidth ceiling (sum of throughput across masters)
- Refresh cycles stealing bus time
Original docs/TODO: "Need to emulate bus contention" (literally
listed by Shamus as still-open).
hle/ [--]
HLE BIOS vs real BIOS divergence. Each test runs once with
virtualjaguar_bios=disabled (HLE) and once with enabled (real
BIOS); both must produce the same observable result for accuracy.
Future:
- 68K register state immediately after reset
- GPU register state
- DSP register state
- JERRY clock dividers (CLK2, CLK3)
- I2S setup (SCLK, SMODE)
- TOM border colour (BORD1/2)
- Vector table contents
- HLE_BIOS_WORK_FLAG_ADDR ($0804) value
- Cart authentication GPU magic at $F03000
memory/ [--]
Address-space behaviour.
Future:
- Main RAM read/write at every width (8/16/32/64-bit)
- Cart ROM read at every width
- GPU local RAM ($F03000..)
- DSP local RAM ($F1B000..)
- Mirror addresses (Jaguar has several)
- Endianness consistency (big-endian Jaguar on LE host)
- Open-bus reads
- Write-only and read-only register correctness
quirks/ [--]
Documented Jaguar 1 hardware quirks and known commercial hacks. A test here is a contract: "the emulator must reproduce this quirk because game X depends on it."
Future:
- A2 yadd tied to A1 yadd (Jaguar 1 bug)
- BSR.L $61FF (Atari
alnlinker absolute address quirk) - 68020 MULL/DIVL trap (Removers Library / m68k-atari-mint-gcc)
- DSP MAC pipelining quirks
- OP scaling underflow / wrap behaviour
- Doom pwidth=8 pixel-replication (now in scanline renderers)
stress/ [--]
AvP-style heavy concurrent workloads. These won't fit the 16.6 ms frame budget on slow hosts -- the goal is to detect regressions in our own throughput.
Future:
- 2000+ small blits per frame (mimics AvP gameplay)
- Concurrent GPU + Blitter + DSP at max sustained rate
- 68K AI-style logic + heavy blitter
- Pathological ADDARRAY input (every daddasel/daddbsel combo)
perf/ [--]
Predictable cycle-stress workloads we can measure across emulator versions to characterise throughput change.
Future:
- N-iteration GPU loop (predictable instruction count)
- N-iteration DSP loop
- N-byte memcpy via 68K
- N-byte blitter copy
- Fixed-rate audio sample budget
Caveats
-
The boot stub assumes the HLE BIOS path is in use (
virtualjaguar_bios=disabled); the runner sets that variable unconditionally. Real-BIOS testing is a separate axis (seehle/). -
Tests halt by
bra.s .at end -- they don't return to a host scheduler. The runner runs N frames and reads the signature; if the test crashed before writing, you get NOT-RUN-YET. -
vasmlicense is "free for non-commercial use" with conditions. We use it as a build-time tool only; nothing assembled by vasm ships in the libretro core. Seeprb28/vasmfor the source we build from. -
Nothing in here yet runs in CI. Once the test set stabilises and vasm install is documented in CI, we'll add a job that runs
make acidand gates merges on it.
See also
docs/TODO-- original devs' (Shamus, CJ, nwagenaar) outstanding accuracy / feature TODO list. Several items there map directly onto categories above (cycle accuracy, VC behaviour, OP timing, bus contention, blitter A1/A2 propagation).docs/profiling.md-- general profiling guide; coversBENCH_PROFILE=1,xctracewrapper, and the perf counter system this toolkit uses.docs/emulation-bug-hunt-todos.md-- our active bug-hunt notes; converging with acid coverage over time.- Issue #131 -- Doom game logic / demos run 1.5-2x too fast. Will
be reproduced + bisected once
timing/andirq/tests cover the surface.
Greater-than Z-buffer report (#1046)
tests/blitter/zmode_gt.s is an original, bootable test inferred from the
AtariAge report and the JTRM v8 p.74 inhibit truth table. It checks unsigned
source Z $8000 against $7FFF/$8000/$8001/$FFFF, verifying both colour and
Z writes across twelve pixel/phrase, BKGWREN, GOURZ and DSTA2 cases. Pixel
cases use BKGWREN to avoid the known silicon comparator erratum. It polls
IDLE with a bounded timeout and reports the failing case/word through the
standard RAM signature. It is not the reporter's missing reproducer.
make -C test/acid tests/blitter/zmode_gt.jag acid_run
ACID_FAST_BLITTER=disabled test/acid/acid_run ./virtualjaguar_libretro.dylib test/acid/tests/blitter/zmode_gt.jag
ACID_FAST_BLITTER=enabled test/acid/acid_run ./virtualjaguar_libretro.dylib test/acid/tests/blitter/zmode_gt.jag
ACID_FAST_BLITTER defaults to disabled (accurate), preserving ordinary
suite behavior. The core's test_blitter_cmd separately covers all eight
ZMODE combinations in both engines. The current public JaguarDemos pin
36afa55 has related Z-buffer sources in jag_ball/rotatez5.js and
rot5z_mm.js; those select less-than (1 << 18), not GT (1 << 20).
GT / DSTA2 reproduction (#1046)
tests/blitter/zmode_gt_dsta2.s is a bootable, original reproducer for a
confirmed fast-blitter defect. It uses A1 X=0, A2 destination X=1, phrase
addressing, distinct computed-Z lanes $4000,$8000,$A000,$C000, and a
destination Z of $7000. GT must reject all three pixels in the strip;
the unused $4000 seed belongs to the lane outside it. Before the fix the
fast engine selected that seed using A1's X, drew an unwanted $ABCD
pixel and reported observed=$1111ABCD, expected=$11111111 at detail 1.
The accurate engine passes before and after the fix.
The screen is red on failure and green on success. RAM $100000 retains
the standard ACID result/detail/observed/expected signature. This ROM uses
the normal acid header, so run with HLE/direct-cart boot, not retail BIOS
authentication. No private ROM assets are required.
make -C test/acid tests/blitter/zmode_gt_dsta2.jag acid_run
ACID_FAST_BLITTER=enabled test/acid/acid_run ./virtualjaguar_libretro.dylib test/acid/tests/blitter/zmode_gt_dsta2.jag
ACID_FAST_BLITTER=disabled test/acid/acid_run ./virtualjaguar_libretro.dylib test/acid/tests/blitter/zmode_gt_dsta2.jag
test_blitter_cmd also checks distinct seeds with positive/negative ZINC,
pixel/phrase destination and source modes, A1/A2 destinations, partial
phrases and a crossed phrase boundary (32 engine/configuration cases).
The original AtariAge report has no supplied GT ROM or register setup;
this reproduces a concrete GT failure, not a verified match to that
unknown setup.