Virtual Jaguar v2.2.0 — HLE BIOS, save states, 22 games fixed

76 commits, single author. HLE BIOS that actually boots most commercial titles without a real BIOS image. Save states, SRAM/EEPROM, cheat codes, RetroAchievements. Audio rewrite, ~2x on the hot paths, SIMD blitter with bit-exactness CI. Closes #27, #85, and many sub-issues of #38.

Open Source Emulation Atari Jaguar libretro C/C++

What’s in this release

Release page with binaries for all 14 platforms: libretro/virtualjaguar-libretro v2.2.0. Closes long-standing libretro tracker issues #27, #85, and most of the sub-bugs in #38.

The notable bits: HLE BIOS compat improvements, ~22 previously-broken games fixed (PR #119), save states / rewind / RetroAchievements (#104, #117), SRAM/EEPROM persistence, cheat support (#113), an audio pipeline reorder, ~2x on the hot paths via a SIMD blitter (#101), DSP MAC40 fixed to true 40-bit semantics (#118), per-button remapping for the 21-key controller, a `make test` harness covering all of the above.

Nothing world-changing. Just a release that gets the core where users want it to be.

HLE BIOS: what changed

Virtual Jaguar has had an HLE BIOS path for a while. It’s the default for most users. v2.2.0 closes a few specific gaps.

Quick legal note since people ask: the HLE path is not a reverse-engineered copy of the real BIOS. No disassembled BIOS code, no boot logo, no Atari-copyrighted asset. It re-implements the spec so the hardware ends up in the same observable state by the time the game gets control.

The fixes in v2.2.0:

  • Raw cartridge ROM acceptance. Many homebrews ship as plain .bin / .j64 / .rom dumps without an Alpine/header wrapper. The loader now accepts them at common Jaguar startup bases ($4000, $20000, $802000) with conservative validation, so unknown content fails fast instead of running into garbage memory. Legitimately-built indie ROMs (the 240p Test Suite port, for example) now boot without manual wrapping.
  • JERRY init values matched to post-engine BIOS state. JaguarReset now writes SCLK=0x13, SMODE=0x15 (the values the real BIOS leaves behind after the engine runs), instead of the previous pre-engine defaults 0x08 / 0x01. Games that probed JERRY state at startup were getting the pre-engine values and failing their detection. Atari Karts is the negative-control regression test for this.
  • HLE coverage extended into resolution / scan-out. The real BIOS also seeds Tom’s display registers (HDB1, HDE, VDB, VDE) so games can rely on a known starting display geometry. The HLE init now mirrors that, which fixed the cluster of “left half of the screen” resolution bugs covered in the next section.

Net: more games boot cleanly through HLE. The remaining HLE gaps (Skyhammer, Iron Soldier 2 audio after character select, Wolf3D under HLE) are tracked in docs/emulation-bug-hunt-todos.md and queued for v2.3.

Per-game fixes worth calling out

Most of these were tracked in issue #38 since 2019. The full table is in docs/emulation-bug-hunt-todos.md. Five worth surfacing:

  • Battle Sphere Gold. The big-deal one. Used to only boot if real BIOS was enabled, and even then it black-screened after the menu. v2.2.0 fixes the menu black-screen on the real-BIOS path AND gets it booting through HLE for the first time. (A separate fast-blitter menu-rendering glitch still exists; switch to the accurate blitter if you hit it.)
  • Doom. Long-time symptom: gameplay video rendered to the left half of the screen, with a ghost of the previous frame buffer on the right half. There was a legacy core option called virtualjaguar_doom_res_hack that double-pixeled the line buffer when pwidth==8 as a workaround. The real fix was making the TOM resolution pipeline read its dimensions from the actual hardware registers (HDB1, HDE, VDB, VDE) and apply proper PWIDTH-driven pixel replication (pwidth_scale = (pwidth >= 8) ? (pwidth/4) : 1) instead of hardcoded dimensions. The doom_res_hack core option was deleted in the same PR (#119 / 4fcf958); it’s no longer needed. Same fix also clears Atari Karts, Flashback, Supercross 3D, Trevor McFur, and Val D’Isère.
  • Cybermorph. Missing/glitched textures restored, and the digitised Skylar voice (“Where’s the bridge?”) actually plays now. Turned out the texture and audio paths shared a TOM register init that was wrong. One root cause, two visible symptoms.
  • Powerdrive Rally. The intro screens used to render cut off, and starting a new game crashed. Both are fixed. The crash was an m68k addressing-mode corner case the new BSR.L absolute-address handling caught.
  • Syndicate. “Pink patches in the writing” (corrupted text rendering in dialog boxes). Fixed on the accurate blitter only. Fast blitter still has it, so pick the accurate blitter in core options if you’re running Syndicate. Same recommendation for I-War, Battle Sphere Gold menus, and Kasumi Ninja stage-load tearing.

The full Fixed list also covers Air Cars, Club Drive, Kasumi Ninja, Missile Command 3D, Pinball Fantasies, Skyhammer (no more freeze; audio still clipped, see below), Trevor McFur, Val D’Isère, Zero 5, plus Iron Soldier 2’s title-screen layout (the post-character-select black screen and the audio clipping are still open).

What’s still broken — and why

The honest part. Five titles still don’t work. I want to put a pin in why for each, because the diagnostic itself was useful.

Cluster investigation across the still-broken hang/crash titles snapshotted DSP RAM, 68K registers, TOM/JERRY/DAC registers, low main RAM, and rendered framebuffers across multiple frames in both real-BIOS and HLE modes. Headline finding: 4 of 5 hang/crash titles (Hyper Force, Iron Soldier, Ruiner Pinball, Super Burnout) show identical or near-identical behavior in BIOS vs HLE: same stuck PC, same nonblack pixel count, same DSP state. So those titles are real emulation bugs, not HLE-init issues. Real BIOS doesn’t fix them either. Queued for v2.3.

Per-title clues with the most actionable signal:

  • Wolfenstein 3D (audio, both BIOS and HLE). HLE: completely silent (RMS=0) for the entire run. BIOS: brief BIOS-chime audio at frames 34–~600 then silent forever (the chime is the BIOS startup tone, not Wolf3D’s audio). Wolf3D’s DSP escapes work RAM by frame 1800: dsp_pc=0x000003FA (HLE) / 0x00181C43 (BIOS), both in main RAM, not DSP work RAM (0xF1B000-0xF1CFFF). Atari Karts HLE keeps the DSP at 0xF1B3FA correctly with active LTXD/RTXD, so the comparison is clean. Wolf3D’s DSP code has a JUMP-through-register where the register holds a garbage value and sends the DSP into 68K territory. Tried memcpy’ing the BIOS DSP audio engine into DSP RAM and starting D_PC at the engine entry, with the same escape symptom because the engine reads DSP registers we never initialize and uses them as jump targets. Reverted; needs full DSP register-bank state replication. Bonus: real RetroArch on iOS shows that pressing A/B during the BIOS logo skips initialization and Wolf3D fails to boot entirely. No other game does that.
  • Raiden (HLE, won’t boot). Cart copies game code from cart $802026..$802986 into main RAM at $180000+, JMPs there, runs initial setup, enters a poll loop at $18014A: TST.B $2C7(A4); BEQ.S -4 — spin until RAM[$180679] becomes non-zero. An interrupt handler is supposed to set that byte; the HLE generic-RTE stub at $404 just RTE’s, so the byte never gets set. Real BIOS has the actual handler. Fix path traced: cart probably installs its own IRQ handlers via the BSRs at $180000 and $180004 before the poll loop; need to identify why those interrupts aren’t arriving (TOM video IRQ enable, JERRY IRQ enable, vector base).
  • Ruiner Pinball. Identical stuck PC at $809CAE and 0% nonblack pixels in BIOS and HLE. Routine at $9CA0 does CMPI.L #0, $00005B18; BEQ +6; JMP $00802000. A separate routine at $2248 calls a function pointer at $0000402C, runs JSR $00802380, then writes MOVE.L #1, $00005B18 only if RAM[$4068] has bits 0+16 set and bit 9 clear. Probing those addresses each frame for 600 frames in both modes: $402C and $4068 stay 0 forever in both, while $5B18 cycles through PRNG-ish values. Cart is missing the initialization that should populate the function pointer and interrupt-flags accumulator. A precondition (probably an early interrupt) is failing.
  • Skyhammer (audio clipping, gameplay works). Boot timeline at frames 60/300/600/1200/3600/7200 shows the 68K progresses through cart code (0x832F4A → 0x8B6378 → 0x1644C → 0x16610 → 0x8AC7E2 → 0x8AC930). Not stuck. The DBF loop at $22EE is a long delay (D0=0xFFFFFF, ~167M cycles, ~6 seconds at 13.3 MHz). Audio clipping is purely DSP-side / I2S; cart-disassembly isn’t the right path. Root cause is in JERRY/DSP timing or HLE audio-engine missing state.
  • Hyper Force / Iron Soldier / Super Burnout. Matching pixel counts and PC ranges in BIOS/HLE. Engine-level bugs, not init.

The point of dumping all that: this isn’t “these games don’t work, sorry.” Each one has a traced root cause now. v2.3 has a real list to work down.

Hardware-accuracy work

The accuracy fixes that didn’t land in the resolution-pipeline cluster but matter:

  • DSP MAC40 to true 40-bit accumulator semantics (PR #118). The Jaguar DSP’s multiply-accumulate is 32×32 → 40-bit, and the old implementation was effectively 32-bit, silently losing the top 8 bits on overflow. Math-heavy games (audio synthesis, 3D transforms) were getting wrong output. Now matches the chip.
  • 68020+ instructions via IllegalOpcode trap (PR #119). The Jaguar’s m68k is a 68000, but a few licensed games happen to use 68020 MULL / DIVL. Real hardware throws an illegal-opcode exception; the BIOS has a handler that emulates them. The HLE path now does the same.
  • BSR.L absolute-address fix for Atari’s aln linker (PR #119). The aln linker emits BSR with a fixed 32-bit absolute target in some configurations; the old m68k decoder was sign-extending it as a relative branch and jumping into garbage. Caught by a homebrew that uses aln.
  • JERRY PIT2 prescaler (PR #119). PIT2 was incorrectly sharing PIT1’s prescaler, which made any game using PIT2 timer interrupts run at the wrong rate. Fixed.
  • Event-system uninitialized slot[0] (PR #119). The event scheduler’s slot 0 was being read before being written on the first frame after reset, returning whatever stack garbage was there. Caused periodic stuttering in some games during the first second of play. Initialized.

Audio pipeline reorder

Long-standing dropout: every few seconds, a brief click or half-sample of silence right at the audio-frame boundary. Caused by the JERRY audio subsystem and the libretro frame loop running on slightly mismatched schedules, so the audio callback could see JERRY state that didn’t correspond to the frame being rendered.

The fix is an ordering change in retro_run(). The new order:

update_input -> DACPrepareFrame -> JaguarExecuteNew -> cheat_apply_all -> SoundCallback -> video_cb

JERRY now interleaves with the main execution loop instead of running as a separate per-sample event queue, so by the time SoundCallback fires, JERRY state matches the frame just rendered. Validation is in test/test_audio_clipping.c, which checks saturation density / longest-run / RMS across a watch-list of titles (Iron Soldier 2 is in the watch-list as --expect-clipping until its remaining HLE audio bug is fixed).

One dependency that came along for the ride: libretro SET_GEOMETRY is now applied at the start of retro_run instead of after video_cb. That fixes the Wolf3D black-screen on iOS Metal RetroArch — frontends that re-allocate the texture on geometry change were dropping the frame submitted right after the change. Specifically helpful for Provenance users on iOS.

Save states, rewind, RA

None of these existed in the previous core. Each was harder than it sounds.

Save states. Conceptually simple: serialize the entire machine state to a blob, deserialize later. The Jaguar is unusually heterogeneous though. Six chips hold state: the m68k CPU, the GPU (RISC core), the DSP (another RISC core, different ISA), the Object Processor, the Blitter, and the audio subsystem. Each had its own ad-hoc representation of state, and several held pointers into shared memory that didn’t serialize cleanly. The work was making each chip’s state deterministically serializable: stable byte layout, no embedded pointers, no undefined fields. With that done, saving and loading is the obvious thing. Run-ahead works for free.

Rewind rides on save states. Once serialization is deterministic, the libretro frontend can ring-buffer and walk backwards on its own. The amount of rewind buffer the user gets is set by the frontend, not the core.

RetroAchievements. Jaguar had no RA support before. v2.2.0 wires the standard hooks: RC_CONSOLE_ATARI_JAGUAR, memory-map exposure, frame-tick callbacks, hardcore-mode enforcement (PR #117). The RA community is now able to author Jaguar achievement sets.

SRAM and EEPROM persistence got fixed in the same release. Save data now survives soft resets, which it didn’t before for cartridges that used the on-cart EEPROM (a small but loud subset of the library, mostly the racing titles).

21-key remap

The Jaguar controller has a digital d-pad, three face buttons (A, B, C), Pause, Option, and a 12-key numeric keypad. The 1993 design assumed games would ship with paper overlays you’d snap on to label what each keypad key does in that specific game.

The problem for emulation is that no modern controller has 21 inputs, and the keypad keys are load-bearing for a lot of titles. Alien vs. Predator uses the keypad for door codes and inventory; Iron Soldier uses it for weapon selection; the homebrew scene uses it constantly. Dropping the keypad onto “keyboard only” isn’t acceptable on platforms like iOS where there is no keyboard.

v2.2.0 ships per-button remapping for the entire 21-key layout via virtualjaguar_p1/p2_retropad_* core options. Every keypad key is independently assignable to any input the libretro frontend exposes: analog-stick directions, modifier-button combos, on-screen overlays, whatever your frontend supports. The defaults are sensible for the most-used titles (keypad mapped to a virtual on-screen overlay on touch platforms; combo-buttons on traditional controllers). The override path is one menu deep. Frontend authors can ship per-game defaults; users can override per-game.

~2x on hot paths

The headline number from the release notes is ~2x speedup on DSP / GPU / memory hot paths, profile-driven against v2.1 on the same hardware running the same scenes. Most of the wins came along with correctness fixes.

The biggest single win is the SIMD blitter (PR #101). The accurate blitter is structured around per-pixel CRY/RGB color conversion, which is exactly the shape SIMD does well on. Three implementations live in src/tom/blitter_simd_{sse2,neon,scalar}.c; the build picks one based on target arch. Bit-exactness between the three is enforced by test/test_blitter_simd.c, which fails CI on any divergence between SSE2, NEON, and the scalar reference.

The fast blitter is still meaningfully faster than the accurate blitter, and the choice between them is exposed as a core option. Tempest 2000 may run below full speed on the accurate blitter on lower-end hardware. Syndicate (the pink-patches fix) requires the accurate blitter. Pick per game.

Smaller wins: tightened the m68k decoder’s common-case dispatch, replaced a few memcpy calls with structured assignment in the OP’s descriptor walk, inlined a couple of accessor functions the compiler wasn’t inlining on its own.

The make test harness

One of the biggest pieces of behind-the-scenes work in v2.2.0 doesn’t show up in any user-visible feature: make test now runs a real harness covering HLE BIOS state, the event queue, blitter SIMD bit-exactness (SSE2 vs NEON vs scalar), DSP MAC40 against a fixed-point reference, the cheat decoder, the RetroAchievements memory-map wiring, save-state round-trip plus rewind, and a screenshot regression test against JagNICCC2000 and YARC test ROMs.

It also runs test_audio_clipping, the watch-list for audio regressions. IS2 and the games still in flight under HLE are tracked there as --expect-clipping until they’re actually fixed, so the harness fails when a previously-known-broken game starts passing without the watch-list being updated. Honest CI for emulation accuracy.

And there’s a new lightweight logging framework in src/log.h with LOG_DBG / LOG_INF / LOG_WRN / LOG_ERR, plus a source-tree reorg into src/{core,tom,jerry,cd,bios,m68000}. Mostly developer-facing, but it makes the codebase substantially easier to grep through if you’re trying to track down a specific chip’s behavior.

What’s next

Jaguar CD. The Atari Jaguar CD add-on shipped a small but interesting library, including some of the most graphically ambitious Jaguar software made. Emulation of it has been historically poor. Today, the only meaningful Jaguar CD emulation outside MiSTer FPGA is fragmentary. Bringing it into virtualjaguar-libretro is the logical next step, and v2.2.0’s improved core makes that work substantially easier. PR #119 stacks the IRQ/HLE/audio fixes that #109 was working around; once it lands, the CD work in #109 reduces from +426 lines to +22.

On the distribution side: Provenance ships virtualjaguar-libretro as one of its Jaguar cores, so users on iOS, tvOS, and macOS will pick up all of v2.2.0’s improvements automatically in the next core update. No re-downloading, no BIOS sourcing, no setup. The HLE BIOS path is especially nice on iOS, where filesystem access for BIOS files is awkward at best.

Related work in the JoeMatt/atari_jaguar_240p_test_suite repo is one of the few things actively producing new homebrew Jaguar ROMs. That makes it one of the best stress-tests for the core: every release of the test suite immediately exercises virtualjaguar-libretro’s edge cases. The two projects feed each other.

If you find a regression, file an issue. Single-author releases are fast, but they also have single-author blind spots. The bit-exactness harness catches a lot. It doesn’t catch everything. The compatibility table in the wiki is the best place to confirm whether a specific title is verified; PRs against it are welcome from anyone who can run a few minutes of a game and report what they see.