coldice.sh

coldice.sh on a Commodore 64

Update · 19 October 2026 net.asm has run. The last module that had never executed opened a socket, finished the handshake, and played a session through to a clean exit. The networking is new below, and the page is revised where it said otherwise. Still not on an Ultimate.

An SSH-2 client for the Commodore 64, written in 6502 assembly. It speaks curve25519, ed25519 and chacha20-poly1305, and it has played a real session against the live server: handshake, key, pty, commands, clean exit. It has never run on real hardware.

A Commodore 64 screen showing an SSH handshake in progress: host coldice.sh, user c64-runner, a checklist with connect and key exchange done and verify host key running, a progress bar at 56 percent, elapsed 0:15.
The host key check, a little past half way. The bar tracks real work rather than a timer: both ladders report once per scalar bit, weighted by what a bit actually costs.

coldice.sh is a cyberpunk text game you play over SSH. To play it from a Commodore 64 you need an SSH client on the Commodore 64, and as far as I can find, there wasn't one. So: c64ssh. 44,515 bytes, 24 assembly modules, about 8,300 lines of plain NMOS 6502. No illegal opcodes, no 65C02 instructions. It loads at $0801 and runs to $B5E3, which is past the BASIC ROM, so the loader banks BASIC out before it jumps. There are 6,684 bytes left below the I/O area.

What it implements

Key exchangecurve25519-sha256, plus kex-strict-c-v00@openssh.com
Host keyssh-ed25519, verified, pinned to a known key
Cipher / MACchacha20-poly1305@openssh.com
Authpublickey (Ed25519), signed on the C64
Identitykey generated on the C64, or loaded from disk or tape
Key fileseed sealed with chacha20-poly1305 under a passphrase
Sessionchannel open, pty-req, shell, flow-control window
TerminalVT100 subset on 40×25, UTF-8 box drawing folded onto PETSCII
A Commodore 64 screen showing a network scan from the coldice.sh game: addresses such as 62.14.0.42 with hostnames like build-56 and open ports including ntp, ldap and snmp, then '15 of 64 answered'.
A session, running: the banner, what happened while it was away, and a scan of sixty-four addresses on forty columns.

What it costs

Measured in VICE, by breakpointing and reading the cycle counter.

X25519 ladder248,269,240 cycles
Ed25519 scalar multiplication390,241,166 cycles
ChaCha20, 64-byte block116,358 cycles
Poly1305 over 256 bytes830,969 cycles
Full handshake, start to shell1,840,944,156 cycles

That handshake is 29 seconds on a C64 Ultimate at 64 MHz, 24 seconds on a Commodore 77 at 77 MHz, and about 31 minutes on a stock 1 MHz C64. Once you are in, the session runs at roughly 8.8 KB/s at 64 MHz, faster than anyone reads.

Where the time went

The multiply underneath all of this used to be shift-and-add. It is now quarter-square: a*b = f(a+b) - f(a-b), read from four page-aligned tables, with one operand written into a table base by self-modifying code. No loop and no branch, and on a chip with no cache a table lookup takes the same time whatever it reads. That is the 8-bit multiply; the 256-bit multiply around it still branches on carries, so what the tables buy there is speed.

It went in twice, and the second time mattered more. Moving the multiply inline let the self-modification lift out of the inner loop entirely, since the operand being written into the table base is the same for all 32 iterations.

fe_mul, shift-and-add306,279 cycles
fe_mul, table-driven149,400 cycles
fe_mul, inlined into mul25679,522 cycles

X25519 came down 4.06×, the Ed25519 scalar multiplication 4.00×, the field inversion and square root 4.19×, Poly1305 2.00×, and ChaCha20 1.04×. ChaCha20 has no multiplies in it at all, which is how you know the measurement is not flattering itself. That took the handshake from two minutes to twenty-nine seconds.

The key is made on the machine

Every machine makes its own key. A fresh one says identity: none yet, and choosing connect without a key makes one first, about six seconds with the status line saying why. One keypress gets a first-time player into the game, and it gets them in as themselves.

