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.
| field | value |
|---|---|
chip | "mos6502" |
nodes | 1725 |
transistors | 3510 |
max_step | 200000 half-cycles per request; shard longer runs |
max_traced | 10000 half-cycles per request when trace is on |
encoding | the 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 | |
|---|---|
rom | optional Rom |
memory | optional SparseMemory, default all zeroes |
reset_vector | optional 0..65535; without it and without a rom, the chip runs whatever $FFFC points at, exactly like the silicon |
watch | optional 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 | |
|---|---|
machine | the Machine from the previous response, unchanged or edited |
half_cycles | 1..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_pc | 0..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_cycles | bound 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 |
pins | input 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 |
watch | node 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 |
trace | if 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.
| query | meaning |
|---|---|
layer | partition (default, 132 disjoint), containers (135, overlapping) or absorbed (the 3 that exist only in the overlapping layer) |
kind | one kind: alu, flags, regs, alat, dbus, irp, sbus, sdp, rdy, pcr, pipe, sync, clock, intr, branch, decimal, incr, chain, bus, stage, pins, rest, logic |
parent | only the children of one group key. parent=regs:a gives the accumulator's three load lines |
block | a functional block, by id or name: which machinery reaches into the ALU |
q | substring of the key or the label |
min_nodes | drop groups smaller than this |
members | include 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.
| query | meaning |
|---|---|
group | every node in one group key |
kind | every node in any container of one kind |
block | a functional block, by id or name |
role | signal, decode term (122 of them) or control line (46) |
q | substring of the node name |
named | true for the 705 named nodes, false for the 842 the die never named |
multi | only the 88 nodes in more than one container |
limit, offset | paging; 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).
| query | meaning |
|---|---|
node | a die name or a node number |
via | all (default), gate, switch or control |
direction | both, in or out, for the gate relations only |
depth | 1 to 4 |
limit | up 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 host | the ROM |
|---|---|
| clears one byte | notices, runs a frame, sets it back |
| writes one byte before each frame | reads it as the controller |
| reads a page of memory | wrote 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.
| range | what |
|---|---|
$0000-$00FF | zero page. The contract bytes live here, and it is where a 6502 keeps its variables: two bytes an instruction instead of three |
$000D | tick. The host clears it; the ROM
raises it when a frame is finished |
$0002 | input. The controller, one byte,
written by the host before each frame |
$0003 | status, optional. The ROM raises it
when the game is over |
$0100-$01FF | the stack. A cartridge whose ROM or screen covers this is refused |
$0200 | the usual .org for a ROM |
$0500 | the screen: width * height bytes,
one tile index a cell, row major. Put it above the ROM |
$FFFA-$FFFF | the 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.
| field | what |
|---|---|
rom | a Rom: the source and its
org |
console | tick, input,
screen, width, height, and optionally
status, score, entropy,
gate_mask, frame_cost, dirs,
watch |
tiles | {pixels: [...]} or
{chr: "hex"} |
meta | name, author,
blurb |
frames | how 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.
| tool | what it answers |
|---|---|
console_spec | what a ROM has to agree with. No arguments. Read it first |
assemble | source to bytes, labels and a listing. An error carries the line |
run | what the chip did: registers, flags, memory you name, named wires you watch, and the screen |
mint_cartridge | source and tiles to one verified file, base64 |
chip_atlas | what 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 | |
|---|---|
version | 1 |
half_cycle | clock 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 |
value | every node's logic level: 432 hex chars |
pullup | per-node pullup state: 432 hex chars. Mutable, not a netlist constant; driving the data bus works by flipping the pull on db0..db7 |
pulldown | per-node pulldown state: 432 hex chars |
trans_on | every 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, size | where 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 |
bytes | the image as hex |
labels | name to address |
listing | per 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, cycle | where in time this reading was taken |
clk0 | the 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, sync | the external bus. sync is high during an opcode fetch |
pc, a, x, y, s, p, ir | the 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 |
flags | P as nv-BdIzC, uppercase where set |
tstates | the 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, pchp | the 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 |
fetch | the last opcode fetch, for disassembly |
watch | each 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 | |
|---|---|
machine | the whole machine, after. POST it back to continue |
observe | an Observation at the final half-cycle |
stepped | half-cycles actually advanced |
completed | for until: whether the next fetch was reached before max_half_cycles. False is an answer, not a failure: twelve opcodes (the JAMs) never finish |
trace | with trace: true, one Observation per half-cycle |
assembled | on 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
| status | meaning |
|---|---|
| 400 | the engine refused: an unknown watch name, a count over max_step, a malformed state blob that passed shape validation |
| 422 | the 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.