6502 as a service

API reference

A transistor-level MOS 6502 over HTTP, one half-cycle at a time.

Nothing behind this API models 6502 behaviour. Every request settles a network of 3510 transistors and 1725 nodes, the same engine the visual explorer runs, verified bit-exact against the original visual6502 simulation. Every register in every response is read back out of its own storage nodes on the die; every cycle count is emergent.

The server is stateless. Each request carries the whole machine in a Machine object: the chip's four state bitsets (about 2 KB of hex) plus a sparse 64 KiB memory. Each response carries the whole machine back. Your copy of that object is the session. Save it to a file, diff two of them, or hand one to somebody else mid-instruction; any server instance can pick any of them up. Resuming from a serialized machine is proven bit-exact over every node at every half-cycle.

Three requests

Assemble, boot, step

# 1. Boot a program. The source is assembled, laid into memory at its
#    .org, the reset vector aimed at it, and the chip power-cycled through
#    its real reset sequence. The machine comes back standing at its first
#    opcode fetch.
curl -s https://6502.tinymachines.ai/api/v1/boot -H 'content-type: application/json' -d '{
  "rom": { "source": "        .org $0200\nstart:  LDA #$2E\n        STA $80\n        LDA #$14\n        STA $81\nsum:    CLC\n        LDA $80\n        ADC $81\n        STA $82\n        JMP start" }
}' > m0.json

# 2. Step 41 half-cycles, tracing every one and watching two named wires.
python3 -c 'import json; m = json.load(open("m0.json"));
print(json.dumps({"machine": m["machine"], "half_cycles": 41,
                  "trace": True, "watch": ["sync", "sb0"]}))' |
  curl -s https://6502.tinymachines.ai/api/v1/step -H 'content-type: application/json' -d @- > m1.json

# 3. Read the answer out of the silicon: A holds $42 (0x42 = 66), and
#    $2E + $14 = $42 sits in memory at $0082.
python3 -c 'import json; r = json.load(open("m1.json"));
print(hex(r["observe"]["a"]), r["observe"]["flags"]);
print(bytes.fromhex(r["machine"]["memory"]["pages"]["00"])[0x82])'

To keep going, POST m1.json's machine back to /v1/step. Stepping 20 and then 21 half-cycles across two requests is byte-identical to stepping 41 in one; the test suite pins it.

Endpoints

Six routes that run the chip

GET/healthz

Liveness. Returns {"ok": true}.

GET/v1/meta

What the engine is and where its edges are.

fieldvalue
chip"mos6502"
nodes1725
transistors3510
max_step200000 half-cycles per request; shard longer runs
max_traced10000 half-cycles per request when trace is on
encodingthe bitset wire encoding, stated (see Encodings)

GET/v1/nodes

Every name watch accepts, with its node id, grouped: rails, pins, registers, buses, datapath, decode, timing, other. The grouping is a reading of the names, an authored convenience; the names and ids are the die's own. datapath is the 49 dpc* lines: decode's outputs, but what they operate is the datapath's switches (dpc23_SBAC opens the special bus into A), so they are filed where somebody hunting "how do I watch the datapath" will look. 832 names resolve: the die's table carries 846 raw entries, 12 of them duplicate keys, and 2 of the 834 that remain are the bit-5 sentinels p5 and Pout5, naming storage the 6502 does not have. Static data: the die does not change, so cache it as hard as you like.

POST/v1/assemble

Assemble source without running it. The assembler is the site's own (web/asm.js), which inverts the disassembler's opcode table, so there is exactly one opinion in this project about what a line of assembly means.

Request is a Rom. Response is an AssembleResponse: the bytes, the label table, and a listing with an address and bytes per line. Errors: 422 with {"error", "line"} naming the offending line.

POST/v1/boot

Start a machine. With a rom, the source is assembled and laid over memory at its org, and reset_vector defaults to that org. The chip is then power-cycled through the 6502's real reset sequence and returned standing at its first opcode fetch (sync high, PC at the vector, half_cycle 0).

