Alternate-history fiction: Avenell Stagecraft and the Cyclorama console never existed. The hardware described here is real — fully specified, and emulated at playcyclorama.com. Play ERG: NIGHTSIDE RUN

Cyclorama SDKEmbedding the emulator

Embedding the emulator

The emulator is a TypeScript library with no dependencies and no I/O of its own: you give it a ROM and controller input, and it gives you machine state, pictures and sound. The same code runs the tests in Node and the reference player in the browser. This page walks through examples/embed/headless.ts, a complete program that a test runs on every check.

node examples/embed/headless.ts

It prints the lamp's position, the picture and sound it collected, and two lines saying that a restored save state and a fresh run both reach exactly the same state.

A ROM and a machine

From examples/embed/headless.ts, lines 7–15

import { Machine } from "../../src/machine/machine.ts";
import { Tally } from "../../src/machine/cpu.ts";
import { buildOne } from "../../tools/build-roms.ts";

// 1. A ROM: any cartridge image works; here Tutorial 1's step 2, built from source.
const rom = buildOne({ name: "step2", file: "examples/tutorial-1/step2.asm", lang: "asm" });

// 2. A machine: the Tally CPU core is passed in; the Cue audio chip is the default.
const m = new Machine(rom, { cpu: new Tally() });

A ROM is a Uint8Array: read one from a file, or build one from source with buildOne (CYASM or SCRIM, the same function the ROM build uses). The Machine powers on at construction: registers take their reset values and the CPU starts at the RESET vector. A cartridge without the CYCL signature at 0010 is refused as the console would refuse it: m.running is then false and no instruction executes.

Running it

From examples/embed/headless.ts, lines 17–20

// 3. Run frames. Each runFrame takes the two controllers' buttons (bit 0 Up, 1 Down,
//    2 Left, 3 Right, 4 A, 5 B, 6 C, 7 START) and runs until the next frame starts.
const RIGHT = 8, A = 16;
for (let f = 0; f < 30; f++) m.runFrame(f === 5 ? A : RIGHT, 0);

runFrame(pad1, pad2) emulates one frame, 358,416 master clock cycles (59,736 CPU cycles), about 1/60 s of console time. It takes as long as it takes on your computer: the machine has no idea of host time, so it can run faster than real time for tests or be paced by a display. The buttons you pass are what the controllers show when the console samples them at the start of vertical blank. m.frame counts the frames run.

Reading the results

From examples/embed/headless.ts, lines 22–29

// 4. Read what the program did: work RAM (8000-BFFF is wram[0..16383]), the picture
//    (256 x 224 pixels, 0xRRGGBB), the sound (16-bit mono samples at about 47,940 Hz).
const word = (addr: number) => m.wram[addr - 0x8000] | (m.wram[addr - 0x8000 + 1] << 8);
const lit = m.framebuffer.filter(p => p !== m.framebuffer[0]).length;
const sound = m.audio.takeSamples();
let peak = 0;
for (const v of sound) peak = Math.max(peak, Math.abs(v));
console.log(`after ${m.frame} frames: lamp at x=${word(0x8000)} y=${word(0x8002)}, ${lit} lamp pixels, ${sound.length} audio samples, peak ${peak}`);

Save states

From examples/embed/headless.ts, lines 31–38

// 5. Save states: a save names its ROM by SHA-256 and restores the machine exactly.
const saved = m.saveState();
for (let f = 0; f < 60; f++) m.runFrame(0, 0);
const later = m.hash();
const copy = new Machine(rom, { cpu: new Tally() });
copy.loadState(saved);
for (let f = 0; f < 60; f++) copy.runFrame(0, 0);
console.log(`save state: ${saved.length} bytes; restored and re-run: ${copy.hash() === later ? "identical" : "DIFFERENT"} (hash ${later})`);

saveState() returns the complete machine (CPU, registers, work RAM, cartridge RAM, video memory, the audio chip and the beam position) as one byte array that names its ROM by SHA-256. loadState checks everything first and throws a SaveStateError without changing the machine if the state is truncated, corrupt, from another ROM or from an unknown version. The format is specified in the spec notes ("Save-state format").

Determinism

From examples/embed/headless.ts, lines 40–44

// 6. Determinism: a fresh machine given the same input reaches the same state, bit for bit.
const again = new Machine(rom, { cpu: new Tally() });
for (let f = 0; f < 30; f++) again.runFrame(f === 5 ? A : RIGHT, 0);
for (let f = 0; f < 60; f++) again.runFrame(0, 0);
console.log(`fresh run with the same input: ${again.hash() === later ? "identical" : "DIFFERENT"}`);

The emulated clock alone decides timing, and nothing in the machine reads the host's time or randomness. So a ROM and a list of per-frame controller inputs determine every later state: m.hash() (8 hex digits over the complete state) is equal for equal histories on any computer, in Node or in a browser, at any display rate. That is what makes completion replays, golden frames and these guides' pictures reproducible, and what a netplay or rewind feature can rely on.

In a web page

The reference player (src/web/player.ts) is the browser host: it paces whole frames to the display, draws the framebuffer on a canvas, plays the sound through an AudioWorklet, and reads the keyboard and gamepads. Its entry script is short:

From src/web/main.ts, lines 10–22

const bin = atob(window.__ROM_B64__);
const rom = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) rom[i] = bin.charCodeAt(i);

const canvas = document.getElementById("screen") as HTMLCanvasElement;
const player = new Player(canvas, rom);
window.__player = player;
player.start();

const overlay = document.getElementById("start")!;
const begin = async () => { overlay.remove(); try { await player.enableAudio(); } catch (e) { console.warn("audio unavailable", e); } };
overlay.addEventListener("click", begin, { once: true });
window.addEventListener("keydown", () => { if (overlay.isConnected) begin(); }, { once: true });

npm run player bundles it with a ROM into one page; for your own page, bundle src/web/player.ts with your tool of choice and hand the Player a canvas and a ROM. Browsers only allow sound after a user gesture, hence enableAudio on the first click or key press. In the standalone page F5 saves a state and F8 restores it.