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 SDKDebugging

Debugging

The SDK's debugger is a library with no user interface: DebugSession adds instruction stepping, PC breakpoints and write watchpoints to a machine, and views.ts turns machine state into things to show: disassembly around the PC, hex dumps, the cartridge header, sprites, lights and the light field's shade at any pixel. Both run in Node and in the browser, so you can script a debugging session, write a test that stops at a label, or build your own debugger front end on them.

This page walks through examples/debug/trace.ts, which a test runs on every check. It debugs Tutorial 1's step 2 (Tutorial 1 explains the program).

node examples/debug/trace.ts

A session with symbols

From examples/debug/trace.ts, lines 13–19

// Assemble with symbols, so breakpoints and watchpoints can use the program's own names.
const built = assemble("examples/tutorial-1/step2.asm", sourceReader(p => readFileSync(p)));
const sym = (name: string) => built.symbols.get(name)!;
const s = new DebugSession(built.rom);
const m = s.machine, cpu = m.cpu;
const regs = () => `R0-R7 ${[...cpu.r].map(hex4).join(" ")}  SP ${hex4(cpu.sp)}  flags ${flagString(cpu.f)}`;
const NONE = [0, 0] as const, RIGHT = [8, 0] as const, A = [16, 0] as const;

assemble returns the ROM together with its symbol table, a map from every label and constant to its value, and labelBanks, the bank of every label (npm run asm -- … --sym file writes both to a file). A DebugSession owns a Machine (s.machine): everything in Embedding applies to it. The CPU's registers are cpu.r (R0-R7), cpu.sp, cpu.pc and cpu.f (the flags: Z, N, C, V and the interrupt enable I).

Breakpoints

From examples/debug/trace.ts, lines 21–25

// 1. A breakpoint on the VBLANK handler: advanceFrame stops there instead of finishing the frame.
s.breakpoints.add(sym("vblank"));
const hit = s.advanceFrame(RIGHT);
console.log(`break: ${hit?.kind} at ${hex4(cpu.pc)} (vblank), frame ${m.frame}`);
console.log(regs());

breakpoints is a set of addresses. advanceFrame(pads) runs to the end of the frame, or stops before an instruction at a breakpoint address and returns { kind: "breakpoint", pc }. It stops here at the first VBLANK: the interrupt has been taken (SP is two words lower: the CPU pushed the return address and the flags) and PUSHM is next.

Stepping

From examples/debug/trace.ts, lines 27–32

// 2. Step three instructions, showing each one before it runs.
for (let i = 0; i < 3; i++) {
  const [line] = disasmWindow(a => m.bus.read8(a), cpu.pc, 0, 0);
  s.stepInstruction(RIGHT);
  console.log(`step: ${hex4(line.addr)}  ${line.text.padEnd(20)} -> ${regs()}`);
}

stepInstruction(pads) executes exactly one instruction (or one interrupt entry); from WAI it idles until the CPU resumes, at most two frames. disasmWindow(read, pc, before, after) disassembles around an address through any byte reader; m.bus.read8 reads what the CPU sees. The output shows PUSHM R0-R3 taking SP down four words, then the load of PAD1 and of the lamp's x position (0078 = 120).

Stepping is exact: the session calls the same machine step a free-running frame does, so a stepped machine computes exactly what an unstepped one does. One difference to know about: while a frame is being stepped, the pads it latches are the ones passed when the previous frame closed. That is why PAD1 reads 0 in this first VBLANK even though Right is passed.

Watchpoints

From examples/debug/trace.ts, lines 34–39

// 3. A watchpoint on the palette variable: pressing A writes it, and execution stops there.
s.breakpoints.clear();
s.watchpoints.add(sym("LPAL"));
let w = null;
for (let f = 0; f < 10 && !w; f++) w = s.advanceFrame(f < 3 ? NONE : A);
if (w?.kind === "watch") console.log(`watch: LPAL (${hex4(w.addr)}) <- ${hex4(w.value)} by the instruction at ${hex4(w.pc)}, frame ${m.frame}`);

watchpoints is a set of CPU addresses; a word write covers both of its bytes. When the program writes one, advanceFrame stops after the writing instruction and returns { kind: "watch", addr, value, pc }, where pc is the address of the instruction that wrote. Here the first new press of A swaps the palette, and the watchpoint names the ST that stored it.

Looking at the video chip

From examples/debug/trace.ts, lines 41–45

// 4. Finish the frame and decode sprite 0 from OAM.
s.watchpoints.clear();
s.advanceFrame(NONE);
const lamp = decodeOam(m.video.oam, 0);
console.log(`sprite 0: x ${lamp.x} y ${lamp.y} tile ${lamp.tile} palette ${lamp.palette} size ${lamp.size}${lamp.hidden ? " hidden" : ""}`);

With no breakpoints or watchpoints, advanceFrame runs the frame at full speed. decodeOam reads one sprite's four OAM words into named fields. The other helpers in views.ts:

HelperWhat it gives
hexRowsHex dump rows of any memory region
parseAddressAn address from text: hex, or a symbol name
parseHeaderThe cartridge header: title, developer code, date, version, banks, cartridge RAM, and the stored and computed checksums
decodeLight, litShadeA light's registers in named fields; the shade the light field computes at a pixel
shadeOverlayThe shade of every pixel of the current frame, for a false-colour view
renderTileSheet, renderMapVRAM tiles at a chosen palette and shade; a tile map (named by its map register) at a chosen shade

To start a session from a moment you reached elsewhere, save the state there with machine.saveState() (the reference player's player.saveState() gives the same bytes) and load it into a session with s.loadStored(state, title). It returns null when the state loaded, or the reason it refused it (another ROM, a damaged state), and a refused state changes nothing.