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.
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.
| Key exchange | curve25519-sha256, plus kex-strict-c-v00@openssh.com |
| Host key | ssh-ed25519, verified, pinned to a known key |
| Cipher / MAC | chacha20-poly1305@openssh.com |
| Auth | publickey (Ed25519), signed on the C64 |
| Identity | key generated on the C64, or loaded from disk or tape |
| Key file | seed sealed with chacha20-poly1305 under a passphrase |
| Session | channel open, pty-req, shell, flow-control window |
| Terminal | VT100 subset on 40×25, UTF-8 box drawing folded onto PETSCII |
Measured in VICE, by breakpointing and reading the cycle counter.
| X25519 ladder | 248,269,240 cycles |
| Ed25519 scalar multiplication | 390,241,166 cycles |
| ChaCha20, 64-byte block | 116,358 cycles |
| Poly1305 over 256 bytes | 830,969 cycles |
| Full handshake, start to shell | 1,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.
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-add | 306,279 cycles |
| fe_mul, table-driven | 149,400 cycles |
| fe_mul, inlined into mul256 | 79,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.
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.
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.
.tap, at tape speed. A passphrase-encrypted
key is then opened again by Python, straight out of the raw disk image.
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.
$DF1C | status when read, control when written |
$DF1D | command data out; reading it returns the interface ident |
$DF1E | response data in |
$DF1F | status 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.
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 pointer | the 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 range | read 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 896 | the largest usable command, which is why a 1,015-byte userauth packet goes out in pieces |
| The ident byte | firmware 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.
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.
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:
| Sent | 3,184 bytes in 76 packets |
| Received | 41,448 bytes in 282 packets |
| Played | handle, deck, hint, scan, probe, exit |
| Ended | exit-status, then CHANNEL_CLOSE |
| Interface cost | 113,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]$ exit | link closed. |
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.
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-256 | fd9c4f6524497a3e3f34ea62d54e712bfe0586ccce585cc516d7eb72fa53f831 |
| Runs from | $0801 to $B5E3, past the BASIC ROM, which the loader banks out before it jumps |
| Licence | free 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.
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.
The front door of the game this client was written to reach.