-
Notifications
You must be signed in to change notification settings - Fork 1
Disk System Internals
This page covers the low-level implementation of the Disk II controller card and disk image format support. For user-facing disk drive operations, see Disk Drives.
- Architecture Overview
- Disk II Controller Card
- Disk Image Abstraction
- DSK Format
- WOZ Format
- GCR Encoding
- Write Support
- State Serialization
- Source Files
The disk subsystem has three layers:
| Layer | Class | Responsibility |
|---|---|---|
| Controller | Disk2Card |
Soft switches, Logic State Sequencer, motor/drive control |
| Image abstraction | DiskImage |
Abstract interface for head positioning and data access |
| Format implementations |
DskDiskImage, WozDiskImage
|
Format-specific loading, nibblization, bit-level storage |
The controller only communicates through the DiskImage interface using phase signals (for head movement) and nibble/bit read/write operations. Format-specific details are hidden behind this abstraction.
The Disk II occupies slot 6 by default:
| Address Range | Function |
|---|---|
$C0E0-$C0EF |
16 soft switches for disk control |
$C600-$C6FF |
256-byte bootstrap ROM (P5A, 341-0027) |
The card does not use expansion ROM ($C800-$CFFF).
The 16 soft switch addresses control all disk operations. Each switch is triggered by any access (read or write) to its address:
| Offset | Even (Off/Low) | Odd (On/High) |
|---|---|---|
$00-$01 |
Phase 0 off | Phase 0 on |
$02-$03 |
Phase 1 off | Phase 1 on |
$04-$05 |
Phase 2 off | Phase 2 on |
$06-$07 |
Phase 3 off | Phase 3 on |
$08-$09 |
Motor off | Motor on |
$0A-$0B |
Select Drive 1 | Select Drive 2 |
$0C-$0D |
Q6 Low (read data) | Q6 High (WP sense / write load) |
$0E-$0F |
Q7 Low (read mode) | Q7 High (write mode) |
The Q6/Q7 combination determines the controller mode:
| Q7 | Q6 | Mode | Operation |
|---|---|---|---|
| 0 | 0 | Read | Returns data register (shift register output) |
| 0 | 1 | Sense | Returns write-protect status in bit 7 |
| 1 | 0 | Write | Writes data register to disk |
| 1 | 1 | Load | Loads CPU bus data into data register |
The heart of the Disk II controller is the Logic State Sequencer (LSS), implemented by the P6 ROM (341-0028). The LSS is a state machine that converts between the serial bit stream on the disk and bytes accessible by the CPU.
Timing: The LSS runs at 2x the CPU clock rate (approximately 2.046 MHz). An 8-phase clock divides each 4-cycle bit cell into 8 ticks. Disk read/write occurs only at phase 4 of this clock.
State machine operation:
-
At each tick, a P6 ROM address is computed from:
- Sequencer state (4 bits, high nibble)
- Q7 and Q6 mode bits
- Data register bit 7 (QA feedback)
- Read pulse from disk (1 = flux transition, inverted for address)
-
The ROM output encodes the next state (high nibble) and an action code (low nibble)
-
Actions on the data register:
| Action Code | Operation | Description |
|---|---|---|
$0-$7 |
CLR | Clear data register to $00
|
$8, $C
|
NOP | No operation |
$9 |
SL0 | Shift left, insert 0 |
$A, $E
|
SR+WP | Shift right, insert write-protect bit |
$B, $F
|
LOAD | Load from CPU bus |
$D |
SL1 | Shift left, insert 1 |
- In write mode (Q7=1), at phase 4 the sequencer outputs bit 3 of the next state to the disk head.
Catch-up mechanism: Rather than running the LSS every CPU cycle, the controller records the last cycle it was clocked and "catches up" when a soft switch is accessed. This is capped at approximately one disk revolution (~53,000 bits x 8 ticks) to prevent excessive computation after long idle periods.
The P6 ROM (341-0028) is a 256x4-bit PROM stored in the emulator in de-scrambled BAPD (Beneath Apple ProDOS) logical format. The ROM address is computed as:
address = (state << 4) | (Q7 << 3) | (Q6 << 2) | (QA << 1) | inverted_pulse
The ROM CRC32 (source format) is b72a2c70.
The disk motor has a delayed-off behavior matching real hardware:
-
Motor on: Immediate; sets
motorOn_flag and resets the LSS cycle counter -
Motor off: Delayed by approximately 1 second (1,023,000 CPU cycles). The
motorOffCycle_timestamp is recorded when the off switch is hit, andisMotorOn()checks elapsed time lazily
This delay allows software to briefly turn the motor "off" during seeks without actually stopping the disk, which was a common technique.
The Disk II uses a 4-phase stepper motor for head positioning. The head moves in half-track (2 quarter-track) increments:
- The controller manages phase magnet states as a 4-bit field (bits 0-3 for phases 0-3)
- Stepping occurs when the current phase is turned OFF and an adjacent phase is ON
- If the next phase (clockwise) is on, the head steps inward (higher track numbers)
- If the previous phase (counter-clockwise) is on, the head steps outward (toward track 0)
- If both or neither adjacent phases are on, no stepping occurs
Position tracking uses quarter-tracks (0-139 for DSK, 0-159 for WOZ). The visible track number is quarter_track / 4.
The DiskImage abstract base class defines the interface between the controller and format-specific implementations:
DiskImage (abstract)
+-- DskDiskImage (DSK/DO/PO raw sector format)
+-- WozDiskImage (WOZ 1.0/2.0 bit-accurate format)
Key interface methods:
| Method | Description |
|---|---|
load() |
Load disk image from raw data |
setPhase() |
Notify of phase magnet state change |
advanceBitPosition() |
Advance disk rotation based on CPU cycles |
readNibble() / writeNibble()
|
Nibble-level access |
readBit() / writeBit()
|
Bit-level access (for LSS) |
getSectorData() |
Get decoded sector data for file explorer |
exportData() |
Export in native format for saving |
The disk image manages head positioning internally based on stepper motor phase changes. The controller only knows about phases (0-3), not tracks.
DSK is a raw sector image format storing exactly 143,360 bytes (140 KB):
| Parameter | Value |
|---|---|
| Tracks | 35 (0-34) |
| Sectors per track | 16 |
| Bytes per sector | 256 |
| Nibbles per track | 6,656 |
| Total size | 143,360 bytes |
Format detection uses content-based heuristics rather than relying solely on file extensions:
-
ProDOS check: Examine block 2 (offset 1024) for a volume directory header. A valid ProDOS disk has storage type
$Fxwith a valid name length and ASCII characters. If found, the format is PO (ProDOS order). -
DOS 3.3 check: Examine the VTOC at track 17, sector 0 (offset 69,632). A valid DOS 3.3 disk has a catalog track of
$11-$14, catalog sector of$00-$0F, and DOS version$03. -
Extension fallback: If content detection fails,
.pofiles are treated as ProDOS order; all others default to DOS order.
Two sector ordering schemes are supported, each with its own logical-to-physical mapping:
DOS 3.3 order (DSK/DO):
| Logical | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Physical | 0 | 13 | 11 | 9 | 7 | 5 | 3 | 1 | 14 | 12 | 10 | 8 | 6 | 4 | 2 | 15 |
ProDOS order (PO):
| Logical | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Physical | 0 | 2 | 4 | 6 | 8 | 10 | 12 | 14 | 1 | 3 | 5 | 7 | 9 | 11 | 13 | 15 |
When a track is first accessed, the raw sector data is converted to a GCR-encoded nibble stream on demand. Each track contains 16 sectors laid out sequentially:
-
Gap 1 (first sector) or Gap 3 (between sectors): Self-sync bytes
- First sector: 128 sync bytes
- Subsequent sectors: 40 bytes (track 0) or 38 bytes (other tracks)
- Address field: Prologue + 4-and-4 encoded volume/track/sector/checksum + epilogue
- Gap 2: 5 sync bytes
- Data field: Prologue + 343 6-and-2 encoded nibbles + epilogue
- Gap 3 end: 1 sync byte
The nibble track is padded or truncated to exactly 6,656 nibbles. Each nibble also carries a sync flag indicating whether it is a 10-bit self-sync byte or a standard 8-bit data byte.
When a modified nibble track needs to be converted back to sector data (for saving), the denibblizer:
- Creates an extended buffer with wrap-around (500 extra nibbles) to handle sectors that span the track boundary
- Searches for address field prologues (
$D5 $AA $96) - Decodes the 4-and-4 encoded address (volume, track, sector, checksum)
- Verifies the address checksum and track number
- Finds the data field prologue (
$D5 $AA $AD) within 50 nibbles - Decodes the 343 6-and-2 encoded nibbles back to 256 bytes
- Writes the decoded data to the sector data array using the appropriate sector mapping
For LSS-level access, each nibble track can be converted to a packed bit stream. The conversion respects the sync flag:
- Data bytes: 8 bits per nibble (MSB first)
- Sync bytes: 10 bits per nibble (8 data bits + 2 extra zero bits)
The total bit count varies per track based on the number of sync bytes. Dirty bit tracks are converted back to nibble tracks before denibblization.
The disk spins at approximately 300 RPM (5 revolutions per second):
| Parameter | Value |
|---|---|
| CPU clock | 1,023,000 Hz |
| Cycles per revolution | ~204,600 |
| Nibbles per track | 6,656 |
| Cycles per nibble | ~31 (giving ~297 RPM, within spec) |
The advanceBitPosition() method advances the nibble position based on elapsed CPU cycles to simulate continuous disk rotation while the motor is on.
WOZ is a bit-accurate disk image format that captures exact magnetic flux transitions. It preserves copy-protection schemes and timing variations that sector-based formats cannot represent. **NOTE THAT COPY PROTECTION IS STILL CAUSING SOME ISSUES SO MORE WORK TO BE DONE :) **
A WOZ file consists of a header followed by a series of chunks:
WOZ Header (12 bytes)
+-- INFO chunk (required)
+-- TMAP chunk (required)
+-- TRKS chunk (required)
+-- META chunk (optional, skipped)
+-- WRIT chunk (optional, skipped)
Header format (12 bytes):
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 4 | Signature |
WOZ1 ($57 $4F $5A $31) or WOZ2 ($57 $4F $5A $32) |
| 4 | 1 | High bits |
$FF (high bit test) |
| 5 | 3 | LF/CR/LF |
$0A $0D $0A (line ending test) |
| 8 | 4 | CRC32 | CRC of all data after this field |
Chunk header (8 bytes):
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | Chunk ID (4 ASCII characters) |
| 4 | 4 | Data size (not including header) |
The INFO chunk (60 bytes in WOZ2) contains disk metadata:
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 1 | Version | 1 or 2 |
| 1 | 1 | Disk type | 1 = 5.25", 2 = 3.5" |
| 2 | 1 | Write protected | 0 or 1 |
| 3 | 1 | Synchronized | Tracks are cross-track synchronized |
| 4 | 1 | Cleaned | MC3470 fake bits removed |
| 5 | 32 | Creator | Software that created the image |
| 37 | 1 | Disk sides | 1 or 2 |
| 38 | 1 | Boot sector format | 0=unknown, 1=16-sector, 2=13-sector, 3=both |
| 39 | 1 | Optimal bit timing | In 125 ns units (default 32 = 4 us) |
| 40 | 2 | Compatible hardware | Bit field |
| 42 | 2 | Required RAM | Minimum RAM in KB |
| 44 | 2 | Largest track | Block count of largest track |
The TMAP (Track Map) chunk is a 160-byte array that maps quarter-track positions (0-159) to track data indices. A value of $FF indicates no track data at that position. Multiple quarter-tracks can reference the same track data index, enabling half-track data sharing.
WOZ1 format: Each track entry is a fixed 6,656 bytes:
| Offset | Size | Field |
|---|---|---|
| 0 | 6,646 | Bitstream data |
| 6,646 | 2 | Bytes used |
| 6,648 | 2 | Bit count |
| 6,650 | 2 | Splice point |
| 6,652 | 1 | Splice nibble |
| 6,653 | 1 | Splice bit count |
| 6,654 | 2 | Reserved |
WOZ2 format: A table of 160 track entries (8 bytes each), followed by track bit data at 512-byte block offsets:
| Offset | Size | Field |
|---|---|---|
| 0 | 2 | Starting block (512-byte blocks from file start) |
| 2 | 2 | Block count |
| 4 | 4 | Bit count |
WOZ supports 160 quarter-track positions (40 possible track positions x 4 quarter-tracks each). The TMAP provides the indirection layer:
- Standard tracks are at quarter-track positions 0, 4, 8, ..., 136
- Half-tracks (used by some copy protection) can exist at positions 2, 6, 10, ...
- Quarter-tracks provide even finer granularity
The stepper motor physics are identical to the DSK implementation but with a wider range (0-159 vs 0-139).
WOZ stores raw bit data in packed MSB-first format. Reading a nibble from the bit stream follows the Disk II hardware behavior:
- Read bits sequentially
- Skip zero bits until a 1-bit is found (sync region)
- Shift bits into an accumulator
- When bit 7 of the accumulator is set, the nibble is complete
A safety limit of 64 bits prevents infinite loops on corrupted data.
Bit timing for WOZ:
| Parameter | Value |
|---|---|
| Cycles per bit | 4 (from optimal bit timing of 32 x 125 ns = 4 us) |
| Bits per revolution | ~51,200 (standard track) |
WOZ images can be decoded to sector data for the File Explorer. This enables browsing DOS 3.3 and ProDOS files on WOZ disks:
- For each track, read nibbles from the bit stream (scanning through twice to handle wrap-around)
- Search for address field prologues (
$D5 $AA $96) - Decode 4-and-4 address fields and verify checksums
- Find data field prologues (
$D5 $AA $AD) - Decode 6-and-2 data fields
Decoding is considered successful if at least 50% of sectors (280 of 560) are recovered. This tolerance allows copy-protected disks with intentionally bad sectors to still have their readable content browsed.
WOZ images are exported in WOZ2 format with the following structure:
- 12-byte WOZ2 header
- INFO chunk (68 bytes total with header)
- TMAP chunk (168 bytes total)
- TRKS chunk with 160-entry track table (1288 bytes)
- Track bit data starting at block 3 (offset 1536)
Blank/unformatted disks can be created with 35 tracks of 51,200 bits each, filled with sync bytes ($FF).
Group Coded Recording (GCR) is the encoding scheme used by the Disk II to store data on floppy disks. It ensures that the encoded bit stream maintains the timing constraints required by the hardware: every byte must have its high bit set and must not contain more than two consecutive zero bits.
The primary data encoding scheme converts 256 data bytes into 343 disk nibbles (342 encoded + 1 checksum):
Step 1: Build auxiliary buffer (86 bytes)
For each of 86 positions, extract bits 0-1 from up to three data bytes:
-
buffer[i]bits 0-1 fromdata[i](with bit swap) -
buffer[i]bits 2-3 fromdata[i+86](with bit swap) -
buffer[i]bits 4-5 fromdata[i+172](with bit swap)
Step 2: Build primary buffer (256 bytes)
Store bits 2-7 of each data byte (shifted right by 2):
buffer[86+i] = data[i] >> 2
Step 3: XOR differential encoding
Each buffer value is XORed with the previous value before encoding:
encoded[i] = ENCODE_6_AND_2[buffer[i] XOR prev]
prev = buffer[i]
Step 4: Checksum
The final (343rd) nibble encodes the last pre-XOR buffer value, serving as a checksum.
The 6-and-2 lookup table maps 64 possible 6-bit values to 64 valid disk nibbles (values $96-$FF with high bit set and no adjacent zeros).
Address field values (volume, track, sector, checksum) use a simpler encoding that splits each byte into odd and even bits:
odd = 0xAA | (value >> 1) & 0x55 // bits 7,5,3,1 -> positions 6,4,2,0
even = 0xAA | value & 0x55 // bits 6,4,2,0 -> positions 6,4,2,0
Each byte becomes two bytes, both guaranteed to have their high bit set.
A complete sector on disk consists of:
[Gap: 14+ sync bytes]
[Address Prologue: D5 AA 96]
[Volume odd] [Volume even]
[Track odd] [Track even]
[Sector odd] [Sector even]
[Checksum odd] [Checksum even] (checksum = volume XOR track XOR sector)
[Address Epilogue: DE AA EB]
[Gap: 6 sync bytes]
[Data Prologue: D5 AA AD]
[343 encoded data nibbles]
[Data Epilogue: DE AA EB]
Marker bytes:
| Marker | Bytes | Purpose |
|---|---|---|
| Address prologue | $D5 $AA $96 |
Start of address field |
| Address epilogue | $DE $AA $EB |
End of address field |
| Data prologue | $D5 $AA $AD |
Start of data field |
| Data epilogue | $DE $AA $EB |
End of data field |
| Sync byte | $FF |
Self-synchronizing (10 bits on disk) |
Self-sync bytes are $FF values that occupy 10 bits on disk instead of the standard 8 bits. The extra two zero bits allow the read hardware to regain byte synchronization. When the controller encounters a stream of 10-bit $FF values, the shift register naturally aligns to byte boundaries because the $FF pattern fills all 8 bits with ones regardless of the starting alignment.
Both DSK and WOZ formats support write operations:
DSK writes:
- Nibbles are written directly to the nibble track cache at the current position
- The track is marked dirty
- On save, dirty tracks are denibblized back to sector data
- For bit-level writes (LSS), bits are written to the bit track cache and later converted back through bit-track -> nibble-track -> sector-data
WOZ writes:
- Bits are written directly to the track's packed bit array
- For nibble writes, 8 bits are written MSB-first
- The modified flag is set for the entire image
Write-protected disks reject all write operations. The write-protect status comes from the WOZ INFO chunk or the write_protected_ flag for DSK images.
The Disk II controller state (32 bytes maximum) includes:
| Field | Size | Description |
|---|---|---|
| Motor on | 1 byte | Motor running flag |
| Selected drive | 1 byte | 0 or 1 |
| Q6 | 1 byte | Q6 latch state |
| Q7 | 1 byte | Q7 latch state |
| Phase states | 1 byte | 4-bit phase magnet field |
| Data register | 1 byte | LSS shift register value |
| Drive 0 quarter-track | 1 byte | Head position for drive 0 |
| Drive 1 quarter-track | 1 byte | Head position for drive 1 |
| Sequencer state | 1 byte | 4-bit LSS state |
| Bus data | 1 byte | Last CPU bus value |
| LSS clock | 1 byte | 8-phase clock position |
Disk image data (sector data and modifications) is saved separately as part of the full emulator state. See Save States for details.
| File | Description |
|---|---|
src/core/cards/disk2/disk2_card.hpp |
Disk II controller card interface |
src/core/cards/disk2/disk2_card.cpp |
Controller implementation, LSS, P6 ROM |
src/core/disk-image/disk_image.hpp |
Abstract disk image base class |
src/core/disk-image/dsk_disk_image.hpp |
DSK format class declaration |
src/core/disk-image/dsk_disk_image.cpp |
DSK nibblization, denibblization, stepper |
src/core/disk-image/woz_disk_image.hpp |
WOZ format class declaration |
src/core/disk-image/woz_disk_image.cpp |
WOZ parsing, bit-level access, sector decoding |
src/core/disk-image/gcr_encoding.hpp |
GCR encoding tables and function declarations |
src/core/disk-image/gcr_encoding.cpp |
6-and-2 and 4-and-4 encoding implementation |
See also: Disk Drives | File Explorer | Expansion Slots | Architecture Overview