npm.io
0.3.0 • Published 1 month ago

@sfotty-pie/sfotty

Licence
MIT
Version
0.3.0
Deps
0
Size
586 kB
Vulns
0
Weekly
0

@sfotty-pie/sfotty

Sfotty is a cycle-exact 6502 (Sixty Five Oh Two) emulator. MOS Technology 6502 is an 8-bit microprocessor introduced in 1975 that powered many classic consoles and home computers such as Atari 2600, Atari 8-bit family, Apple II, Nintendo Entertainment System, Commodore 64, Atari Lynx, and BBC Micro.

Sfotty implements all documented and undocumented opcodes, and is verified cycle by cycle against the SingleStepTests/65x02 test suite.

Contributors: see design.md for how it works internally.

Usage

npm install @sfotty-pie/sfotty

The CPU must be provided with a Memory implementation during construction, and then you can drive it by calling cycle() in a loop. The CPU will call the bus's read and write methods on every cycle, and you can react to those calls to implement memory-mapped I/O, breakpoints, execute traps, or whatever else your system needs.

import { Sfotty, type Memory } from "@sfotty-pie/sfotty";

const ram = new Uint8Array(0x10000);

const bus: Memory = {
  read: (address) => ram[address]!,
  write: (address, value) => {
    ram[address] = value;
  },
};

// LDA #$2A; STA $00 at $0600, with the reset vector pointing at it.
ram.set([0xa9, 0x2a, 0x85, 0x00], 0x0600);
ram.set([0x00, 0x06], 0xfffc);

// A new CPU powers on into the 7-cycle reset sequence, like the real chip.
const cpu = new Sfotty(bus);

for (let i = 0; i < 12; i++) cpu.cycle(); // 7 reset cycles + LDA (2) + STA (3)

console.log(cpu.A); // 42
console.log(ram[0x00]); // 42

Both bus methods receive ReadOptions bit flags that classify the access (side-effect-free peek, opcode fetch, dummy cycle, DMA), so read- and write-sensitive I/O registers can react correctly - as in the example, an implementation that doesn't care may just omit the parameter.

Beyond cycle(), the CPU exposes the registers and flags, the RDY/IRQ/NMI input lines, reset(), hardware-variant construction options, and a small disassembler ships in the same package - see the JSDoc comments on the exported types for the full surface.

Traps and asynchronous behavior

cycle() is synchronous, so the bus can't return a promise - but it can throw. Throwing from read or write is the supported way to suspend the CPU for anything that can't be answered inline: asynchronous I/O behind a memory-mapped register, a debugger pausing on a breakpoint or watchpoint, an execute trap on a magic address, and so on.

This is safe because the first thing every cycle does is its single bus access - no register or internal state is touched before it. A throw therefore unwinds with the CPU in its exact pre-cycle state, and once the host has reacted it calls cycle() again to retry the same cycle from scratch.

const TRAP = Symbol("trap");

class Bus implements Memory {
  read(address: number, options: ReadOptions): number {
    if (address === 0xd000) throw TRAP; // a memory-mapped register, say
    return this.ram[address]!;
  }
  write(address: number, value: number): void {
    /* ... */
  }
}

try {
  cpu.cycle();
} catch (e) {
  if (e !== TRAP) throw e;
  await serviceRegister(); // react: do async I/O, pause in a debugger, etc.
  cpu.cycle(); // retries the exact same cycle
}

The thrown value can be anything; the host already knows the address and access kind from its own bus, so a sentinel like the symbol above is usually enough.

License and credits

MIT license.

Keywords