request field
romoptional Rom
memoryoptional SparseMemory, default all zeroes
reset_vectoroptional 0..65535; without it and without a rom, the chip runs whatever $FFFC points at, exactly like the silicon
watchoptional list of node names to read in the observation

Response is a StepResponse with stepped: 0 and, when a rom was supplied, the assembled listing.

POST/v1/step

Advance a machine. Give exactly one of half_cycles or until: "instruction"; supplying both or neither is a 422.

request field
machinethe Machine from the previous response, unchanged or edited
half_cycles1..200000: advance exactly this many clock phases
until"instruction": run to the next opcode fetch (sync high, clk0 low). "cycle": one whole clock cycle, two half-cycles
until_pc0..65535: run to the opcode fetch at this address. A breakpoint, with the same honesty at the bound: a loop that never fetches there returns completed: false
max_half_cyclesbound for until and until_pc, default 200. A JAM opcode never reaches another fetch; the response says completed: false at the bound rather than hanging
pinsinput pins to drive before stepping: {"irq": 0}. Names res, irq, nmi, rdy, so, values 0 or 1. These are levels, not assertions: four of the five are active low, so 0 asserts them. The drive lives in the machine's own pull state, so a pin stays where you put it across requests until set again. rdy: 0 stalls the chip mid-instruction; irq: 0 with the I flag clear runs the whole interrupt sequence, which is the 6502's BRK in disguise
watchnode names, read at the end and at every traced step (see /v1/nodes for all of them). An unknown name is a 400, not a silently absent key
traceif true, one Observation per half-cycle
format"objects" (default) or "rows": the trace as columnar rows in trace_rows, the same information in fewer bytes. Measured on the trace payload alone (the machine, identical under both formats, excluded): 3.7x smaller at 45 half-cycles watching 2 nodes, 7.5x at 133 watching 22. Gzipped the two forms nearly converge, so rows earns its keep as parse time and allocation. Encodings in the response model and in Encodings

The atlas

Seven routes that ask what a wire is part of

/v1/nodes above answers what can I watch, and its grouping is a reading of the names. These five answer what is this node part of, and every answer is measured. Twenty-three kinds of machinery are walked out of the switch network itself: the ALU as eight bit slices with its inputs and carry ends, the status register as one container per flag, the four registers as their storage closures plus the lines that move each, the timing chain as the cells that compute each T-state, the decimal correction as everything its names are wired into. The derivation is web/chip-groups.js, the same module the tracer and the chip map draw with, so the API and the drawings cannot disagree.

Two layers, and the difference is the point. The partition is 132 groups with every one of the 1547 nodes in exactly one, because a drawing needs disjoint boxes and ownership goes to the most specific claim. The containers are the same derivations unfiltered: 135 of them, overlapping, with 88 nodes in more than one and three (sdp:sd1, sdp:sd2, sbus:link) that exist only there, absorbed whole by a container that outranks them. Asking which group a node is in and getting one answer is a fact about the drawing, not about the chip.

Nothing here runs the chip and none of it changes, so every response is served public, max-age=86400.

GET/v1/atlas

What the atlas holds: the 23 container kinds with their sizes, the twelve functional blocks plus the static logic and the residue, the three node roles, and the bounds a walk is held to. The counts are the chip map's own: 132 groups, 534 bundles carrying 1644 gate legs and 310 switches between groups, 922 and 313 staying inside one.

GET/v1/atlas/full

The whole atlas in one response. Every kind, every group with its members and the bundles it anchors, every overlapping container, every node with its tags, and all 534 bundles. About 328 KB, 48 KB gzipped, which is less than /v1/tags alone costs. Grab it once and answer every question below locally.

It is the exporter's own file byte for byte, not a re-serialisation of it, so a consumer holding this is holding exactly what the service is holding. The one thing it does not carry is the die's alias table: nodes[].name is one name per node and 125 nodes have more than one, so fetch /v1/nodes (4.6 KB gzipped) beside it if you need to resolve any of the 832 names.

