Transwarp Writer (Native C64 encoder)

Having always been fascinated by fastloaders, I was mightily impressed when Krill released his Transwarp 50x fast loader that doesn’t require any hardware modifications or custom KERNALs.

It was quite an achievement, with one caveat: it required using a PC tool (cc1541) to encode files in Transwarp format. You couldn’t actually make Transwarp-encoded files on original hardware.

Not any more. Since I’ve always had an interest in speedloaders and Commodore 1541 drive loads in general, I decided it was time to get deep into it. And thanks to Claude Code, I was able to do this without devoting a month of midnight oil to it.

So here’s a Commodore 64, native 6502 Transwarp Writer:

This was primarily an education exercise for me. If you hate AI and you think I did something wrong by using AI, well, whatever. AI is a great educational tool when harnessed as such, and that was my purpose here.

If you want all the details, you can download the source and peruse the docs folder for details about the Transwarp format and the port. You are of course free to take and improve upon any of it.

twwriter.d64 (the release disk)

  • TWWRITER: the tool itself, stored in Transwarp format, so LOAD"TWWRITER",8,1 loads it in under a second. That speed costs space: 84 blocks, against 70 as a plain file.
  • BIGTEST ENCODED: the largest file the loader can take (206 blocks as a plain file). It checks itself when it runs, and a green border means it loaded correctly.
  • DEMO GRP ENCODED: the demo Galactic Rasterbar Power.
  • TWWRITER-SLOW: the same tool as a plain PRG, and the copy to use if you want it on another disk. A file copier can’t copy Transwarp files, because it follows the directory entry to the loader and misses the data.
  • TRANSWARP V0.86: Krill’s loader. Each Transwarp file starts it from its own directory entry, so nothing has to be in memory beforehand.
  • README: two screens explaining how to use the tool.
  • Never VALIDATE a Transwarp disk. VALIDATE frees every track the Transwarp files are stored on.

twwriter-testsource.d64

  • Plain source files to encode yourself: BIGTEST ($0400–$CFFF), the demo, and the loader.
  • The files that are already encoded can’t be used as sources, because the tool would follow their directory entries to the loader.

How much of it is a port of cc1541

  • The encoder is a close port of cc1541’s. The block encoder, key schedule, scramble tables, keyed-file padding and directory metadata were all ported to 6502. The GCR tables were copied from cc1541 unchanged.
  • Tests keep it a port. The Python reference matches cc1541 byte for byte, and the C64 assembly matches the Python reference byte for byte.
  • Four deliberate differences, two of which change the output:
    • CRC look-back fixed. For files of 4907–4934 bytes, cc1541 reads from before the start of its input buffer, which is undefined behavior. twwriter uses 0 there instead. This has been reported upstream.
    • Lookup instead of search. cc1541 tries 64 values to find the right one. twwriter looks it up in a table instead, which makes the encoder 1.85× faster with identical output (checked against every possible input).
    • Encoder restructured. It works on GCR table indices instead of GCR bytes, and precomputes per-key tables once per file. That brings it to about 27K cycles per block, down from 260K. The output is still identical.
    • Random padding. cc1541 seeds rand() from the clock. twwriter mixes the raster, CIA timers, jiffy clock and SID noise. Keyed files therefore differ from cc1541’s on purpose, while unkeyed files are identical. Predictable padding was one of the three weaknesses that broke the v0.86 challenge disk, and it’s the only one a writer can fix.
  • Everything below the encoder is original. cc1541 writes a .d64 image on a PC, while twwriter drives a real disk drive. The drive I/O, BAM and directory handling, buffering in the RAM under I/O and the KERNAL, streaming of large files, loader install, fast drive code with two-bit transfers, and the UI were all written from scratch for this project.

How the fast paths work: Transwarp, and TWWRITER’s fast read and write

Now for the really interesting details! (This was synthesized by Opus 5.5 and explains how Transwarp works and also how the fast load/save routines in the writer work.)

This covers three pieces of code that all get past the C64’s slow serial bus, each in its own way:

  1. Krill’s Transwarp loader reads its own specially prepared files at about 50 times the speed of a KERNAL LOAD.
  2. TWWRITER’s fast read reads an ordinary DOS file, the source to encode, at about 5 times the KERNAL’s speed.
  3. TWWRITER’s fast write puts the encoded Transwarp tracks on disk at about 5.5 times the speed of writing over the serial bus.

Everything here was checked against the source:

  • third_party/transwarp/transwarp.s (Transwarp v0.86, © Gunnar Ruthenberg, GPL v3);
  • twwriter/src/ (TWWRITER);
  • PLAN.md, where the benchmarks are.

