apple-panic-series — Part 5

nibbler: A WOZ Disk Analysis Toolkit

Every nibbler command demonstrated against Apple Panic — actual outputs, what each line means, and how to read the results. The reference guide for analysing a copy-protected Apple II WOZ disk image from first scan to disassembled source.

18 min read intermediate
apple-ii6502copy-protectionretrocomputingapple-panic-seriestoolreference

This is an addendum to the four-part “Cracking Open Apple Panic” series. Part 4 described the nibbler toolkit in broad strokes. This article shows every command in full against a real disk, explains what the outputs mean, and covers the Python library interface.

nibbler is in the Orchard repository. All examples use Apple Panic - Disk 1, Side A.woz.


nibbler is a Python toolkit for analysing Apple II WOZ disk images. It grew from the 39 individual scripts written during the Apple Panic reverse engineering project. Where the investigation scripts were purpose-built and disposable, nibbler is general-purpose: it works on any WOZ2 file, has a consistent CLI, and is importable as a library.

The complete workflow — from raw WOZ file to annotated assembly source — requires eight commands. This article walks through all of them in the order you’d use them, explains what each one shows, and demonstrates what the output actually looks like against Apple Panic.

Installation

git clone https://github.com/BrentRector/orchard
cd orchard
python -m nibbler --help

No package installation required. Python 3.9+ with no dependencies beyond the standard library.

All commands follow the same pattern:

python -m nibbler <command> <woz_file> [options]

info — What’s on the disk?

info parses the WOZ2 file header and prints the disk metadata and track map. Run this first, before anything else.

python -m nibbler info "apple-panic/Apple Panic - Disk 1, Side A.woz"
WOZ2 file: Apple Panic - Disk 1, Side A.woz
  Disk type: 5.25"
  Creator: Applesauce v1.56.1
  Version: 2
  Synchronized: 1
  Write protected: 1
  Tracks with data: 14

Track map:
  Track  0:  51107 bits (13 blocks)
  Track  1:  51125 bits (13 blocks)
  Track  2:  51089 bits (13 blocks)
  Track  3:  51134 bits (13 blocks)
  Track  4:  51098 bits (13 blocks)
  Track  5:  51102 bits (13 blocks)
  Track  6:  51091 bits (13 blocks)
  Track  7:  51118 bits (13 blocks)
  Track  8:  51108 bits (13 blocks)
  Track  9:  51116 bits (13 blocks)
  Track 10:  51093 bits (13 blocks)
  Track 11:  51109 bits (13 blocks)
  Track 12:  51097 bits (13 blocks)
  Track 13:  51056 bits (13 blocks)

Half/quarter tracks: [1, 5, 6, 7, 9, 13, 14, 17, 21, 25, 26, 27]

What to read here: The standard 35 tracks immediately drops to 14. A standard DOS 3.3 disk uses all 35; having only 14 with data says this is a custom loader disk — the game fits everything in less than half the disk’s capacity, which means there’s no filesystem, no catalog track, no wasted sectors. The bit counts (~51,000 per track) are normal for a 5.25-inch disk at standard rotation speed.

The half/quarter tracks are notable. Standard disks write only at integer track positions (0, 1, 2, …). Non-integer entries in the track map — position 1 would be track 0.25, position 5 would be track 1.25, and so on — indicate data at positions the standard DOS stepper never visits. This is one of the oldest copy protection techniques. None of the half-track positions here carry readable sectors (they’re used to confuse some copiers that blindly step through all positions), but their presence confirms this disk was deliberately constructed.


scan — What encoding is each track using?

scan converts every track’s bit stream to nibbles and searches for sector address fields in both 6-and-2 and 5-and-3 encoding, auto-detecting non-standard prologs.