GET/v1/groups

The containers, filtered.

querymeaning
layerpartition (default, 132 disjoint), containers (135, overlapping) or absorbed (the 3 that exist only in the overlapping layer)
kindone kind: alu, flags, regs, alat, dbus, irp, sbus, sdp, rdy, pcr, pipe, sync, clock, intr, branch, decimal, incr, chain, bus, stage, pins, rest, logic
parentonly the children of one group key. parent=regs:a gives the accumulator's three load lines
blocka functional block, by id or name: which machinery reaches into the ALU
qsubstring of the key or the label
min_nodesdrop groups smaller than this
membersinclude each group's node list

GET/v1/groups/{key}

One container, in full: its parent and children, the blocks its nodes are filed in, every other container it shares nodes with and how many, the bundles it anchors (the gate legs and switches crossing to each neighbouring group, with the control lines on them, and the gate direction reported from this group's side), and its members.

layer=containers returns the derivation's own node set instead of the partition's, with owned and claimed_elsewhere beside it. intr:nmi is 20 nodes as a walk and 18 as a box, because the pipeline latch file outranks the interrupts and keeps pipeVectorA2: the one address bit by which $FFFA differs from $FFFE, which the walk found without being told.

Keys are kind:id. Five of them carry a slash of their own (alat:ADL/ABL is one load line, not two path segments), so the route takes the rest of the path as the key.

GET/v1/tags

Nodes with their tags: the group that owns each, every container it is in, its functional block, role, pull-up, die centroid and degree. A separate route from /v1/nodes rather than a mode of it, because that one answers a different question and consumers depend on its shape.

querymeaning
groupevery node in one group key
kindevery node in any container of one kind
blocka functional block, by id or name
rolesignal, decode term (122 of them) or control line (46)
qsubstring of the node name
namedtrue for the 705 named nodes, false for the 842 the die never named
multionly the 88 nodes in more than one container
limit, offsetpaging; total says how many matched

GET/v1/node/{ref}

One node, by any die name or by number. 125 nodes carry more than one name (p4 and Pout4 are one wire), and every one of them resolves; 47 names carry a slash, so the route takes the rest of the path as the reference. An unnamed node answers to its number, with or without the # the pages print (%231446, since a bare # is a fragment and never reaches the server).

GET/v1/neighbors

What one node reaches, with each neighbour's own tags. Four relations, kept apart because they are four different things: drives (this node is an input to that gate), driven_by (that node is an input to the gate driving this one), channel (a pass transistor, which conducts both ways and therefore has no direction, reported with the control line that opens it) and opens (this node is a control line, reaching the two ends of the switch it operates, which is not a path through it at all).

querymeaning
nodea die name or a node number
viaall (default), gate, switch or control
directionboth, in or out, for the gate relations only
depth1 to 4
limitup to 2000; truncated says when the cap was reached

A rail is reported and never walked. vss and vcc reach hundreds of transistors, and stepping through one would join most of the chip into a single answer. Eleven gates really do take vss as an input, the permanently-off pull-up that makes RDY and S.O. inputs rather than outputs, so those legs appear with rail: true and the walk stops there. A control line is never followed as though it were a signal path either: cclk opens 243 switches, and expanding it buries whatever was asked about.

Cartridges

A ROM, its tiles, and the contract they were written to

There is no video hardware on this die and no interrupt in use, so a frame is not something the silicon knows about. It is an agreement between a ROM and whatever drives it, and that agreement is the whole console:

the hostthe ROM
clears one bytenotices, runs a frame, sets it back
writes one byte before each framereads it as the controller
reads a page of memorywrote tile indices into it

The ROM busy-waits on that flag, which is the only way to synchronise with the outside world when you have no interrupt and no timer. It works over HTTP because the API is stateless: the frame boundary is a memory edit between two /v1/step calls, and the whole machine travels in each one. Nothing about this was designed for games. It falls out of a design that carries the machine as a value.

The memory map

None of these are facts about the silicon. They are addresses two programs agree on, which is why they are published rather than left to be inferred from a game that already works.

rangewhat
$0000-$00FFzero page. The contract bytes live here, and it is where a 6502 keeps its variables: two bytes an instruction instead of three
$000Dtick. The host clears it; the ROM raises it when a frame is finished
$0002input. The controller, one byte, written by the host before each frame
$0003status, optional. The ROM raises it when the game is over
$0100-$01FFthe stack. A cartridge whose ROM or screen covers this is refused
$0200the usual .org for a ROM
$0500the screen: width * height bytes, one tile index a cell, row major. Put it above the ROM
$FFFA-$FFFFthe vectors. Booting writes the reset vector at $FFFC

A ROM that reaches its own screen is overwritten by the picture it draws, and it assembles and boots first, so nothing errors: Die Runner grew past $0400 when its scenery arrived and drew unrelated memory for a while. That is a refusal here rather than a game that runs and is wrong.

Tiles

8x8 pixels, two bits a pixel, sixteen bytes a tile: the NES shape, because it is what every old-school sprite tool emits and because four colours a tile is the constraint that makes the art look like the era rather than like a photograph.

bytes 0..7    bit 0 of each pixel, one byte a row, MSB the leftmost pixel
bytes 8..15   bit 1 of each pixel
colour        (plane1 << 1) | plane0   ->  0..3

The palette is the die's own, the four colours the exploded view paints the mask layers in: #0B1120 substrate, #3E93A6 diffusion, #E0A24B polysilicon, #4FBFD4 metal. Colour 0 is drawn, not skipped, because this is a tiled screen and not a sprite layer.

Art arrives in either of two forms and leaves in both. chr is the binary format above as hex, which is what a converter emits. pixels is eight strings of eight '0'..'3' a tile, which is the form something writing a cartridge from text can actually emit. Whichever arrives, both are written into the file, so a reader never has to decode and a drawing tool never has to parse ASCII.

"pixels": [["00333300",
            "03222230",
            "32000023",
            "32033023",
            "32033023",
            "32000023",
            "03222230",
            "00333300"]]     // one tile: a capacitor

GET/v1/console

Everything above as data: the contract, the memory map, the tile format, the palette, the cartridge format's version, and the smallest ROM that is a cartridge. Static, and a reading of nothing. Read this before writing one.

POST/v1/cartridge

Mint a cartridge: assemble the source, check the layout can work, run it on the chip, and pack the ROM, its tiles and the contract into one gzipped file. Returns application/gzip, or the document itself with ?format=json.

fieldwhat
roma Rom: the source and its org
consoletick, input, screen, width, height, and optionally status, score, entropy, gate_mask, frame_cost, dirs, watch
tiles{pixels: [...]} or {chr: "hex"}
metaname, author, blurb
frameshow many frames to run when verifying, default 3. Zero skips it

The refusals are the part worth having, and each one is a mistake that has actually been made here: a ROM overlapping its own screen, a ROM or a screen over the stack page, a ROM reaching the vectors, a contract byte inside the ROM (the host would be writing into the code), two contract fields sharing an address. Each comes back 422 with the reason and what to do about it.

Verification runs the thing. Assembling is not the same as being right: a ROM that assembles, boots and never raises its tick flag is a ROM that does not run on this console, and nothing short of running it says so. The report carries frames completed, the half-cycles each one cost, whether the screen changed, and which tile indices are on it. A screen that is one value everywhere is called out, because that is what a program drawing nothing looks like.

The frame cost is measured, not rounded up. Stepping past the tick flag cannot say when the flag went up, so the first pass doubles its chunks until the flag is set and the second walks the last chunk again in sixteenths. Reporting the chunk total instead was the first version, and it claimed 12,000 half-cycles for a frame costing 600: the chunker's arithmetic wearing a measurement's clothes. Die Runner's own page had carried a declared 12,000 for exactly that reason; measured, it is 8,704, steady over twelve frames.

The file

One gzipped JSON document, and it carries the contract with the ROM rather than beside it, because the contract is the part an outside author has to agree with and a contract in a different file from the bytes it governs is the copy that drifts.

{
  "format": "tinymachines.cartridge", "version": 1,
  "encoding": { /* how to read every field, in the file */ },
  "contract": { /* what each console address is for */ },
  "meta":    { name, author, blurb, minted },
  "rom":     { org, end, size, reset, bytes, labels, source },
  "console": { tick, input, screen, width, height, ... },
  "tiles":   { count, chr, pixels, palette },
  "verify":  { /* what the chip did when this was minted */ },
  "notes":   [ /* what is worth saying and is not fatal */ ]
}

The source travels too, so a cartridge can be read, edited and minted again by whoever has the file. Gzip mtime is zero, so minting the same cartridge twice gives the same bytes: a container that changes every time it is written cannot be diffed, and diffing two cartridges is how a person sees what an edit did.

# Mint one, then play it.
curl -s https://6502.tinymachines.ai/api/v1/console | python3 -m json.tool

curl -s https://6502.tinymachines.ai/api/v1/cartridge -H 'content-type: application/json' \
  -d @cartridge.json -o mine.cart.gz

# games.tinymachines.ai loads one from ?cart=<url> or from its file picker.
python3 -c 'import gzip,json,sys; print(json.load(gzip.open("mine.cart.gz"))["verify"])'

MCP

Five tools, each one a whole errand

POST /api/mcp speaks the Model Context Protocol over streamable HTTP: initialize, tools/list, tools/call. No session id is issued and no SSE stream is opened, for the same reason the API keeps no sessions. A GET returns 405, which is the spec's own answer for a server that offers no stream.

The tools are coarse where the HTTP routes are fine-grained, and that is the design rather than a shortcut. The API is stateless because a program holds the machine: 2 KB of hex travels out and back, and the client's copy is the session. An MCP client is a language model, and a model cannot usefully hold 2 KB of hex. Pasting a machine into the next tool call would spend most of a context window carrying a value it cannot read. So run assembles, boots, steps and reports in one call, and the machine never leaves the server.

toolwhat it answers
console_specwhat a ROM has to agree with. No arguments. Read it first
assemblesource to bytes, labels and a listing. An error carries the line
runwhat the chip did: registers, flags, memory you name, named wires you watch, and the screen
mint_cartridgesource and tiles to one verified file, base64
chip_atlaswhat a wire is part of, out of the 132 derived containers that cover all 1725 nodes

run renders the screen as two hex characters a cell, row major, and that is the one thing that turns writing a 6502 game from guessing into working: an assembler says the bytes are legal, and only the picture says the program is right. Give it frames and a controller byte to hold down, and it hands back what your ROM drew.

Give run exactly one of half_cycles, until_pc (a breakpoint at an opcode fetch) or frames. Addresses are hex with or without a $, because a model writes $0500 and a program writes 1280 and neither should have to learn the other's spelling.

# Hand-driving it: initialize, then call a tool.
curl -s https://6502.tinymachines.ai/api/mcp -H 'content-type: application/json' -d '{
  "jsonrpc":"2.0","id":1,"method":"initialize",
  "params":{"protocolVersion":"2025-06-18","capabilities":{},
            "clientInfo":{"name":"curl","version":"0"}}}'