The menu is the screen before the network: generate a new key, load one from disk or from tape, save this one to either, show the public key, set a passphrase, connect. The C64 prints the authorized_keys line to put on the server, and the identity line carries the same SHA-256 fingerprint OpenSSH prints, so the right disk can be recognised without reading 68 characters of base64.

A Commodore 64 screen headed 'c64ssh . keys'. It reads identity: generated here, a sha256 fingerprint, saved as: plain text. Then a numbered menu: generate a new key, load a key from disk, load a key from tape, save this key to disk, save this key to tape, show the public key, set a passphrase for the key file, connect. Below it, in green, an ssh-ed25519 public key line ending in the comment c64ssh, and the messages 'new key. save it before you lose it.' and '4 saves it to disk, 5 to tape.'
A key made on the machine, and the line to paste into authorized_keys. An unsaved key is a character that ends at power-off, so the screen says so and then says which key saves it where.

The file format is its own rather than OpenSSH's, because a PEM private key would cost a base64 decoder and an ASN.1 parser to produce a file nothing else could read anyway. Plain, it is 74 bytes: magic, the 32-byte seed, the public key, and a Fletcher-16 that notices two bytes swapping places where a plain sum would not. With a passphrase it is 104 bytes: a 16-byte salt, the seed under ChaCha20, the public key in the clear, and a Poly1305 tag over all of it, so a wrong passphrase is caught by the tag and says so rather than handing back a plausible key no server will accept. The public key is stored rather than derived, because deriving it is a scalar multiplication: six seconds on an Ultimate, six minutes on a breadbin.

The passphrase goes through 256 iterations of SHA-256, which is 1.8 seconds on an Ultimate and just under two minutes on a stock machine. That is a work factor of 256 against a PC doing millions a second: it stops someone who picks the disk up, and a long passphrase is what stops anyone else.

How it is verified

The networking

A stock Commodore 64 has no network. The C64 Ultimate is not a stock Commodore 64: it is the whole machine in an FPGA, and the networking is built into it, running on its own processor with its own firmware and its own TCP/IP stack. The 6502 never sees a packet. It sees four registers and asks the machine to do the networking for it. The registers sit in the cartridge address range because that is the I/O space a Commodore 64 gives an expansion device, whether it plugs into the port or is built into the machine.

$DF1Cstatus when read, control when written
$DF1Dcommand data out; reading it returns the interface ident
$DF1Eresponse data in
$DF1Fstatus data in

Every exchange is the same six steps: poll until idle, write the command bytes, write PUSH_CMD to the control register, poll until the busy state clears, drain the response and status bytes, then write DATA_ACC. Four commands of the network target cover the whole client: open a socket, read, write, close. Everything above that line is ordinary 6502 code that would run on any C64.

What the hardware demands

The interface's queues are smaller than the protocol's packets, and they fail in ways worth naming. These come from the published SDK, whose core is verified on an Ultimate 64 Elite running firmware 3.15, and they correct the documentation in places.

Saturating pointerthe response queue holds 896 bytes and its pointer stops rather than passing the last one. A reply that exactly fills it leaves the data-available bit set for ever, and a drain loop waiting for that bit hangs the machine. Reads are clamped to 512
The wedge rangeread lengths of 769 to 1023 take the machine off the network until it is power-cycled: the firmware range-checks at 1024 and can buffer 896. The same clamp covers it
895, not 896the largest usable command, which is why a 1,015-byte userauth packet goes out in pieces
The ident bytefirmware 3.15 returns $49 while an interrupt is pending, not the documented $C9, so the check masks the top bit first

One more only shows up against a live socket. A read that returns nothing is not a closed connection. The interface answers with whatever has already arrived and never waits, so bytes still in flight look exactly like a peer that hung up. Against a recording that reading is correct, because the script running out really is the end. Against a network it is a session that dies at the first pause, so the reader now sits through a set number of empty reads before it believes the stream has ended.

Testing hardware that is not there