Anything that is my own arithmetic or reading between the lines is marked (inferred).


0. The baseline: why a normal LOAD is slow

A stock C64 loads from a 1541 at roughly 390 bytes per second. Transwarp reports its own speed as a multiple of this figure: kernalthrpt in transwarp.s is 0.39 KB/s. TWWRITER measured 384-390 bytes/s.

The disk itself is not the bottleneck. The 1541 turns at 300 rpm, one revolution per 200 ms. A raw GCR byte passes under the head every 26 µs on tracks 1-17 (28, 30 and 32 µs in the three outer zones). That is about 7,700 raw bytes per revolution, or roughly 38 KB/s coming off the head on the outer tracks.

The cost is everywhere else:

  • The serial bus is slow. The KERNAL’s serial protocol moves one bit per clock pulse on a single data line, with a handshake and generous delays around every byte.
  • The drive does all the work. It reads a whole sector, GCR-decodes it, checks it, then trickles it out byte by byte.
  • The files are spread out. DOS writes a file’s sectors 10 apart (the standard interleave), so the drive can finish sending one sector before the next arrives. Following that chain costs about 10 revolutions per track.

Every fast loader attacks some of these. Transwarp attacks all of them.


1. Krill’s Transwarp loader

1.1 The one idea behind it: move the decode work to the PC encoder

A normal sector read in the 1541 has two steps. First it reads 325 raw GCR bytes off the disk. Then it decodes them 5-to-4 into the 260-byte data block: a marker, 256 data bytes, a checksum and two padding bytes. The decode is too slow to do while the bytes fly past, so the ROM does it afterwards (about 20 ms per sector, as TWWRITER measured).

Transwarp skips that step:

  • The drive never GCR-decodes the data blocks. Each raw byte it reads from the disk data port ($1C01) goes through a single lookup in a 256-entry table. The result is folded into a running accumulator (eor/adc/ror), and the bits go straight out onto the serial bus while the disk keeps turning (readloop, transwarp.s ~5484-5525).
  • The table is filled in with code. decodetable lives at drive address $0600. Its unused entries are filled with drive code, so the table and the loop fit into the drive’s tiny RAM together (~5326-5387).

This works only because the encoder, cc1541 on a PC and now TWWRITER on the C64, did the opposite work in advance:

  • It picks each sector’s 256 bytes so that their normal DOS GCR image is exactly the raw stream the drive’s table-and-accumulator logic turns into the wanted data.
  • The disk is then written with an ordinary sector write, so it is always legal GCR.
  • The format notes are in docs/transwarp-format.md.

The price is capacity. A Transwarp sector carries 223 bytes of payload instead of 254:

  • 192 “base” bytes are sent live while the sector passes under the head.
  • 31 “buffer” bytes come from accumulator values the drive pushed onto its stack as it read (BASBSZ = $C0, BUFBSZ = $1F).
  • (inferred) This is the cost of decoding with one table lookup per raw byte instead of a true 5-to-4 GCR decode.

1.2 The drive’s read loop: no handshake, timed by the disk

In the fastest zone (tracks 1-17):

  • The loop reads 5 raw bytes per pass and waits for the disk controller’s byte-ready flag (bvc *) only once per pass. The other four reads of $1C01 land in the right place purely by cycle counting.
  • Each pass pushes 3 payload bytes out over the bus, two bits per write to the drive’s serial port $1800 (the CLK and DATA lines), plus one accumulator byte onto the stack for later.

On the slower zones, loadfile patches the loop with a second byte-ready wait and pha/pla padding so the timing still fits (~5107-5133, 5474-5481).

There is no handshake per byte. Every pass the drive first sets the lines to “wait” (CLK only) and then to “go” (both lines). That edge is the only synchronization the C64 gets, once every 3 bytes.

1.3 The C64’s receive loop and the jmp ($DD00) trick

The C64 side stays resident at the top of RAM. The timing-critical receive code is assembled to run in the RAM under the KERNAL ROM (from $E000); its CRC table, zero-page save area and error log sit in the pages just below. It hooks the LOAD vector at $0330.

For the load itself it:

  • turns interrupts off;
  • blanks the screen, because VIC “badlines” would steal 40 cycles at random moments;
  • turns sprites off, because sprite DMA steals cycles too;
  • sets CIA 2 port B ($DD01, the user port) to output $00.

That last step sets up the neat part. With $DD01 holding $00, the instruction jmp ($DD00) jumps into zero page, to an address formed by the serial lines themselves:

  • bits 6 and 7 of $DD00 are CLK IN and DATA IN;
  • the low bits are fixed, so the target is $02, $42, $82 or $C2.