curl -s https://6502.tinymachines.ai/api/mcp -H 'content-type: application/json' -d '{
  "jsonrpc":"2.0","id":2,"method":"tools/call",
  "params":{"name":"run","arguments":{
      "source":".org $0200\nstart LDA #$2E\n      CLC\n      ADC #$14\n      STA $82\n      JMP start",
      "half_cycles":41, "read":["0082"], "watch":["sync"]}}}'

A tool that refuses comes back as a normal result with isError set and the reason in the text, never as a JSON-RPC error: the model has to be able to read why and try again, and a protocol error is for the client rather than for the model.

Objects

The shapes that travel

Machine

The unit of statelessness: {"state": ChipState, "memory": SparseMemory}. POST it back to continue exactly where the last response left off.

ChipState

field
version1
half_cycleclock phases since reset. The chip does work on both edges, so this, not the cycle, is the fundamental unit
last_fetch{"addr", "opcode"} of the most recent opcode fetch, or null. Bookkeeping for disassembly, not silicon: IR alone cannot locate the operand bytes, because PC has moved past them
valueevery node's logic level: 432 hex chars
pullupper-node pullup state: 432 hex chars. Mutable, not a netlist constant; driving the data bus works by flipping the pull on db0..db7
pulldownper-node pulldown state: 432 hex chars
trans_onevery transistor's conducting state: 878 hex chars

