Cartridges

A cartridge is one gzipped JSON file carrying the ROM (bytes, labels and source), its tiles in both the binary form and as rows of 0..3, and the console addresses it was written to.

The contract travels with the bytes rather than beside them, because the contract is the part an outside author has to agree with, and a page needs eight addresses to play a game with no hardware to ask about any of them. A contract in a different file is the copy that drifts.

mtime is zero, so minting the same cartridge twice gives the same bytes and two of them can be diffed.

curl -s localhost:6502/v1/console                     # the contract, published
curl -s localhost:6502/v1/cartridge -d @cart.json \
     -H 'content-type: application/json' -o mine.cart.gz
curl -s 'localhost:6502/v1/cartridge?format=json' -d @cart.json ... | jq .verify

GET /v1/console is the contract as data, and it is the copy to read from a program. This page is the reasoning; that route is the reference.

Two things minting does that assembling cannot

It refuses a layout that cannot work. A ROM overlapping its own screen assembles perfectly and then draws over itself; a contract byte inside the ROM is the host writing into the code. Each is a 422 with the reason, not a cartridge that fails later.

Reading the assembler's inclusive end as one-past made every one of those checks a byte short, which test_cartridge.py now pins from both sides.

It runs the thing. 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, what each cost, whether the screen changed, and which tiles are on it.

The frame cost is measured on a ladder that ignores the cartridge

The cost is measured on an absolute ladder, 128 half-cycles up to 16k and then 1024, and deliberately not seeded from anything the cartridge declares.

Sizing the first step from a declared cost is right for a host and wrong for a measurement. The same ROM minted at frame_cost 512 and at 20000 measured 6400 and 6250, each number being its own request rounded up.

This is not hypothetical. The frame cost Die Runner's page claimed was its own request read back. The console asks for frameCost half-cycles and then reports what it spent, so whatever was written there confirmed itself: 12,000 was a number the file had typed, not a number the chip had produced.

We ran it and measured: Die Runner's steady frame is 8,704, rock solid over twelve frames, with the first at 5,440. That is about 28% less chip time a frame than the page was buying.

Loading one

The console page loads a cartridge from ?cart=<url> or from the file picker, and a loaded cartridge joins the picker rather than quietly replacing what the label says is on screen. Its tiles replace the sheet, so a cartridge brings its own art.

games/deploy.sh mints the sample rather than keeping it in the tree, so it cannot go stale against rom/dierunner.s and every deploy exercises the endpoint.

# Minted by the API, which refuses a layout that cannot work and then RUNS it.
python3 games/tools/mint.py --api https://6502.tinymachines.ai/api

Cartridge zero

rom/snake.rom, 351 bytes, here to prove the pipe end to end.

Every address in its cartridge entry was read off the disassembly (rom/snake.lst) and then confirmed on the running chip, never guessed. An earlier reading had 2 = right, and the snake walked downwards to say otherwise.

addrwhat
$0Dtick flag: host clears, ROM raises
$02requested direction, 1 up 2 down 3 left 4 right
$03game over
$0400-$04FFthe screen, 16x16, 0 empty 1 snake 2 food

The board wraps rather than having walls: AND #$0F on both nibbles of the cell index.

Provenance: written by Grok on the site owner's prompt, and owned by them under xAI's consumer terms. It is not derived from the die data and carries none of that data's obligations.

Cartridge one

rom/dierunner.s, 339 bytes, written for this console and assembled by the project's own assembler (games/tools/asm.mjs over web/asm.js, which inverts the disassembler's table, so if it assembles it disassembles back to the same lines).

You are a charge carrier descending the die. The world scrolls up to meet you. Polysilicon gates bar the way with one opening; pass-transistor gates have two channels and only one conducts, and every eighth frame the clock phase flips and they swap. A channel that is shut now will be open in a moment, which is the whole game. Charge packets score. The die wraps.

The seven tiles that arrived with the art are all in use, and none is decoration for its own sake. The die used to be empty between barriers.

tilewhat it iswhat it does
9poly busa run of three across the die. Scenery
10power railruns down a column for three to six rows. Scenery
11diff wellan occasional single. Scenery
12poly Twhere a rail comes in
13metal Lwhere a rail turns and leaves
14capacitorworth five charge packets
15bond padsignposts the gap of the barrier above it

The power rail is the one worth understanding: the ROM draws a single cell per row, and it comes out as a rail because the world scrolls. A poly T caps the end it comes in at and a metal L the end it leaves by, so a rail has a direction without the ROM ever drawing a line.

Only a plain barrier gets a bond pad. Which channel of a switched gate is open depends on a control line that will have moved by the time the player arrives, so a signpost there would be pointing at a guess. See the console contract.