python -m nibbler scan "apple-panic/Apple Panic - Disk 1, Side A.woz"
Track    Encoding   6+2   5+3   Addr CK   Data CK  Notes
---------------------------------------------------------------------------
    0        dual     1    13     13/14        OK
    1         5+3     0    13     17/17        OK   addr=$D5 $BE $B5
    2         5+3     0    13     17/17        OK   addr=$D5 $BE $B5
    3         5+3     0    13     13/14        OK   addr=$D5 $AB $B5
    4         5+3     0    13     17/17        OK   addr=$D5 $BF $B5
    5         5+3     0    13     17/17        OK   addr=$D5 $EB $B5
    6         5+3     0    13     19/19        OK   addr=$DE $FB $B5
    7         5+3     0    13     21/21        OK   addr=$DE $AA $B5
    8         5+3     0    13     17/17        OK   addr=$DE $FA $B5
    9         5+3     0    13     21/21        OK   addr=$DE $AA $B5
   10         5+3     0    13     17/17        OK   addr=$DE $AB $B5
   11         5+3     0    13     17/17        OK   addr=$DE $EA $B5
   12         5+3     0    13     17/17        OK   addr=$DE $EF $B5
   13         5+3     0    13     17/17        OK   addr=$DE $BB $B5

What to read here: The dual on track 0 says both encoding formats exist on the same track — one 6-and-2 sector (the boot sector the P6 ROM reads) and thirteen 5-and-3 sectors. Tracks 1–13 are pure 5-and-3.

The Addr CK column shows how many address field checksums pass. Track 0 shows 13 of 14 failing — all thirteen 5-and-3 sectors have deliberate bad checksums. Track 3 shows 13 of 14: one sector on track 3 has an extra bad checksum alongside the standard ones (this is sector 11, the deliberate decoy).

The Notes column shows the auto-detected address prolog for each track. The format is [first byte] [second byte] [third byte]. Standard 5-and-3 would be $D5 $AA $B5 everywhere. Instead:

  • Tracks 1–5: $D5 first byte, but the second byte varies per track ($BE, $AB, $BF, $EB)
  • Tracks 6–13: $DE first byte plus varying second bytes

This table is the primary output you’d use to catalogue what a disk is doing with its address fields. Two lines with the same prolog bytes means both those tracks are read by the same RWTS configuration. The shift from $D5 to $DE at track 6 is the $1000 runtime patch in action.


protect — What protection is this disk using?

protect runs a comprehensive analysis and generates a structured markdown report. This is the command to run when you want a complete picture before digging into the boot code.

python -m nibbler protect "apple-panic/Apple Panic - Disk 1, Side A.woz"
python -m nibbler protect "apple-panic/Apple Panic - Disk 1, Side A.woz" -o report.md
# Copy Protection Analysis: Apple Panic - Disk 1, Side A.woz

## Detected Techniques

### 1. Dual-Format Track (Track 0)
Track 0 contains both 6-and-2 (1 sector) and 5-and-3 (13 sectors) encoded sectors.
Standard copiers reading 16 sectors of 6-and-2 will find only 1 sector here.

### 2. 5-and-3 Encoding (All tracks)
All non-boot sectors use the obsolete 13-sector 5-and-3 format.
Post-1980, most copy tools only support 6-and-2 (DOS 3.3 standard).

### 3. Invalid Address Checksums
Track 0: 13 of 14 address fields fail checksum verification.
Nibble copiers that validate headers before reading data will reject all 5-and-3 sectors.

### 4. Invalid Data Checksum
Track 3, Sector 11: data field checksum fails with both standard and corrupted GCR tables.
This sector is not used by the loader. Appears to be a deliberate decoy.

### 5. Non-Standard Address Prolog — First Byte $DE
Tracks 6-13 use $DE as the first address prolog byte instead of standard $D5.
Detected by structural analysis: third byte $B5 and 4-and-4 address data follow.
Copiers scanning for $D5 as the sector boundary marker find zero sectors on tracks 6-13.

### 6. Non-Standard Address Prolog — Per-Track Second Byte
Second byte varies per track across all 13 tracks (1-13):
  Track  1: $BE    Track  5: $EB    Track  9: $AA
  Track  2: $BE    Track  6: $FB    Track 10: $AB
  Track  3: $AB    Track  7: $AA    Track 11: $EA
  Track  4: $BF    Track  8: $FA    Track 12: $EF
                               Track 13: $BB