A blob of the wrong length, a non-hex character, or a set bit in the padding past the last node is refused (422 at the model boundary, 400 at the engine): a state that decodes to the wrong chip is worse than one that is rejected.

SparseMemory

64 KiB as {"fill": "00", "pages": {"02": "<512 hex chars>"}}: a background byte plus only the 256-byte pages that differ from it. Keys are the page number in hex ("00".."ff", so page "02" starts at $0200). Responses are canonical: a page that is all fill is dropped, because "fill everywhere except the listed pages" is the entire meaning. Set fill to "ea" and an empty machine executes NOPs.

Rom

A program as source, not bytes: {"source": "...", "org": 512} (org optional, default $0200, and a .org directive in the source wins). Labels, forward references, .byte/.word data and comments are the assembler's; the programs page shows the dialect.

AssembleResponse

field
org, end, sizewhere the image starts and ends, and its byte count. A gap left by .org is filled with $00, not skipped: it is memory the chip fetches through
bytesthe image as hex
labelsname to address
listingper source line: {"n", "text", "label", "addr", "bytes"}, with addr and bytes null on lines that emit nothing

Observation

What a learner reads off the chip at one instant. All of it is read out of the silicon; none of it is modelled.

field
half_cycle, cyclewhere in time this reading was taken
clk0the clock input pin's level
phase"phi1" or "phi2": the internal phase, read from the on-die clock generator, which lags the pin
addr, data, rw, syncthe external bus. sync is high during an opcode fetch
pc, a, x, y, s, p, irthe registers, each read from its own storage nodes. P bit 5 reads as 1: no storage node exists for it, same as real silicon. S out of power-on is whatever its nodes came up as; reset only decrements it by three
flagsP as nv-BdIzC, uppercase where set
tstatesthe timing chain, e.g. "T0+T1". More than one can be active: the chain overlaps the tail of one instruction with the head of the next
hidden"T1", "VEC0", "T6" or "": the timing bits that never reach the decode PLA
store_data"SD1", "SD2" or "": the store-data pipeline, active in a store's or read-modify-write's data cycles
alu, alua, alub, sb, idb, idl, dor, adl, adh, abl, abh, pclp, pchpthe named internal storage, each byte read from its own wires: the adder's hold register and its two input latches, the special and internal data buses, the input data latch (what memory answered), the data output register (what a write will drive), the internal address bus, the address output latches (what the pins hold steady), and the program counter's prime latches (the incremented next PC). These are the values that live in no register: during ADC's overlap the operands sit in alua/alub, then the sum is real on alu and sb while a still reads the old value. A precharged bus idles high where nothing drives it, which is the silicon's answer, not a default
fetchthe last opcode fetch, for disassembly
watcheach watched node name to its level. Any of the 832 resolvable names on the die works: sync, sb0, cclk, dpc17_SUMS, ... and /v1/nodes lists them all