The loader copies small jump tables to exactly those addresses (table00, table40, table80, tablec0, ~3240-3275):

lineslands atdoes
both low (“go”)$02jmp recvblkpal: receive 3 bytes
one low (“wait”)$42 / $82jmp ($DD00) again: a 5-cycle spin
both high$C2jmp blockmissed

So a single indirect jump is both the wait loop and a four-way branch on the bus state. Every 3-byte chunk ends in jmp ($DD00) (~3371), which re-locks the C64 to the drive’s “go” edge. The drift between the two clocks can never build up:

  • the drive runs at 1 MHz;
  • a PAL C64 runs at 0.985 MHz and an NTSC one at 1.023 MHz;
  • on NTSC the installer patches the receive loop with an extra cycle.

Each byte is built from four reads of $DD00 (2 bits each). Three go through small bit-position lookup tables (ldx $DD00 / eor table,x), and the fourth is added in directly (adc $DD00), since CLK and DATA already sit in bits 6-7 (~3338-3365). With an encryption key, the installer permutes those tables, so decryption costs the C64 nothing extra.

1.4 Whole tracks, in whatever order they come

A Transwarp file lives in whole tracks. They are marked used in the BAM but belong to no DOS sector chain. The file’s directory entry points at the loader’s boot file, and the file’s metadata sits in the rest of the entry.

The drive has no chain to follow, so it:

  • takes the first sector header it sees on a track (getsctrloop, ~5262-5277) and reads from there;
  • tells the C64 that sector’s number; the C64 counts sectors as they pass and maps each one to its block through a 21-entry table (permuted when there is a key);
  • catches a block it missed on another revolution;
  • (inferred) therefore gets a whole track in about one revolution plus whatever it missed. A DOS file at interleave 10 needs about ten.

When a track is done, the drive steps the head to the next one. While the head is moving it sends each sector’s 31 buffered bytes (sendbuffer, ~5200-5238):

  • it fires the second step phase after the third sector’s buffer (STPSCT = 3);
  • then it waits for the head to settle (STPDLY).

So the head movement costs little.

Errors are caught on the C64:

  • a CRC-8 (polynomial $31) over part of each block, combined with a digest byte from the drive;
  • a bad block is fetched again, up to 5 times, before the load gives up (MAXTRY = 5).

1.5 Where “50x” comes from

The loader prints its speed as throughput divided by 0.39 KB/s. (inferred) The physical ceiling is 223 bytes × sectors per track, once per 200 ms revolution:

trackssectorsTranswarp ceiling
1-172123.4 KB/s
18-241921.2 KB/s
25-301820.1 KB/s
31-351719.0 KB/s

A head step and settle between tracks brings the outer tracks to roughly 19-20 KB/s, and 19.5 / 0.39 ≈ 50. What limits Transwarp is the disk’s rotation, not the bus.

1.6 Getting the code into the drive

Even the installation is fast:

  • The first command is an M-E whose command string contains a small trampoline. It runs in the drive’s command buffer at $0200.
  • The trampoline receives the next stage one bit at a time, with ATN as the strobe.
  • That stage pulls in the rest two bits at a time (trampoline, upload, ~4675-4745).
  • Any other drive that answers on 8-11 first gets a small “ATN responder” parked in it with M-W/M-E (multidrives, ~2104-2135). (inferred) It is meant to keep that drive from disturbing the bus; in practice v0.86 still hangs with a second drive attached (below).

Two limits of v0.86 matter to TWWRITER users:

  • it loads only files that fit in $0400-$CFFF;
  • it hangs after its banner when a second drive is on the bus (seen in VICE and on real hardware).

2. TWWRITER’s fast read (tw_fread.asm + tw_rdrive.asm)

2.1 Why it can’t work like Transwarp

TWWRITER’s source is an ordinary DOS PRG:

  • It is normal GCR. Transwarp’s drive code can only read Transwarp’s own encoding, because its lookup table does not decode ordinary GCR.
  • It is a sector chain placed at interleave 10, and each sector’s first two bytes name the next sector. The chain can only be followed in order, which rules out taking the first sector that comes by.

So TWWRITER does not try to beat the disk. It lets the drive’s own ROM do the reading (sync, GCR decode, checksum) and speeds up only the part that is slow for no physical reason: the trip over the serial bus.

Other reasons from the tool’s side:

  • Transwarp takes over memory the tool needs: the RAM under the KERNAL, zero page, and a load range of $0400-$CFFF. TWWRITER’s file buffer runs from $6800 to $FFFF.
  • The tool has to be able to stop and restart a read mid-file, for files longer than memory.
  • It has to work with a second drive on the bus.