Even a copier that handles $DE must know the correct second byte per track.

### 7. Non-Standard Sector/Track Numbers
Address fields on tracks 1-6, 8, 10-13 contain sector numbers outside 0-12
and track numbers not matching the physical position. Examples:
  Track 1: sector 215, track-field 253
  Track 2: sector 215, track-field 253
  ...
Copiers that validate sector ranges will reject these as malformed.

### 8. Half/Quarter Track Data
Data exists at non-integer track positions: 0.25, 1.25, 1.5, ...
Standard DOS 3.3 copiers only access integer tracks.

## Techniques Requiring Boot Code Analysis
The following techniques are not detectable by static disk analysis:
- GCR table corruption (ASL x3 applied at runtime by stage 2 loader)
- Custom post-decode permutation ($0346 vs standard $02D1)
- Self-modifying code (boot sector patches stage 2 before entering it)

Use `nibbler boot` or `scripts/boot_emulate_full.py` to observe these at runtime.

What to read here: The report gives you the static picture — what’s visible without executing any code. The last section is important: three of the nine protection layers are invisible to static analysis and only appear when you run the code. This is the report’s way of telling you that scan and protect aren’t sufficient — you’ll need boot to see everything.


nibbles — What does the raw bit stream look like?

nibbles dumps the raw nibble stream for a track, 32 nibbles per line, with optional highlighting of specific byte values. This is for manual inspection when something doesn’t add up from scan’s summary.

python -m nibbler nibbles "apple-panic/Apple Panic - Disk 1, Side A.woz" 0 --highlight D5,AA,DE
Track 0: 6388 nibbles

      0: FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF [D5][AA] 96 BF BF FF FE FF 9E AB FF FE
     32: [D5][AA] AD AB AE AE BF BF FF AB 9A B7 B5 BF AD AE AE BF BF FF AB 9F B7 B5 BF ...
     64: FF FF FF FF FF FF FF FF FF FF FF FF FF FF [D5][AA] B5 BF BF FF FE FF AB FF FE ...
     96: [D5][AA] AD AB AE AE ...

What to read here: The FF runs are sync bytes — self-clocking bytes that let the hardware regain bit synchronization before the sector header arrives. A long FF run followed by D5 AA 96 is the start of a 6-and-2 address field. D5 AA B5 is a 5-and-3 address field. D5 AA AD is a data field prolog for either format.

On track 0 you’d see the D5 AA 96 boot sector markers interspersed with D5 AA B5 markers for the thirteen 5-and-3 sectors. The --highlight flag brackets specific byte values in [] to make the marker positions visible. On tracks 6–13 you’d see DE where D5 normally appears, confirming the patched first byte.

Use nibbles when scan reports something unexpected and you want to see the actual byte sequence around it.


decode — What’s inside a specific sector?

decode extracts and hex-dumps individual sectors, trying both 6-and-2 and 5-and-3 encoding and handling non-standard $DE prologs automatically.

# Hex dump all sectors on track 0
python -m nibbler decode "apple-panic/Apple Panic - Disk 1, Side A.woz" 0

# Extract the boot sector to a file
python -m nibbler decode "apple-panic/Apple Panic - Disk 1, Side A.woz" 0 --sector 0 -o boot_sector.bin
=== Track 0, Sector 0 (6+2, checksum: OK) ===
  $0000: 01 A5 27 C9 09 D0 06 A9 20 20 ED FD A9 04 48 A9
  $0010: 01 48 60 A0 00 B9 00 08 99 00 02 88 10 F8 4C 0F
  $0020: 02 98 09 00 85 26 A9 08 85 27 ...
  ...

=== Track 0, Sector 1 (5+3, checksum: OK) ===
  $0000: A2 60 BD E2 B7 9D 00 04 CA D0 F8 A0 00 A2 00 BD
  $0010: 00 B7 9D 01 B7 E8 C8 C0 04 D0 F3 4C 00 00 ...
  ...

