SearchA-ZE › ElkJS

ElkJS

2020 Open source · GPL-2.0 Online

ElkJS is a JavaScript-based Acorn Electron emulator by Darren Coles that runs directly in a web browser without any download. Inspired by the JSSpeccy project, it emulates the Electron's 6502 and ULA accurately enough to play many of the classic games written for the machine.

Visit the official site ↗

Runs on: Web browser

ElkJS Online Emulator

Play ElkJS using JavaScript directly in your browser.

Configurations

ConfigurationEmulatorMachineOSLegal
Acorn ElectronElkJSAcorn ElectrongreyOpen ⛶

Machines emulated

Chips

Notes

Embedding

ElkJS is a set of plain-global JavaScript modules (no bundler). Vendor the engine scripts and load them in order, then drive the machine yourself instead of calling the stock ElkJs(id) driver - its internal setTimeout loop cannot be paused or single-stepped from outside, which the debugger needs.

Boot. Replicate the driver's wiring (each chip is a factory hanging off the global ElkJs function), point the Display at a <canvas>, then run your own loop that mirrors the stock frame, 312 scanlines, stepping the CPU one scanline at a time and clocking the display and the RTC / vertical-blank interrupts:

var sheila    = ElkJs.Sheila({});
var keyboard  = ElkJs.Keyboard({});
var memory    = ElkJs.Memory({ sheila: sheila, keyboard: keyboard });
var display   = ElkJs.Display({ sheila: sheila, memory: memory, output: "screen" });
var processor = ElkJs.Processor({ memory: memory, sheila: sheila, display: display });
processor.initialise();                       // build the opcode table
(function frame(){
  display.startFrame();
  while (display.beamRow < 312) {
    display.startRow(); processor.runCode(); display.processRow();
    if (display.beamRow == 99)  sheila.trigger_rtc();
    if (display.beamRow == 255) sheila.trigger_vbl();
  }
  requestAnimationFrame(frame);
})();

The machine is plain objects. Everything the debugger needs is a live global, with no wasm heap to reach into:

MemberKindWhat it does
processor.runCode()methodRun the CPU for one scanline's worth of cycles (~128). The stock stepping unit.
processor.reset6502()methodReset the CPU (reloads PC from the $FFFC vector).
memory.readmem(a) / writemem(a,v)methodThe banked bus: 32K RAM, the paged BASIC ROM ($8000), the OS ROM ($C000) and the ULA I/O window ($FE00).
memory.makeSnapshotData()methodSerialise the 32K RAM; processor.makeSnapshotData() serialises the registers.
displayfieldThe video ULA raster; startFrame blits the previous frame's ImageData to the canvas, processRow draws one scanline.
sheilafieldThe ULA registers: romBank, screenMode, interrupt, and the RTC / vertical-blank interrupt triggers.
keyboard.readkeys(addr)methodScans the 14-row key matrix that the OS ROM reads through the paged keyboard window.

Because the CPU, bus and RAM are ordinary JavaScript, the debugger single-steps the 6502, reads and writes registers, disassembles memory and implements breakpoints and watchpoints as host-side checks around these calls.

Debugger integration

Wiring ElkJS into the shared in-browser debugger needed a boot shim and a small patch to the CPU core, because ElkJS keeps its 6502 registers private.

1 · A boot shim, because the stock driver's loop is not controllable. ElkJs(id) hides every chip inside a closure and runs a fixed setTimeout frame loop. We instead build the chips ourselves (they are factories on the global ElkJs function) and run a loop we control, so pause / step / breakpoints work. When breakpoints are set, the loop runs the CPU one instruction at a time and checks the PC before each:

// our loop: one scanline, but stop before any instruction whose PC is watched
if (processor.runCodeChecked(breakpoints, resumeSkip)) { running = false; }

2 · A ~10-line patch to expose the registers. Tom Walker's 6502 core (from Elkulator) stores a, x, y, s, pc and the flags object p as closure-private variables - only makeSnapshotData / loadSnapshot could see them, which is too coarse for a live debugger. We added live getters/setters so the debugger can read and write them each refresh, plus a single-instruction step and the breakpoint-checked runner above:

// added to processor.js, just before "return self"
Object.defineProperty(self, 'a',  { get: function(){ return a; },  set: function(v){ a = v & 0xff; } });
Object.defineProperty(self, 'pc', { get: function(){ return pc; }, set: function(v){ pc = v & 0xffff; } });
Object.defineProperty(self, 'p',  { get: function(){ return p; } });   // live flags object

Techniques for deeper access. The plug-in then reads the running machine through these surfaces:

  • Side-effect-free reads. The hex and disassembly views scrub memory with a patched memory.peek(addr) that skips RAM-sync stalls and never touches the ULA I/O window ($FE00–$FEFF), whose reads clear interrupt latches and the cassette flag.
  • Direct state. Registers are the newly-exposed live properties (processor.pc/.a/.x/.y/.s, and processor.p for the flags, normalised to 0/1 in the plug-in).
  • Breakpoints. runCodeChecked(set) compares the PC against a JS Set before each instruction and pauses at-speed; a resume steps off the current breakpoint first.
  • Watchpoints. The plug-in wraps memory.writemem, so a write to a watched address pauses the loop on the next scanline boundary.
  • Single instruction step. processor.stepInstruction() runs exactly one opcode and returns the new PC.

Everything the debugger shows, registers read/write, memory hex/disassembly, follow-PC, single-step, breakpoints and watchpoints, is built from these, with a tiny, self-contained patch to the vendored core.

Architecture

ElkJS is a readable, interpreted Acorn Electron by Darren Coles, adapting Tom Walker's Elkulator 6502 core to JavaScript. Each block is its own factory on the global ElkJs function:

  • Processor - the 6502 interpreter; runCode() executes one scanline of cycles, with a full opcode table and the ADC/SBC/BCD paths.
  • Memory - the address map: 32K RAM ($0000), the paged ROM slot ($8000, normally BASIC or the keyboard window) and the OS ROM ($C000), with the ULA I/O hole at $FE00.
  • Sheila - the ULA registers: ROM paging, screen mode, sound latches and the interrupt controller (RTC and display-end / vertical-blank interrupts).
  • Display - the video ULA, rastered scanline-by-scanline into an ImageData and blitted to the canvas each frame; MODEs 0–6.
  • Keyboard - the 14-row key matrix, read through the paged keyboard window at $8000-$BFFF.
  • ROMs: the Acorn OS and BBC BASIC, the two 16K system ROMs, loaded at boot.

Each scanline steps the CPU and draws one row; the RTC and vertical-blank interrupts fire at fixed beam rows. Because every component is an ordinary object, the whole machine state is inspectable, which is what the debugger reads each refresh.