2.2 How it works

  • Upload. 228 bytes of drive code go into DOS buffer 2 ($0500), sent with M-W in 32-byte pieces and read back with M-R to check them.
  • Start. An M-E starts the code. The start track and sector, plus two 16-byte bit-spreading tables, ride along in the same command. They stay in the drive’s command buffer, where the code uses them.
  • Finding the file. The code streams the directory chain (18/1) to the C64 through the same mechanism, and the C64 matches the name, * and ? included. There is no DOS directory search.
  • Reading each sector. The drive queues an ordinary ROM read job ($80), and the ROM delivers the decoded 256 bytes. The drive sends a 260-byte frame: a dummy byte, the job status, the 256 bytes (links included) and two check bytes.
  • Read-ahead. The read of the next sector in the chain is queued before the frame is finished. The disk works while the C64 stores the data.

2.3 The protocol: two bits at a time, the C64 keeps time

  • Per sector: the drive holds CLK low (“I’m here”). The C64 pulls DATA (“send”), and the drive releases CLK (“here it comes”).
  • Per byte:
    1. The drive pulls DATA low (“ready”).
    2. The C64 answers with a 9-cycle pulse on CLK, the start marker and the only timebase.
    3. The drive then puts four bit-pairs on DATA and CLK, one every 20 drive cycles, low bits first.
    4. The C64 samples $DD00 four times, 20 cycles apart, then a fifth time to see both lines idle, which catches a transfer that slipped out of step.
  • Sampling point. The C64 samples 26 cycles into each window on PAL and 28 on NTSC, picked from the KERNAL’s PAL/NTSC flag. Both were swept in VICE, with at least five cycles of margin each way.
  • Checking. Each frame carries Fletcher-style running sums (seed $5A). A plain XOR would miss a transfer that arrives shifted by a bit-pair. A bad frame is fetched again, up to 5 times.

Each byte restarts from a fresh edge, so the clock drift between the drive and the C64 (1.5% on PAL, 2.3% on NTSC) stays at a cycle or two.

The transfer runs with interrupts off and the screen blanked; the border steps one color per sector as the progress display. Any ATN on the bus, for this drive or another, ends the drive code cleanly, and the C64 can restart it at the sector it wants next.

2.4 Its speed limit is the interleave

The 1541 only starts a read job if the header it just saw is 2-7 sectors before the wanted one ($F423 in the ROM), and it spends ~20 ms decoding GCR first. At interleave 10, the frame for one sector must therefore be over within about 43 ms, or the next sector goes by and costs a whole revolution. The frame takes 36-37 ms, about 4 ms of slack. Getting there took pipelining both sides: the waits between one byte’s bit-pairs are used to prepare the next byte.

The result:

  • about 96 ms per sector, which is 10 sector-times, exactly the interleave;
  • about 2,650 bytes/s in theory;
  • 1,856 bytes/s measured on a real 30,000-byte file, including start-up, the directory search and track changes;
  • against 390 bytes/s through the KERNAL.

To go faster it would have to read sectors out of order, which a DOS chain does not allow without reading ahead into drive RAM.

Fallback. The drive is identified by a ROM byte ($E5C6): 1541, 1541C, 1541-II, 1570 and 1571, stock or JiffyDOS, are accepted. Anything else is read with the KERNAL (READ: KERNAL (SERIAL BUS)).


3. TWWRITER’s fast write (tw_fast.asm + tw_drive.asm)

3.1 What gets written is an ordinary sector

The C64 does all the Transwarp work: the encoding and, when there is a key, the encryption.

  • Encoding. Each Transwarp block becomes a normal 256-byte DOS sector. When the drive GCR-encodes it the standard way, it produces the exact 325-byte raw stream Transwarp’s loader expects.
  • Checksum. Byte 0 is chosen so that the sector’s DOS checksum comes out right.

So the drive only has to write normal sectors. The speed-up comes from two things: getting the 256 bytes to the drive quickly, and not wasting revolutions once they are there.

3.2 Getting the sector to the drive

  • Upload. About 510 bytes of drive code go into buffers 2 and 3 ($0500-$06FF), with M-W, and are read back with M-R.
  • The target drive may be reset once (UJ). After an ordinary LOAD, DOS keeps those buffers for itself, so the tool resets the drive to get them back. A drive whose number was changed in software would lose that number, so it is left alone and writes over the serial bus.
  • Per sector, the C64 sends an M-E and then 260 bytes: track, sector, the 256 data bytes and two check bytes (Fletcher-style, seed $A5).