=== Track 0, Sector 11 (5+3, checksum: BAD) ===
  $0000: [garbled — intentionally bad checksum]

What to read here: Sector 0 (6-and-2) is the boot sector — its first bytes are 01 A5 27 which disassemble as: byte $01 (data, not used), LDA $27, CMP #$09. The boot sector starts with a comparison that checks which slot the disk controller is in. Sector 1 (5-and-3) starts with A2 60 BD E2 B7 — the game loader at $B700.

The --sector N -o FILE form is useful when you want to disassemble a specific sector with disasm, without booting the disk first. Extract the boot sector, then:

python -m nibbler disasm boot_sector.bin --base 0x0800

boot — Run the disk and capture memory

boot runs the 6502 emulator from the P6 ROM boot sequence to a specified stop address, then dumps or saves the resulting memory state. This is the command that defeats all runtime protection — self-modifying code, GCR corruption, per-track patches — because it runs the actual boot code.

python -m nibbler boot "apple-panic/Apple Panic - Disk 1, Side A.woz" \
    --stop 0x4000 --dump 0x4000-0xA7FF --save game.bin
Booting Apple Panic - Disk 1, Side A.woz...
Will stop at $4000

    5,000,000 instructions  PC=$B97A  (game loader: reading track 2)
   10,000,000 instructions  PC=$B97A  (game loader: reading track 3)
   20,000,000 instructions  PC=$B97A  (game loader: reading track 5)
   30,000,000 instructions  PC=$BE44  (game loader: JSR $1000 — RWTS patch)
   40,000,000 instructions  PC=$B97A  (game loader: reading track 7)
   55,000,000 instructions  PC=$B97A  (game loader: reading track 11)
   69,000,000 instructions  PC=$B97A  (game loader: reading track 13)

Stopped: stop_at $4000
Instructions: 69,813,247
Final PC: $4000
State: A=$00 X=$60 Y=$00 SP=$FD P=30

Memory $4000-$A7FF (26624 bytes):
  $4000: 4C 65 40 EA EA ...
Saved to game.bin

What to read here: The instruction count — 69.8 million — reflects how many times the RWTS poll loop ran while waiting for each sector to spin under the read head. Each plateau in the progress output (PC=$B97A repeating) is the RWTS sitting in its disk poll loop between sectors. The brief jump to PC=$BE44 at 30M instructions is the JSR $1000 call that patches the RWTS and displays the title screen.

The --dump range and --save can be combined: the range controls what gets hex-printed to stdout, while --save always saves the full 64K memory state regardless of --dump. If you want just the game binary, specify --dump 0x4000-0xA7FF --save game.bin.

If you don’t know the stop address: omit --stop. The emulator will run until the instruction limit and print a non-zero memory summary showing which regions contain data. The game entry point will be obvious: it’s where the densest block of non-zero bytes starts above the loader region.

The --trace flag logs every disk I/O event — seeks, address field detections, data reads — to stdout. Noisy but useful when debugging why a sector isn’t being found:

python -m nibbler boot "disk.woz" --stop 0x4000 --trace 2>&1 | grep "addr prolog"

dsk — Convert or create a DSK image

dsk has two modes: converting a standard WOZ to DSK, and creating a bootable DSK from an extracted binary.

Apple Panic can’t be directly converted to DSK — it uses 5-and-3 encoding and custom loaders, none of which have a DSK equivalent. But you can create a bootable DSK from the extracted game.bin:

# This will FAIL for Apple Panic (non-standard encoding):
python -m nibbler dsk "Apple Panic - Disk 1, Side A.woz" -o output.dsk
# Error: Cannot convert copy-protected disk with non-standard encoding to DSK.
# Use 'nibbler boot' to extract the runtime binary, then 'nibbler dsk --binary'.