StepResponse

field
machinethe whole machine, after. POST it back to continue
observean Observation at the final half-cycle
steppedhalf-cycles actually advanced
completedfor until: whether the next fetch was reached before max_half_cycles. False is an answer, not a failure: twelve opcodes (the JAMs) never finish
tracewith trace: true, one Observation per half-cycle
assembledon boot with a rom: the AssembleResponse

Encodings

The bits on the wire

A bitset is lowercase hex of its bytes: bit i of the set is byte i/8, LSB first within the byte. Node numbering is visual6502's own, so node 0 is bit 0 of byte 0. The node sets (1725 bits) pack to 216 bytes, 432 hex chars; the transistor set (3510 bits) to 439 bytes, 878 chars. The same convention the simulator's halfshot export uses.

Memory pages are 256 bytes as 512 hex chars, plain byte order. All hex is accepted case-insensitively and returned lowercase.

The rows trace

With format: "rows", trace_rows carries {cols, watch_names, watch_encoding, rows}: one row per half-cycle, columns named by cols. Encodings, stated: clk0 and sync are 0/1; phase is 1 or 2; rw is 0 read, 1 write; tstates is a bitmask, bit n for Tn (bit 1 meaning the T+/T1x state); hidden is 0 none, 1 T1, 2 VEC0, 3 T6; store_data is 0 none, 1 SD1, 2 SD2; a fetch that has not happened is addr -1, opcode -1. The flags string is dropped because it derives from p.