VICE has no Ultimate emulation and there is no ROM to load, because the behaviour lives in an FPGA and in firmware on the Ultimate's own processor. So the interface was modelled on the host side of the test harness. A watchpoint on each register stops the emulator on every access, and a program on the other side answers it with the documented behaviour: the queue sizes, the block protocol, the saturating pointer. Behind that sits an ordinary TCP socket to the real server.

This tests the client's sequencing against the documented interface, not the firmware's agreement with that documentation. A watchpoint fires on an address and cannot tell you what the address is wired to, so the harness can show that the client and the documentation agree and it cannot show that the client is talking to anything. The calibration above is what keeps it from being circular, and it is the part that came from somebody else's hardware. The instruction stream is real: net.asm executes as written, and its chunking, its drain loops and its error paths all ran. Running them is what turned up the chunking bug: the size was chosen by testing the high byte of a length, which asks whether it is at least 256 rather than at least 512, so a 1,015-byte request went out in twenty-nine pieces instead of two.

What a session looks like

Keystrokes are injected the way a person's arrive. While the emulator is stopped at a register access, one PETSCII character goes into the KERNAL keyboard buffer and the count is set, and GETIN takes it on the next pass of the session loop. Nothing in the client knows the difference, and nothing in the client was changed to allow it. A complete run, decrypted afterwards with the session keys read out of the C64's own memory:

Sent3,184 bytes in 76 packets
Received41,448 bytes in 282 packets
Playedhandle, deck, hint, scan, probe, exit
Endedexit-status, then CHANNEL_CLOSE
Interface cost113,583 register accesses to move 44,064 bytes, or 2.6 per byte

The last line is the one that matters on real hardware, because on an Ultimate each of those accesses is a register read on the real interface, measured in cycles. In this harness each one also stops the machine and makes a round trip to another process, about 0.4 ms, which is where the run's 286 seconds went. The wall clock here measures the harness. The 6502's own cost is the cycle count further up this page.

On the C64's screen, the whole thing ended the way it should:

trinity@deck:> [lvl 1]$ exitlink closed.

The same machine, a second client

Eight of the modules here are also in claude64, a TLS 1.3 client for the Commodore 64 that talks to api.anthropic.com and does X25519, ECDSA over P-256 and chacha20-poly1305 on the 6510 itself. SSH and TLS disagree about almost everything above the arithmetic and agree about nearly all of it below.

Run it

The program itself. It loads at $0801 and runs, and it wants a Commodore 64 with the Ultimate's command interface behind it: a C64 Ultimate, or an Ultimate-II+ in an original machine. Turn the turbo on before you start it. At 1 MHz the key exchange is about half an hour and the server's door shuts a long way before the end of it.

c64ssh.prg ↓ 44,569 bytes

SHA-256fd9c4f6524497a3e3f34ea62d54e712bfe0586ccce585cc516d7eb72fa53f831
Runs from$0801 to $B5E3, past the BASIC ROM, which the loader banks out before it jumps
Licencefree for noncommercial use under the PolyForm Noncommercial License 1.0.0. Commercial use needs a separate one, which is a mail to the address below

There is no identity in the file. It carries no key, no account and no name: the first thing it does is make you a key of your own on your own machine, and the first connection with it creates your runner. What it does carry is coldice.sh's host key, pinned, which is how it knows the server it reached is the server it meant.

It has never run on real hardware, so if you have an Ultimate you would be the first. I would like to know either way: coldice.sh@june17.app.

What has never run

The hardware in all of this is a model. It answers the way the documentation and somebody else's hardware testing say the real one does, which is not the same as being the real one. The Ultimate arrives in February, and the first run on it is the thing that has not happened.

One fault from the live sessions is also still unexplained. In one run a packet the client sent failed its own authentication check when read back. Decrypted with the keys out of the C64's memory, the stream parses cleanly for thirty packets and then does not, and the server was right to hang up on it. It has not recurred, including in a session with seventy-six outgoing packets. Until it does, the honest statement is that the client has a bug it has shown once.

My own machine arrives in February. If anyone can get a C64 Ultimate into my hands before then, this goes from a model of the hardware to the machine.

Open coldice.sh →

The front door of the game this client was written to reach.