# This works — creates a bootable DSK from the extracted game binary:
python -m nibbler dsk --binary game.bin --load-addr 0x4000 --entry-addr 0x4000 -o applepanic_clean.dsk
Bootable DSK created: applepanic_clean.dsk
  Load address: $4000
  Entry address: $4000
  Binary size: 26624 bytes
  Total: 143360 bytes (padded to standard 140K DSK)

The resulting applepanic_clean.dsk works in any Apple II emulator that accepts standard DSK images — AppleWin, Virtual II, MAME. It’s unprotected: the copy protection existed on the floppy, not in the game code itself.

For standard 6-and-2 disks (no custom encoding), dsk converts directly:

python -m nibbler dsk "standard_dos33_disk.woz" -o output.dsk

disasm — Disassemble code

disasm disassembles a binary file as 6502 code. Two modes: linear (sequential, fast) and recursive descent (follows branches, slower but correct).

# Linear disassembly of the extracted boot sector:
python -m nibbler disasm boot_sector.bin --base 0x0800

# Recursive descent from the game entry point:
python -m nibbler disasm game.bin --base 0x4000 --entry 0x4000 -r

Linear output (boot sector at $0800):

$0800  01        ???
$0801  A0 00     LDY #$00
$0803  B9 00 08  LDA $0800,Y
$0806  99 00 02  STA $0200,Y
$0809  88        DEY
$080A  D0 F7     BNE $0803
$080C  4C 0F 02  JMP $020F

The ??? at $0800 is the sector count byte ($01) — data, not code. Linear disassembly doesn’t know this, so it decodes it as an opcode. This is the fundamental limitation of linear disassembly and why recursive descent exists.

Recursive descent output (game binary at $4000):

$4000  L_4000          JMP $4065

$4025  BRIDGE          JMP $7000

$4065  ENTRY           LDA #$00
$4067                  STA $C050    ; GR/Text: set graphics mode
$406A                  STA $C057    ; GR/Text: set hi-res mode
$406D                  ...

$7000  GAME_START       LDA #$01
$7002                   STA $7464    ; current_level = 1
$7005                   JSR $758B    ; enemy spawn setup
...

Recursive descent generates labels (L_4000, BRIDGE, ENTRY, GAME_START) for every branch target and JSR destination. Hardware register accesses like STA $C050 get automatic comments from a built-in table of Apple II soft switches.

The -r flag enables recursive mode. Without it, disasm decodes every byte as code sequentially — useful for small known-code regions, wrong for anything with data mixed in.


Full Workflow: Apple Panic from WOZ to Assembly

Putting it all together:

# 1. Survey the disk
python -m nibbler info "Apple Panic - Disk 1, Side A.woz"
python -m nibbler scan "Apple Panic - Disk 1, Side A.woz"

# 2. Generate full protection report
python -m nibbler protect "Apple Panic - Disk 1, Side A.woz" -o protection_report.md

# 3. Inspect the boot sector manually
python -m nibbler decode "Apple Panic - Disk 1, Side A.woz" 0 --sector 0 -o boot_sector.bin
python -m nibbler disasm boot_sector.bin --base 0x0800

# 4. Boot-emulate and extract the game binary
python -m nibbler boot "Apple Panic - Disk 1, Side A.woz" \
    --stop 0x4000 --dump 0x4000-0xA7FF --save game.bin

# 5. Disassemble the game
python -m nibbler disasm game.bin --base 0x4000 --entry 0x4000 -r

# 6. Create a bootable DSK for emulator testing
python -m nibbler dsk --binary game.bin --load-addr 0x4000 --entry-addr 0x4000 -o game.dsk

Steps 1–3 are static analysis — no code execution, fast, safe. Step 4 is where the protection is actually defeated. Steps 5–6 work on the extracted binary and don’t touch the WOZ file again.


Python Library Interface

Every CLI command is backed by a module that can be used directly from Python:

from nibbler.woz import WOZFile
from nibbler.gcr import find_sectors_53, auto_detect_address_prologs
from nibbler.cpu import CPU6502
from nibbler.disk import WOZDisk
from nibbler.boot import BootAnalyzer
from nibbler.analyze import CopyProtectionAnalyzer
from nibbler.dsk import woz_to_dsk, create_bootable_dsk
from nibbler.disasm import Disassembler, disassemble_region

Reading a WOZ file and iterating tracks:

from nibbler.woz import WOZFile

woz = WOZFile("Apple Panic - Disk 1, Side A.woz")
print(f"Tracks with data: {woz.track_count}")

for track_num in range(14):
    bits = woz.get_track_bits(track_num)
    print(f"Track {track_num}: {len(bits)} bits")

Running the copy protection analyser:

from nibbler.woz import WOZFile
from nibbler.analyze import CopyProtectionAnalyzer

woz = WOZFile("Apple Panic - Disk 1, Side A.woz")
analyzer = CopyProtectionAnalyzer(woz)
results = analyzer.analyze()
print(analyzer.generate_report(results))

Booting a disk and capturing memory:

from nibbler.boot import BootAnalyzer

ba = BootAnalyzer("Apple Panic - Disk 1, Side A.woz")
memory = ba.boot(stop_address=0x4000, max_instructions=200_000_000)

# Extract game binary
game_bytes = memory[0x4000:0xA800]
with open("game.bin", "wb") as f:
    f.write(game_bytes)

Recursive descent disassembly:

from nibbler.disasm import Disassembler

with open("game.bin", "rb") as f:
    data = f.read()

d = Disassembler(data, base_address=0x4000)
d.disassemble(entry_points=[0x4000, 0x7000])
print(d.format_listing())

The library interface is useful when you need to build custom analysis pipelines: scanning multiple disks automatically, comparing outputs across a collection, or integrating nibbler into a larger tool.


Module Reference

ModuleKey contents
woz.pyWOZFile — WOZ2 header/TMAP/TRKS parser; get_track_bits() with bit-doubling
gcr.pyfind_sectors_62(), find_sectors_53(), auto_detect_address_prologs(), decode_sector_62(), decode_sector_53(), GCR encode/decode tables
cpu.pyCPU6502 — full NMOS 6502 emulator (all 256 opcodes including 29 undocumented); breakpoint system; Disk II soft switch handling
disk.pyWOZDisk — nibble streaming from WOZ bit streams; stepper motor simulation; I/O trace callbacks
boot.pyBootAnalyzer — P6 ROM boot emulation; memory capture; snapshot saving; disk I/O tracing
analyze.pyCopyProtectionAnalyzer — 8-technique detector; markdown report generator; non-standard prolog auto-detection
dsk.pywoz_to_dsk(), write_dsk(), create_bootable_dsk(); DOS 3.3 VTOC/catalog reading
disasm.pyDisassembler (recursive descent with gap-filling); disassemble_region() (linear); OPCODES table (all 256 including undocumented); Apple II hardware register comment table

What nibbler Can’t Tell You

protect detects eight technique classes from static analysis. It will not detect GCR table corruption, self-modifying code, or custom post-decode permutations from the disk surface alone — those only appear at runtime. The report always notes which techniques remain invisible to static analysis and suggests boot as the next step.

boot works on any custom-loader Apple II disk that uses the standard P6 ROM entry point (loads sector 0 of track 0 in 6-and-2 and jumps to $0801). A disk that requires a custom ROM, non-standard stepper timing, or a modified controller card is outside its scope. Apple Panic, like most copy-protected commercial software of the era, stays within that scope.

The disasm module’s recursive descent handles most real programs correctly but can miss subroutines that are only reachable via computed indirect jumps (JMP ($xxxx) with runtime pointer values). The gap-filling pass catches most of these by pattern-matching for valid subroutine structures in unclassified regions, but it’s not foolproof. For games with complex dispatch tables, manual inspection of the gap-filler’s output is worthwhile.


The full source for nibbler, all 39 investigation scripts, and the Apple Panic assembly source are in the Orchard repository.