watch is a lowercase hex bitset over watch_names, the same convention as the state blobs (bit i in byte i/8, LSB first), fixed width of ceil(names/8) bytes, an empty string with no watches; watch_encoding: "hex" says so on the wire. Hex and not an integer because a JSON number is a float64 to every browser: past 53 names an integer mask silently corrupts with no error anywhere, which a consumer watching 64 names found. A reader that can decode the state blobs decodes watch with the same three lines.

Responses are gzipped when the client accepts it, which shrinks both trace forms by an order of magnitude and narrows the gap between them: repeated key names compress to almost nothing.

Errors

Refusals, never guesses

statusmeaning
400the engine refused: an unknown watch name, a count over max_step, a malformed state blob that passed shape validation
422the request shape: a truncated hex blob, more or fewer than one of half_cycles/until/until_pc, or an assembly error (with the line number in detail)

CORS is open (Access-Control-Allow-Origin: *), on purpose: the server holds no user state and no credentials, so a notebook, a classroom page, or a CodePen can POST a machine here from any origin and risk nothing.

What to run first

The result that is in no register

Boot the add program from the quick start and step 45 half-cycles with trace on, watching dpc17_SUMS (the adder's sum select) and dpc23_SBAC (special bus into A: the writeback). The trace shows two different kinds of control line at once, measured on this exact run: SUMS is high in all 45 half-cycles, because the adder sums continuously whether or not the instruction wants it, while SBAC pulses exactly where A loads, once per instruction that writes it.

The pulse at h=38 is the one to stare at. ADC's own cycles are over: sync was high at h=36 for the next instruction's fetch, IR already holds STA's opcode, and A still reads $2E. The sum exists and is in no register, and the observation shows the whole mechanism: at h=36 the operands sit in the adder's input latches (alua $2E, alub $14), at h=37 the alu and sb fields both read $42 while a reads $2E and idl already holds the next opcode. One half-cycle into STA, SBAC fires and A snaps to $42. The pulse at h=30 is the same overlap happening to the LDA before it. Every behavioural emulator hides this; it falls out of this one because nothing here knows what ADC is supposed to do.

The interactive companion: the Lab's ADC walkthrough shows the same moment on the die, and the Trace does it for any of the 256 opcodes.

Behind the API

What answers a request

A pool of warm engine processes (halfwave, Rust, zero dependencies), each holding the parsed netlist and one constructed machine. A request overwrites the machine's entire state with yours, settles the network the requested number of half-cycles, and hands the state back; nothing survives between requests. One warm instance runs about 28,000 half-cycles a second, which is a simulated 6502 at roughly 14 kHz: three orders of magnitude slower than the real part, and exactly as fast as a learning tool needs to be.

Swagger UI and ReDoc are generated from the same Pydantic models that validate every request, so they cannot drift from the behaviour.