The Apple 1 Cassette Interface

An Original Apple 1 Cassette Interface

A basic Apple 1 lacks one important feature: a mass storage capability. This is where the Apple 1 Cassette Interface comes in, from now on referred to as ACI. The ACI allows us to save memory contents to a standard audio cassette and later load the contents back into memory from this audio cassette.
At the same time the ACI doubles the ROM capacity of the basic Apple 1, from 256 bytes to an overwhelming 512 bytes (overwhelming back in '76 I mean).

In this chapter I will explain how the cassette interface is used, how it works electrically and how it works from a software point of view.

Using The ACI

The ACI program can be started by running the program from address $C100. After starting the ACI program an asterisk is printed and the cursor is dropped one line.


C100: A9*

ACI commands are very similar to monitor commands. In fact they all consist of a start address, a separating dot, an end address and a command. The command can either be an R for Read from tape, or a W for Write to tape operations.

The next example writes a block of memory from $0300 until $03FF to cassette tape (data on $03FF is included). It's the user's responsibility to start the recording function on the cassette recorder before hitting the Return key. This ensures that the tape speed is constant as soon as the recording starts and it will automatically skip the clear leader tape at the beginning of the cassette.


To play back the same recording to the Apple 1 you must first locate the recording on the tape. Then type the following command to the Woz ACI program, but don't hit Return yet! Hit return at the beginning of the 10 second header.

Note: The back slash prompt indicates you have returned to the monitor. It does not indicate an error situation! In fact the ACI does not support any error indication.


After hitting return the Woz ACI program waits a few seconds to allow the tape to stabilize, after which it waits for the end of the header. At the end of the header the program will be read from tape to memory.

You can also type more than one command per line. This way you can concatenate several read and/or write commands on one single line.

0000.00FFW 0200.0FFFW

The above command will first write a 10 seconds long header, followed by the data from $0000 to $00FF. This is then immediately followed by a new 10 seconds long header and the data from $0200 to $0FFF.

It is extremely important to keep the address ranges during read commands exactly the same as they were used with the respective write commands.

TIP: You can add a spoken note in front of each recording with information about the following data such as its purpose, and its start and end addresses.

Spaces are ignored in the input field. The commands are only executed as soon as you press the Return key. Only the characters 0..9, A..F, 'R', 'W' and '.' are allowed in the ACI input field. Any other character (including lower case and Ctrl characters) will cause the parsing of the input field to stop, returning you to the beginning of the ACI program.
Errors are detected when parsing the input line. This means that a command line with multiple commands may be executed only partially until the input error is detected.

It is the intention to keep the address range of a read command equal to the address range used by the command which wrote the data to tape. The start address of the memory block may be different though. This allows you to read a piece of data into another memory location.
Be warned though that reading data into a different location will most likely result in a program which doesn't work because absolute addresses are not automatically renumbered.

In fact the address range of the playback may differ, with the possible result of a program which does not work. It is not really a problem if the address range on tape is shorter than the read address range. All available data is read from tape, only control is not returned to the ACI or Woz Monitor because the ACI program is still waiting for more data to arrive.
This probably means that you'll have to press RESET to regain control of the Apple 1. In that case the data read from tape is still in memory and can be used as intended.

In case the address range on tape is longer than the range you've selected to be read you'll end up with only a part of the data you had saved. The rest of the data on tape is simply ignored.

Tip: Recordings made with the ACI are fairly unreliable measured by today's standard. For example there is no error correction/detection performed on the data read from tape. If you're saving very important data, save it twice or maybe even three times in a row. If one recording fails, you'll always have the other(s) to fall back on.

There are a few minor differences between the input of ACI commands compared to Woz Monitor commands.

First of all the entire page $0200 is used for input, not only the first half of it like with the Woz Monitor. This shouldn't be a problem because you probably don't need more than 30 bytes of input buffer anyway. Only if you want to do something silly, like entering more than 256 characters you'll notice that the ACI program simply forgets the first 256.

Next comes the fact that the ACI program does not allow the use of a back space character. If you detect a typing error before you hit Return you can simply press ESC to trash it all and start your command from scratch.
Hexadecimal input errors can also be corrected by simply typing more than 4 digits. Only the last 4 digits of a hexadecimal number are used, all preceding digits will be discarded.

There is a small undocumented feature built into the ACI regarding address input. When the input of the start address is separated from the input of the end address by the separation dot, the start address is copied to a new location in memory without clearing the old location. Then the end address is composed into the memory location which still holds a copy of the start address.
Thus if you enter a 3 digit end address, the left most 4th digit will be a left over from the start address. Entering a 2 digit address will leave you even with 2 digits from the start address.


The example above will write a block of memory from $0300 to $03FF. No problem, that's what we've intended, isn't it. Yes it is, but you don't know how lucky you are! In fact the zero in $03FF is a left over from the right most zero of $0300.


Now you ran out of luck! The example above will save a block of memory beginning at $0301 and ending at $13FF! Simply because the '1' is left over from the start address.
In this example the only penalty will be that the recording is far too long. The relevant data is recorded, along with a lot of irrelevant data. However I can imagine that while reading the same block into memory it may destroy parts of valuable other data in memory.

To avoid problems with this undocumented features I advice you to always use a 4 digit end address, even if there are leading zeroes involved.

The ACI Circuit Description

First of all it is important to place the jumpers on the original Apple 1 main board correctly. These jumpers can be found at locations B9 and B10. For the ACI a jumper must be connected between points 'R' and 'C', this causes the ACI ROM to appear in the 13th block of memory (the $Cxxx block).

The description of the hardware is illustrated by some excerpts from the original schematic diagram which was included in the ACI's user manual. You can find a scan of the ACI's user manual in the downloads section.

ACI Input Amplifier Let's start with probably the easiest part of the entire circuit, the input amplifier. Basically it is a zero crossing detector. The AC input signal is superimposed on a DC voltage of half the supply voltage, after which it is fed to the negative input of the Opamp. The positive input of the Opamp is held at half the supply voltage and is used as a reference. Whenever the voltage on the input is above the reference voltage, the output of the Opamp is low. Otherwise the output of is high. The 47k resistor provides for a small hysteresis which raises or lowers the reference voltage a little to avoid jitter around the tripping point of the circuit.

It can be rather critical to get the input signal level just right. You'll learn soon enough which volume setting on your cassette player gives the best results.
To help you to adjust the input level an LED indicator is provided. According to the user manual this LED should just start to glow fully, whatever that may be. If that doesn't work you should increase the volume of the cassette player just a little until you get it right.

ACI chip select and output circuitry Next comes the Chip Select circuitry. As a bonus this circuit also includes the cassette output.
The two NOR gates on the left decode the highest address lines A9, A10, A11 and the block select signal which is connected to Block 'C' on the Apple 1 main board. Both outputs are "1" when all 4 inputs are low. These two outputs are fed to the 3-input NAND gate, which also gets the Φ2 signal on its third pin. Only when all 3 lines are high the output will be low.
This all means that the output of this 3-input NAND is low whenever the address is in the range of $C000 to $C1FF. This signal is used as Chip Select for the PROM, which means that the PROM can be seen from both ranges starting at $C000 and at $C100. Only the one starting at $C100 is actually used by the software.

The next NOR gate adds the signal from A8 to the game, which means that the output of the NOR gate is high when the address is in the range from $C000 to $C0FF. This range is actually used as I/O range by the ACI software.

The signal from the last NOR gate is fed to the flip-flop, which generates the output signal to the cassette recorder. Each time an address in the range from $C000 to $C0FF is accessed the flip-flop changes state. The data read from such action can be ignored because all we need is really the read action itself.
The output from the last NOR gate is also fed to the input circuitry which I will discuss next.

ACI Input Select circuitry Here we have a real clever piece of circuitry. We still need a way for the software to read the input. Let me start to explain what the idea behind it is before I'll show you how it is done.
The output of the input amplifier (AMP) modifies address line A0 to the PROM whenever an odd address is read from the range of $C081 to $C0FF. This way the software actually reads from an even address or an odd address, depending on the output state of the input amplifier. We will later see how the software interprets this behaviour.

The left most 3-input NAND gets signals from the Chip Select circuit described above, A7 and the input amplifier's output. Only when all three of those signals are high, the output of the NAND gets low.
This low is combined with the original A0 signal in the next NAND. Its output will follow the inverse of A0 for as long as the first NAND's output is high, which fortunately is almost always. This can only change when an address is read in the range from $C080 to $C0FF, then the output of the second NAND will be high if the input amplifier's output is high, regardless of the level of A0.
Now we only have to invert the output of the second NAND to achieve the proper polarity of the A0 signal for the PROM.

Needless to say that this only happens when reading from I/O space! A0 is never modified when reading from ROM space ($C100 to $C1FF).

And yes, in case you're wondering, the cassette output will constantly change state whenever the cassette input is polled. But that will be at such a high frequency that you probably won't notice it.

The ACI Software Description

Again it was a very tight fit to squeeze the Apple 1 Cassette Interface software into the 256 bytes of available PROM. Again there were some concessions to be made regarding the human interface, but we'll cover them in due time. The source code wozaci.asm is part of a download package which can be downloaded from the downloads section.

;  The WOZ Apple Cassette Interface for the Apple 1
;  Written by Steve Wozniak somewhere around 1976

                .CR     6502
                .OR     $C100
                .TF     WOZACI.HEX,HEX,8

;  Memory declaration

HEX1L           .EQ     $24             End address of dump block
HEX1H           .EQ     $25
HEX2L           .EQ     $26             Begin address of dump block
HEX2H           .EQ     $27
SAVEINDEX       .EQ     $28             Save index in input buffer
LASTSTATE       .EQ     $29             Last input state

IN              .EQ     $0200           Input buffer
FLIP            .EQ     $C000           Output flip-flop
TAPEIN          .EQ     $C081           Tape input
KBD             .EQ     $D010           PIA.A keyboard input
KBDCR           .EQ     $D011           PIA.A keyboard control register
ESCAPE          .EQ     $FF1A           Escape back to monitor
ECHO            .EQ     $FFEF           Echo character to terminal

;  Constants

CR              .EQ     $8D             Carriage Return
ESC             .EQ     $9B             ASCII ESC

;  Let's get started

WOZACI          LDA     #"*"            Print the Tape prompt
                JSR     ECHO
                LDA     #CR             And drop the cursor one line
                JSR     ECHO

                LDY     #-1             Reset the input buffer index
KBDWAIT         LDA     KBDCR           Wait for a key
                BPL     KBDWAIT         Still no key!

                LDA     KBD             Read key from keyboard
                STA     IN,Y            Save it into buffer
                JSR     ECHO            And type it on the screen
                CMP     #ESC
                BEQ     WOZACI          Start from scratch if ESC!
                CMP     #CR
                BNE     NEXTCHAR        Read keys until CR

                LDX     #-1             Initialize parse buffer pointer

; Start parsing first or a new tape command

NEXTCMD         LDA     #0              Clear begin and end values
                STA     HEX1L
                STA     HEX1H
                STA     HEX2L
                STA     HEX2H

NEXTCHR         INX                     Increment input pointer
                LDA     IN,X            Get next char from input line
                CMP     #"R"            Read command?
                BEQ     READ            Yes!
                CMP     #"W"            Write command?
                BEQ     WRITE           Yes! (note: CY=1)
                CMP     #"."            Separator?
                BEQ     SEP             Yes!
                CMP     #CR             End of line?
                BEQ     GOESC           Escape to monitor! We're done
                CMP     #" "            Ignore spaces
                BEQ     NEXTCHR
                EOR     #"0"            Map digits to 0-9
                CMP     #9+1            Is it a decimal digit?
                BCC     DIG             Yes!
                ADC     #$88            Map letter "A"-"F" to $FA-$FF
                CMP     #$FA            Hex letter?
                BCC     WOZACI          No! Character not hex!

DIG             ASL                     Hex digit to MSD of A

                LDY     #4              Shift count
HEXSHIFT        ASL                     Hex digit left, MSB to carry
                ROL     HEX1L           Rotate into LSD
                ROL     HEX1H           Rotate into MSD
                DEY                     Done 4 shifts?
                BNE     HEXSHIFT        No! Loop
                BEQ     NEXTCHR         Handle next character

; Return to monitor, prints \ first

GOESC           JMP     ESCAPE          Escape back to monitor

; Separating . found. Copy HEX1 to Hex2. Doesn't clear HEX1!!!

SEP             LDA     HEX1L           Copy hex value 1 to hex value 2
                STA     HEX2L
                LDA     HEX1H
                STA     HEX2H
                BCS     NEXTCHR         Always taken!

; Write a block of memory to tape

WRITE           LDA     #64             Write 10 second header
                JSR     WHEADER

WRNEXT          DEY                     Compensate timing for extra work
                LDX     #0              Get next byte to write
                LDA     (HEX2L,X)

                LDX     #8*2            Shift 8 bits (decremented twice)
WBITLOOP        ASL                     Shift MSB to carry
                JSR     WRITEBIT        Write this bit
                BNE     WBITLOOP        Do all 8 bits!

                JSR     INCADDR         Increment address
                LDY     #30             Compensate timer for extra work
                BCC     WRNEXT          Not done yet! Write next byte

RESTIDX         LDX     SAVEINDEX       Restore index in input line
                BCS     NEXTCMD         Always taken!

; Read from tape

READ            JSR     FULLCYCLE       Wait until full cycle is detected
                LDA     #22             Introduce some delay to allow
                JSR     WHEADER          the tape speed to stabilize
                JSR     FULLCYCLE       Synchronize with full cycle

NOTSTART        LDY     #31             Try to detect the much shorter
                JSR     CMPLEVEL          start bit
                BCS     NOTSTART        Start bit not detected yet!

                JSR     CMPLEVEL        Wait for 2nd phase of start bit

                LDY     #58             Set threshold value in middle
RDBYTE          LDX     #8              Receiver 8 bits
RDBIT           PHA
                JSR     FULLCYCLE       Detect a full cycle
                ROL                     Roll new bit into result
                LDY     #57             Set threshold value in middle
                DEX                     Decrement bit counter
                BNE     RDBIT           Read next bit!
                STA     (HEX2L,X)       Save new byte

                JSR     INCADDR         Increment address
                LDY     #53             Compensate threshold with workload
                BCC     RDBYTE          Do next byte if not done yet!
                BCS     RESTIDX         Always taken! Restore parse index

FULLCYCLE       JSR     CMPLEVEL        Wait for two level changes
CMPLEVEL        DEY                     Decrement time counter
                LDA     TAPEIN          Get Tape In data
                CMP     LASTSTATE       Same as before?
                BEQ     CMPLEVEL        Yes!
                STA     LASTSTATE       Save new data

                CPY     #128            Compare threshold

; Write header to tape
; The header consists of an asymmetric cycle, starting with one phase of
; approximately (66+47)x5=565us, followed by a second phase of
; approximately (44+47)x5=455us.
; Total cycle duration is approximately 1020us ~ 1kHz. The actual
; frequencywill be a bit lower because of the additional workload between
; the twoloops.
; The header ends with a short phase of (30+47)x5=385us and a normal
; phase of (44+47)x5=455us. This start bit must be detected by the read
; routine to trigger the reading of the actual data.

WHEADER         STX     SAVEINDEX       Save index in input line
HCOUNT          LDY     #66             Extra long delay
                JSR     WDELAY          CY is constantly 1, writing a 1
                BNE     HCOUNT          Do this 64 * 256 time!
                ADC     #-2             Decrement A (CY=1 all the time)
                BCS     HCOUNT          Not all done!
                LDY     #30             Write a final short bit (start)

; Write a full bit cycle
; Upon entry Y contains a compensated value for the first phase of 0
; bit length. All subsequent loops don't have to be time compensated.

WRITEBIT        JSR     WDELAY          Do two equal phases
                LDY     #44             Load 250us counter - compensation

WDELAY          DEY                     Delay 250us (one phase of 2kHz)
                BNE     WDELAY
                BCC     WRITE1          Write a '1' (2kHz)

                LDY     #47             Additional delay for '0' (1kHz)
WDELAY0         DEY                      (delay 250us)
                BNE     WDELAY0

WRITE1          LDY     FLIP,X          Flip the output bit
                LDY     #41             Reload 250us cntr (compensation)
                DEX                     Decrement bit counter

; Increment current address and compare with last address

INCADDR         LDA     HEX2L           Compare current address with
                CMP     HEX1L            end address
                LDA     HEX2H
                SBC     HEX1H
                INC     HEX2L           And increment current address
                BNE     NOCARRY         No carry to MSB!
                INC     HEX2H


                .LI     OFF

ACI listing

The program starts by printing an asterisk, ACI's prompt, followed by a carriage return. Then the input buffer pointer is reset before we enter the command input loop. This loop will only exit when a CR is received or when the ESC key is pressed. The ESC key will send the program back to the beginning of the ACI program, the CR key will start the parsing of the command(s).
Inside the input loop every typed character is simply stored in the input buffer and echoed to the screen.

This is where we'll find the first concessions regarding the human interface. No attempts are made to reject control characters for instance, this is not really a problem though because wrong keys will be detected during parsing anyway. No attempt is made to limit the input buffer length, the entire page 2 can be filled with input characters. When the page 2 is full the pointer simply wraps around, effectively forgetting all previous 256 typed characters. Only a fool behind the keyboard will notice this of course.

As soon as a CR character is typed we start the parsing of commands from the input buffer. Remember that there can be more than one command on each input line.
First of all two 16-bits values are cleared to 0. Keep this in mind, we'll soon find out that this is part of the undocumented feature I talked about regarding the input of the end address.
There are a total of 3 special characters to be recognized in the input buffer: An 'R', which triggers the Read command. A 'W', which triggers the Write command. And a dot, which separates the begin and end addresses. Here we see some other concessions regarding the user interface. For instance it is perfectly possible to give a Read or Write command without entering either a start or end address. It is also possible to use more than one separator dot, effectively entering more than 2 addresses for one command. This is not really a problem, the ACI program simply uses the last two.
Spaces are simply ignored, no matter where they appear. For instance you can type 0 3 0 0 . 0 3 F F W, and it is still accepted!
The last thing we have to parse are the hexadecimal digits. We've seen exactly the same code in the Woz Monitor. If an illegal character is found the parsing is aborted and the ACI program is restarted from scratch.

Now it's time to explain the undocumented feature of the end address input. Have a look at the code following the label SEP. When a dot is entered, the address in HEX1 is copied to HEX2, however HEX1 is not cleared after that. New hex-digits will shift in from the right, bumping the old digits out from the left. This means that if you enter less than 4 digits in the second address there are still some digits in HEX1 which don't belong there any more.
Obviously it was all a matter of lack of program memory which caused this undocumented feature.

The original ad for the ACI The Write routine starts by writing a 10 seconds long header, after that the data bytes are written to tape. Each time a new byte is collected from memory, which is then shifted out bit by bit. Then the begin address is incremented until it is higher than the end address.
Because all timing is done in software loops you'll see quite a lot of compensation instructions in the different parts of the process. Their purpose is to compensate for the extra work which is needed to get new bytes, shift bits and increment addresses.

The Read routine may appear to be a little more complicated at first, but we'll manage it all the same. First we try to detect a full cycle of the input signal to assure us that the tape really has been started. Then we introduce a short delay to allow the tape speed to stabilize. The write header routine is abused to create this delay. This has one extra benefit, it saves the X pointer for us which we need later in order to parse the next command. Finally another full cycle is detected to synchronize the timing with the tape signal.
Then we start a loop which must detect the start bit, which is shorter than the other bits in the header. When the start bit is detected we have to wait for the second half of the start-bit before we can start reading the actual data.
Now it's time to read in the data. Obviously all bytes contain 8 bits, which explains the RDBIT loop. Inside this loop we measure the duration of a full cycle. If the timer value passes the 0 we know that the time was longer than the average between a "1" and a "0", and vice versa. As soon as a byte is read it is stored in memory, after which the begin address is incremented until it is larger than the end address.
Here we also see some compensation values for the timing to overcome the differences in workload.
The FULLCYCLE routine simply calls the CMPLEVEL routine, which is effectively executed twice this way. The timer is decremented, which will finally indicate the interval time for a full cycle. And now we come to the clever part of Steve's hardware. Remember the way we read the input, by manipulating address line A0 to the PROM? Well the LDA TAPEIN instruction may effectively load the accumulator with the value on $C080 or $C081. We're not interested in the actual value in neither of these addresses, we only want them to be different. And fortunately they are different.
The CMP #128 instruction at the end will set the Carry flag according to the measured time. If the timer crosses the 0 the carry will be set, otherwise the carry will be cleared. Now the carry represents the level of the new bit.

The WHEADER routine is responsible for writing the header to the tape. First of all the parse pointer X is saved. Then we see two nested loops.
The inner loop uses the X register, which counts a full cycle each time. Well not each time, the first time the X register doesn't contain 0 to start with. But that is only a marginal difference, which is hardly noticeable.
The outer loop uses the Accumulator as counter. Since there is no DEA instruction on the NMOS version of the 6502 we have to use the ADC instruction. We only subtract 1 here, because the carry is always set inside the loop! The end result is indeed a header with a time of about 10 seconds.
When the program finally falls through the outer loop a relatively short bit is written (LDA #30) which will function as start bit.

Now we arrive at the WRITEBIT routine. This routine is a concatenation of some timing loops. The first loop times a single phase of the 2kHz frequency (in case the data bit is 0), this loop is only followed by a second loop if a 1kHz phase is required.
The WDELAY routine is executed twice to create a full cycle of the selected frequency.

Finally the INCADDR routine first compares the begin address with the end address to see if we're done. The actual decision is postponed though to the calling routine, only the Carry flag is set accordingly.
Then the address is simply incremented. And then the 256 bytes of the PROM are completely filled again.

I have only a few last remarks to make regarding the ACI software. First of all there was no room to initialize the stack pointer again. This means that you cannot read data into page $01 and expect it to survive the stack actions of the ACI program because the stack can be all over the place.
The second memory space you have to stay clear of are the addresses $0024 to $0029, which are used by the ACI program.
Finally you should be aware of the use of page $02 as input buffer. If you keep the tape commands short, you should be able to use most of page $02 though.