JS7800
JS7800 is an Atari 7800 ProSystem emulator written entirely in JavaScript by raz0red, a hand port of Greg Stanton's ProSystem emulator. It runs in-browser with an HTML5 canvas and Web Audio, emulating the 7800's SALLY (6502) CPU, the MARIA display processor and the TIA/RIOT support chips, with optional POKEY and Expansion Module sound.
Runs on: Web browser
JS7800 Online Emulator
Play JS7800 using JavaScript directly in your browser.
Controls
Configurations
| Configuration | Emulator | Machine | OS | Legal | |
|---|---|---|---|---|---|
| Atari 7800 | JS7800 | Atari 7800 | grey | Open ⛶ |
Machines emulated
Chips
Notes
Embedding
JS7800 is a webpack ES-module project, not a single script. We bundle a small boot entry with esbuild into js7800-boot.js, load it as <script type="module">, and drive the ProSystem core ourselves rather than using JS7800's own UI — so the loop can be paused and single-stepped, which the debugger needs.
Boot. Wire the shared Video module to a <canvas> through the "init" event, load the cartridge, then reset the machine and run a loop on ProSystem.ExecuteFrame:
import * as ProSystem from "./core/prosystem/ProSystem.js";
import * as Cartridge from "./core/prosystem/Cartridge.js";
import * as Video from "./core/web/video.js";
Events.fireEvent("init", { canvas, mainContainer, innerContainer, controlsDiv });
Cartridge.Load(romBytes, romBytes.length); // parse the .a78 header + map banks
Database.Load(Cartridge.GetDigest()); // per-cart tweaks by MD5
Events.fireEvent("onCartridgeLoaded", Cartridge);
ProSystem.Reset(function(){ loop(); }); // reset is async (high-score cart)
function loop(){ ProSystem.ExecuteFrame(input); Video.flipImage(); requestAnimationFrame(loop); }
The machine is plain JavaScript. Each hardware block is a module of ordinary functions and arrays — no wasm heap to reach into:
| Member | Kind | What it does |
|---|---|---|
ProSystem.ExecuteFrame(input) | method | Emulate one video frame — steps the Sally CPU, Maria graphics, RIOT and TIA/POKEY for a full field. input is a 19-entry array of joystick and console-switch booleans. |
ProSystem.Reset(cb) | method | Reset the console; cb runs once the (optional) high-score cartridge has loaded. |
Sally.ExecuteInstruction() | method | Run exactly one 6502 instruction and return its cycle count — the basis of single-step. |
Sally.GetSallyA/X/Y/S/P(), Set… | methods | Read and write the CPU registers and status byte directly. |
Sally.GetSallyPC() | method | The program counter, a Pair object: .getW() / .setW() read and write the 16-bit value. |
Memory.ram | field | The 64 KB CPU-visible address space (RAM, register shadows and the mapped cartridge bank) — a plain array, readable with no side effects. |
Memory.Read/Write(addr[,v]) | methods | Bus access with hardware side effects; Write also routes ROM-region pokes through cartridge banking. |
Video.flipImage() | method | Blit Maria's finished framebuffer through the region palette to the canvas. |
ProSystem.ProSystemSave/Load() | methods | Serialise / restore the whole machine (a save state). |
Because it is all JavaScript, the host page inspects and controls the machine directly — which is exactly what the debugger does.
Debugger integration
Wiring JS7800 into the shared in-browser debugger needed a boot shim, plus a set of techniques for reaching into the live machine.
1 · A boot shim, because JS7800's top module owns the loop. Its js7800.js builds a logo, a controls bar and a high-score client, and runs an internal setTimeout loop that cannot be paused or single-stepped from outside. So we import only the core modules and drive them from a loop we control:
// our loop, not JS7800's — so pause / step / breakpoints work
function step(n){ while(n-->0) ProSystem.ExecuteFrame(input); Video.flipImage(); }
function stepInsn(n){ while(n-->0) Sally.ExecuteInstruction(); Video.flipImage(); }
2 · Bundled as an ES module. The core is a graph of circularly-importing ES modules with a webpack-only palette loader. Building the boot entry with esbuild (--bundle --format=esm --loader:.pal=dataurl) keeps live module bindings and inlines the palettes; the page loads the result with <script type="module">. Because a module is deferred, EMU_BOOT is published asynchronously, so the debug plug-in polls for it.
Techniques for deeper access. The plug-in reaches into the running machine through the core's own surfaces — no fork:
- The Sally CPU is a 6502. Atari's SALLY is a stock 6502 core, so it reuses the shared
mos6502disassembler and the standard status-flag layout (C Z I D B V N) with no new decoder. - Direct registers.
Sally.GetSallyA/X/Y/S/P()and their setters read and write the CPU live; the program counter is aPair, written throughGetSallyPC().setW(). - Side-effect-free reads. The hex and disassembly views read the raw
Memory.ramarray, so auto-polling the view never acknowledges a RIOT timer or an interrupt flag the wayMemory.Read()would; pokes still go throughMemory.Write()for correct banking. - Instruction step and breakpoints.
Sally.ExecuteInstruction()advances exactly one instruction. With breakpoints set, the loop runs instruction-by-instruction and halts the moment the PC reaches a watched address; with none set it runs whole frames at full speed with correct Maria video and RIOT timing.
Everything the debugger shows — registers read/write, memory hex/disassembly, follow-PC, single-step and execution breakpoints — is built from these, with no changes to the emulator core.
Architecture
JS7800 is a port of Greg Stanton's ProSystem, a readable interpreted Atari 7800 emulator. Each chip of the 7800 is its own module:
Sally— the 6502 CPU (Atari's "SALLY" variant), with the 64 KB memory map and cartridge banking throughMemory.Maria— the 7800's display processor: a DMA-driven graphics engine that reads display lists and rasters scan-line by scan-line into a blit surface.Tia— the 2600-era TIA, retained on the 7800 for two audio channels (and 2600 backwards compatibility).Riot— the 6532 RIOT: the joystick / console-switch ports and the interval timer.Pokey/Xm— optional POKEY sound in some carts, and the Expansion Module (POKEY + YM2151).Cartridge— parses the.a78header, hashes the ROM and selects the bank-switching scheme;Regionholds the NTSC/PAL palettes and timing.
Each ExecuteFrame() walks the scan lines, running the CPU up to each Maria DMA and WSYNC boundary and firing the vertical-blank NMI, then hands the finished framebuffer to Video. Because every component is an ordinary object, the whole machine state is inspectable — which is what the debugger reads each refresh.
Sound
Pattern V-stub. The 7800's sound hardware is fully emulated in the core — the 2600-era Tia gives two audio channels, and some carts add Pokey or the Expansion Module's YM2151. What was missing in our embed is delivery: our pausable boot loop drives the ProSystem core directly and never called the core's own WebAudio path (core/web/audio.js), so the emulated TIA ran silently. We re-enable it and route its samples through the shared emulators.org sink (window.EmuAudio), rather than the core's own AudioContext.
Where the samples come from. Each video frame ProSystem.ExecuteFrame() fills Tia.buffer (two samples per scan line). Right after it, the boot calls Sound.Store(), which resamples that TIA buffer (plus POKEY / YM2151 when a cart uses them) to the output rate and hands the boot a frame of mono samples through a store callback.
Sample rate / pitch. The output rate is the one constant that governs pitch, so at start-up the boot sets it to the shared context's rate with Sound.SetSampleRate(EmuAudio.sampleRate) (this also propagates to POKEY and the YM2151). The core then produces about EmuAudio.sampleRate / 60 samples per frame with a Bresenham split of the remainder, so a whole second sums to exactly the context rate — no drift and no extra resampling.
Delivery. The store callback converts each mono sample to interleaved-stereo Int16 (a one-pole DC blocker removes the TIA's unipolar bias, for a cleaner tone and no click when the mute gain steps), duplicates it to L and R, and pushes one frame with EmuAudio.push():
function audioCallback(sample, ym, length) {
var stereo = new Int16Array(length << 1);
for (var i = 0; i < length; i++) {
var mono = ym ? (((sample[i] / 255) + (ym[i] / 128)) / 2) : (sample[i] / 255);
var y = mono - dcPrevIn + 0.995 * dcPrevOut; // DC blocker
dcPrevIn = mono; dcPrevOut = y;
var s = (clamp(y * 3.0) * 32767) | 0;
stereo[i << 1] = s; stereo[(i << 1) + 1] = s;
}
EmuAudio.push(stereo);
}
Mute contract. EMU_BOOT.transport.isMuted() / setMute() delegate to EmuAudio, which starts muted (browsers block audio before a gesture) and holds a gain node at 0 until the shell's Sound button unmutes from a real click. Because the vendored core would otherwise open its own competing AudioContext (via Region.js → Webaudio.reinit), the boot sets window.__JS7800_EMBED_AUDIO and core/web/audio.js skips its path when that flag is present, leaving the shared sink as the single audio owner.
Starting the game. The machine boots to Beef Drop's genuine title screen and waits there. Press RESET, Select or Start, or use the on-screen controls, to begin play. Sound is fully supported once the game runs; the page starts muted, so click the Sound button to hear it. The title screen may play the ROM's own music.