This time the C64 sends and keeps time:

  1. The drive pulls DATA (“busy”), then releases it (“ready”).
  2. The C64 pulls CLK as a start marker.
  3. The C64 puts four bit-pairs on DATA and CLK, 42 cycles apart, high bits first.
  4. The drive samples each one near the middle of its window. The delay after the marker (DRV_WAIT = 8) was swept in VICE: 6 to 10 work.

The drive queues the write only if both sums come out zero, and tells the C64 on the same lines.

3.3 The drive’s own write job: no verify revolution

The 1541’s own write job writes the sector and then turns itself into a verify job, which costs most of another revolution per sector. TWWRITER queues its own job instead (drv_wjob, an execute job $E0 in slot 2). The ROM’s job loop steps to the right track and jumps to it inside the disk controller’s interrupt. It does what the ROM job does, and stops there:

  1. Compute the sector’s checksum (ROM $F5E9).
  2. Check write protect.
  3. GCR-encode the buffer in the drive (ROM $F78F). The 69 bytes that do not fit go to $01BB-$01FF.
  4. Find the sector’s header (ROM $F510).
  5. Count off the header gap: 9 bytes, or 8 on the earliest 1541 ROMs.
  6. Switch the head to write mode.
  7. Write five $FF sync bytes, then 69 + 256 = 325 GCR bytes, one per byte-ready flag. This loop is copied instruction for instruction from the ROM’s.
  8. Switch back to read mode and report OK.

The job calls ROM routines at fixed addresses, so it runs only on ROMs the tool recognizes:

  • A small program in the drive sums $F2B0-$F99B into a 3-byte signature, which is compared with a table of 8 known ROMs. 105C26, for example, is the stock 1541/1541-II ROM, and 1E76B8 is JiffyDOS 1541.
  • An unknown ROM still gets the fast transfer, but uses the ROM’s own write-and-verify job, at about 595 ms a block (WRITE: DRIVE CODE, ROM JOB, ROM xxxxxx).

Pipelining. The drive reports a job’s result only when the C64 comes with the next sector, so every error arrives one sector late. The tool keeps the track and sector of the job that is out (fw_lt/fw_ls) and names that one in the error message.

3.4 Verify a whole track at once instead

Skipping the verify revolution is safe because the tool reads every track back after writing it:

  • A second drive job (drv_vjob) reads the track, taking every fourth sector so each one is decoded before the next comes round.
  • It checks each sector’s header and checksum, in about 1.2 s per track.
  • A track that fails is encoded again and rewritten, up to three writes in all, before the tool stops with an error.
  • Track 18 (the directory and BAM) and the loader’s tracks are read back too.

If a sector will not go through the fast transfer after 8 tries, the tool finishes the file over the serial bus (DRIVE CODE FAILED: SERIAL BUS FROM HERE).

3.5 Speed, and what limits it

per blockserial bus + U2drive code, ROM write jobdrive code, own job
total1,204 ms595 ms216.6 ms

Most of the 216.6 ms is rotational waiting:

  • sectors go out in order 0, 1, 2, …;
  • sending one takes ~110 ms, about 11 sector-times, so the head has passed the next sector by the time it arrives;
  • each sector therefore costs about one revolution.

Writing in an interleaved order would cut that down. It is the obvious next step and is not done yet.


4. Side by side

Transwarp loaderTWWRITER fast readTWWRITER fast write
Directiondrive → C64drive → C64C64 → drive
Reads/writesits own pre-encoded whole tracksany DOS PRG (sector chain)normal sectors carrying Transwarp data
GCR in the drivenone: one table lookup per raw bytethe ROM’s read jobthe ROM’s GCR encoder, the tool’s write job
Sector orderwhatever comes by firstthe chain’s order (interleave 10)0, 1, 2, …
Bus2 bits/write, no per-byte handshake2 bits/step, handshake per byte2 bits/step, handshake per byte
Who keeps timethe drive (disk byte clock), C64 resyncs every 3 bytesthe C64’s start pulse, per bytethe C64’s start marker, per byte
Screenblanked, sprites off, SEIblanked, SEI during a frameblanked, SEI during a sector
Limited bydisk rotation (~1 rev/track)interleave (~10 revs/track)~1 rev per sector
Speed~50 × KERNAL~1.9 KB/s (~5 × KERNAL)~4.6 blocks/s (~5.5 × serial write)

One thought on “Transwarp Writer (Native C64 encoder)”

  1. Pingback: Benchmarking Transwarp, the insanely fast 1541 loader | Obliterator918's Commodore 64 Project Haven

Leave a Reply

Your email address will not be published. Required fields are marked *