Breaking Enigma on the Commodore 64
By Michael Doornbos
- 22 minutes read - 4609 wordsIn The Math Behind Enigma we talked about cribs, guessed plaintext that gave codebreakers a way in. In the emulator article we built a working Enigma M3 on the Commodore 64. Now let’s put the two together. We’re going to use the C64 to break an Enigma message.
Not a toy message with a known answer. A proper brute-force search through nearly six million possible settings, testing each one against a crib until the machine finds the right configuration. The same approach, in spirit if not in scale, that Bletchley Park used to crack German traffic during the war.
The Scenario
You’re at Bletchley Park. The Y stations have intercepted a transmission from a U-boat in the Bay of Biscay. The ciphertext is:
YDMAOIGMPQZPFVRCIGIIKJV
Intelligence suggests it’s a weather report. German weather messages reliably started with WETTER, the German word for weather. That’s our crib: a guessed fragment of plaintext that we can use to test candidate settings.
We don’t know which rotors were used, or their starting positions. We don’t know the plugboard configuration. But we have six characters of probable plaintext, and a Commodore 64 with nothing better to do.
Ignoring the Plugboard
The plugboard adds over 150 trillion possible configurations on its own, but Marian Rejewski figured out in 1932 that it’s the easiest part to ignore. The plugboard doesn’t interact with the rotors or the reflector. Once you know the rotor settings, the plugboard swaps can be deduced separately by comparing known plaintext against the rotor-only encryption. It’s just a simple substitution at that point.
So we drop it entirely. Our emulator supports the plugboard, but for the search, every candidate uses an identity plugboard (no swaps). We’re also leaving out ring settings since our emulator doesn’t implement them. We’ll come back to that.
The Search Space
We have to guess two things: which rotors (and their order) and what starting positions they were set to. The crib is what lets us test each guess. Encrypt “WETTER” with a candidate configuration and see if the output matches the intercepted ciphertext. If it does, we’ve found the settings.
Our emulator is an M3, the Kriegsmarine’s three-rotor machine with eight rotors to choose from. That gives us:
- Rotor selection: Pick 3 from 8, order matters. That’s 8 x 7 x 6 = 336 permutations. Rotors I-III-V is a different machine than Rotors III-I-V because the signal passes through them in a different order.
- Starting positions: Each of the 3 rotors can start at any of 26 positions (A-Z). That’s 26 x 26 x 26 = 17,576 combinations per ordering.
- Total: 336 x 17,576 = 5,909,376 candidates
Nearly six million settings. On a 1 MHz processor. That’s going to take a while.
But six million is manageable. Look at what happens when you scale up:
| Configuration | Orderings | Positions | Total |
|---|---|---|---|
| Rotors I-VIII (M3) | 336 | 17,576 | 5,909,376 |
| Add ring settings | 336 | 17,576 x 17,576 | ~103 billion |
| Add plugboard (10 pairs) | 336 | 17,576 | ~8.9 x 10^17 |
The first row is what we’re doing today. The second row is where brute force hits a wall. A hundred billion candidates at a thousand per second is about 3,300 years. That’s why Turing built the Bombe. It eliminated impossible settings through logical contradiction instead of testing each one. We’ll get to that later in the series.
For now, six million is plenty.
Early Exit
The big win is early exit. For each candidate, we encrypt the first character of the crib and compare it to the first character of the ciphertext. If they don’t match, we skip immediately to the next candidate. Only 1 in 26 candidates will pass the first-character test. Of those, only 1 in 26 will pass the second. Most candidates are eliminated in microseconds.
On average, we test about 1.04 characters per candidate instead of all 6. That’s the difference between a search that takes hours and one that takes minutes.
Validating the Crib
Before searching, we can do a quick sanity check. Enigma’s reflector guarantees that a letter never encrypts to itself. If our crib alignment has any position where the plaintext and ciphertext letters match, the crib is wrong (or in the wrong position).
Check our crib WETTER against the ciphertext YDMAOI:
Position: 0 1 2 3 4 5
Plaintext: W E T T E R
Ciphertext: Y D M A O I
Match? No No No No No No ✓
No letter encrypts to itself. The crib alignment is valid. If any position had matched, we’d know to shift the crib to a different position in the ciphertext or reconsider our guess entirely. At Bletchley Park, this was the main trick for checking whether a crib was in the right position. The reflector was supposed to be a feature, not a fatal flaw.
The Algorithm
The brute-force crib attack in pseudocode:
for each permutation of 3 rotors from {I, II, III, IV, V, VI, VII, VIII}:
for left_position = A to Z:
for middle_position = A to Z:
for right_position = A to Z:
configure machine with this rotor order and positions
for each character in crib:
encrypt the crib character
if result ≠ corresponding ciphertext character:
skip to next candidate (early exit)
if all characters matched:
FOUND — display settings and decrypt
Six nested loops. The outer three generate permutations, the inner three sweep positions. For each candidate, we run the crib through the Enigma encrypt routine and bail at the first mismatch.
BASIC Version
The BASIC version searches all 17,576 starting positions for a single rotor ordering that you specify. It’s too slow to search all 336 orderings in one sitting (that would take weeks), but it shows how the attack works and lets you watch the C64 grind through the positions.
The rotor wiring, stepping logic, and encryption subroutine are identical to the emulator. The only new code is the search loop and the crib comparison.
5 PRINT CHR$(5)
10 PRINT CHR$(147);" ENIGMA CRIB ATTACK"
20 PRINT " BRUTE FORCE SEARCH":PRINT
100 DIM RF(7,25),RI(7,25),RE(25)
110 FOR R=0 TO 7:FOR I=0 TO 25
120 READ RF(R,I):NEXT I,R
200 DATA 4,10,12,5,11,6,3,16,21,25
210 DATA 13,19,14,22,24,7,23,20,18,15
220 DATA 0,8,1,17,2,9
230 DATA 0,9,3,10,18,8,17,20,23,1
240 DATA 11,7,22,19,12,2,16,6,25,13
250 DATA 15,24,5,21,14,4
260 DATA 1,3,5,7,9,11,2,15,17,19
270 DATA 23,21,25,13,24,4,8,22,6,0
280 DATA 10,12,20,18,16,14
290 DATA 4,18,14,21,15,25,9,0,24,16
300 DATA 20,8,17,7,23,11,13,5,19,6
310 DATA 10,3,2,12,22,1
320 DATA 21,25,1,17,6,8,19,24,20,15
330 DATA 18,3,13,7,11,23,0,22,12,9
340 DATA 16,14,5,4,2,10
350 DATA 9,15,6,21,14,20,12,5,24,16
360 DATA 1,4,13,7,25,17,3,10,0,18
370 DATA 23,11,8,2,19,22
380 DATA 13,25,9,7,6,17,2,23,12,24
390 DATA 18,22,1,14,20,5,0,8,21,11
395 DATA 15,4,10,16,3,19
400 DATA 5,10,16,7,19,11,23,14,2,1
410 DATA 9,18,15,3,25,17,0,12,4,22
420 DATA 13,8,20,24,6,21
450 REM COMPUTE INVERSE TABLES
460 FOR R=0 TO 7:FOR I=0 TO 25
470 RI(R,RF(R,I))=I
480 NEXT I,R
500 REM REFLECTOR UKW-B
510 FOR I=0 TO 25:READ RE(I):NEXT
520 DATA 24,17,20,7,16,18,11,3,15,23
530 DATA 13,6,14,10,12,8,4,1,5,25
540 DATA 2,22,21,9,0,19
600 REM NOTCH POSITIONS (TWO PER ROTOR)
610 DIM NO(7,1)
620 NO(0,0)=16:NO(0,1)=-1
630 NO(1,0)=4:NO(1,1)=-1
640 NO(2,0)=21:NO(2,1)=-1
650 NO(3,0)=9:NO(3,1)=-1
660 NO(4,0)=25:NO(4,1)=-1
670 NO(5,0)=25:NO(5,1)=12
680 NO(6,0)=25:NO(6,1)=12
690 NO(7,0)=25:NO(7,1)=12
700 REM CRIB AND TARGET DATA
710 DIM CR(5),TG(5)
720 CR(0)=22:CR(1)=4:CR(2)=19
730 CR(3)=19:CR(4)=4:CR(5)=17
740 TG(0)=24:TG(1)=3:TG(2)=12
750 TG(3)=0:TG(4)=14:TG(5)=8
760 CL=6
770 CT$="YDMAOIGMPQZPFVRCIGIIKJV"
800 PRINT "SELECT 3 ROTORS (1-8)"
810 INPUT "LEFT ROTOR";RL
820 INPUT "MIDDLE ROTOR";RM
830 INPUT "RIGHT ROTOR";RR
840 RL=RL-1:RM=RM-1:RR=RR-1
850 PRINT:PRINT "SEARCHING 17,576 POSITIONS"
860 PRINT "FOR ROTORS";RL+1;"-";RM+1;"-";RR+1
870 PRINT:TI$="000000"
900 FOR L0=0 TO 25
910 PRINT CHR$(L0+65);" ";
920 FOR M0=0 TO 25:FOR R0=0 TO 25
930 GOSUB 5000
940 IF F=1 THEN 1100
950 NEXT R0,M0,L0
960 PRINT:PRINT
970 PRINT "NOT FOUND (";INT(TI/60);"SEC)"
980 END
1100 PRINT:PRINT
1110 PRINT "FOUND!"
1120 PRINT "POSITION: ";CHR$(L0+65);"-";
1125 PRINT CHR$(M0+65);"-";CHR$(R0+65)
1130 PRINT "TIME:";INT(TI/60);"SECONDS"
1150 PRINT:PRINT "DECRYPTED:"
1160 LP=L0:MP=M0:RP=R0
1170 FOR I=1 TO LEN(CT$)
1180 GOSUB 2000
1190 C=ASC(MID$(CT$,I,1))-65
1200 GOSUB 3000
1210 PRINT CHR$(C+65);
1220 NEXT
1230 PRINT:END
2000 REM STEP ROTORS
2010 IF MP<>NO(RM,0)ANDMP<>NO(RM,1) THEN 2040
2020 LP=LP+1:LP=LP-INT(LP/26)*26
2030 MP=MP+1:MP=MP-INT(MP/26)*26
2040 IF RP<>NO(RR,0)ANDRP<>NO(RR,1) THEN 2060
2050 MP=MP+1:MP=MP-INT(MP/26)*26
2060 RP=RP+1:RP=RP-INT(RP/26)*26
2070 RETURN
3000 REM ENCRYPT (FORWARD)
3010 E=(C+RP):E=E-INT(E/26)*26
3020 C=RF(RR,E)
3030 C=(C-RP+26):C=C-INT(C/26)*26
3040 E=(C+MP):E=E-INT(E/26)*26
3050 C=RF(RM,E)
3060 C=(C-MP+26):C=C-INT(C/26)*26
3070 E=(C+LP):E=E-INT(E/26)*26
3080 C=RF(RL,E)
3090 C=(C-LP+26):C=C-INT(C/26)*26
3100 REM REFLECTOR
3110 C=RE(C)
3120 REM ENCRYPT (INVERSE)
3130 E=(C+LP):E=E-INT(E/26)*26
3140 C=RI(RL,E)
3150 C=(C-LP+26):C=C-INT(C/26)*26
3160 E=(C+MP):E=E-INT(E/26)*26
3170 C=RI(RM,E)
3180 C=(C-MP+26):C=C-INT(C/26)*26
3190 E=(C+RP):E=E-INT(E/26)*26
3200 C=RI(RR,E)
3210 C=(C-RP+26):C=C-INT(C/26)*26
3220 RETURN
5000 REM TEST CRIB
5010 LP=L0:MP=M0:RP=R0:K=0
5020 GOSUB 2000:C=CR(K):GOSUB 3000
5030 IF C<>TG(K) THEN F=0:RETURN
5040 K=K+1:IF K<CL THEN 5020
5050 F=1:RETURN
The search loop at lines 900-950 sweeps left, middle, and right positions from A through Z. Line 910 prints the current left position as a progress indicator. You’ll see the letters A through Z appear across the screen as the search progresses.
The crib test at line 5000 is the most performance-critical code since it runs for every single candidate. It sets the rotor positions, then for each crib character, calls the step and encrypt subroutines and compares the result to the target. The manual loop at lines 5020-5040 avoids BASIC’s FOR/NEXT stack issues. A RETURN from inside a FOR loop would leave an unmatched frame on the stack, which eventually crashes.
The critical detail: after a failed crib test, the rotor positions (LP, MP, RP) are in some intermediate state because the step routine modified them. But we overwrite them at the start of the next test (line 5010), so it doesn’t matter. We only care about the positions before encryption begins.
The BASIC version only searches one rotor ordering per run, the one you enter. It’s too slow to try all 336. If you happen to enter the correct ordering (3-1-5), the search finds the answer at position M-C-Q after about 8,164 candidates and roughly 24 minutes. Enter the wrong ordering, say 1-2-3, and you’ll wait about 52 minutes watching it grind through all 17,576 positions before printing “NOT FOUND.” Then you’d have to run it again with a different guess.
At Bletchley Park, this is roughly how the workload was divided: different teams or machines would each take a subset of possible orderings and search in parallel. The BASIC version puts you in that role. Pick an ordering, hope you’re right, and wait.
The assembly version, by contrast, searches all 336 orderings automatically. No guessing, no re-running. It finds the answer regardless of where in the search space it happens to be.
Setting TI$="000000" at line 870 resets the jiffy clock to zero. After the search, INT(TI/60) gives elapsed seconds directly. TI counts in 60ths of a second, so dividing by 60 is all you need.
Assembly Version
The assembly version does the full search: all 336 rotor orderings, all 17,576 positions each, nearly six million candidates total. It reuses the encrypt, rotor_pass, and mod26 routines from the emulator with no changes needed. The step routine gains a dual-notch check for Rotors VI-VIII. The new code is the search loop, crib comparison, and progress display.
The Permutation Loop
Generating all permutations of 3 rotors from 8 is three nested loops with skip logic. If the middle rotor is the same as the left, skip it. If the right rotor matches either the left or middle, skip it.
; search all P(8,3) = 336 orderings
search_all
lda #0
sta try_l
sa_left lda #0
sta try_m
sa_mid lda try_m
cmp try_l
beq sa_sm
lda #0
sta try_r
sa_right lda try_r
cmp try_l
beq sa_sr
cmp try_m
beq sa_sr
; valid permutation
jsr search_pos
lda found
bne sa_done
sa_sr inc try_r
lda try_r
cmp #8
bne sa_right
sa_sm inc try_m
lda try_m
cmp #8
bne sa_mid
inc try_l
lda try_l
cmp #8
bne sa_left
sa_done rts
Eight values for left, seven valid for middle, six valid for right: 8 x 7 x 6 = 336 valid permutations. The cmp/beq pairs at the top of each inner loop skip duplicates. The search exits early when found is set.
The Position Search and Crib Test
For each valid rotor permutation, we configure the machine and sweep through all 17,576 starting positions. The crib test is inlined in the position loop for speed:
search_pos
; configure rotors
lda try_l
sta left_sel
lda try_m
sta mid_sel
lda try_r
sta right_sel
; border color = progress
inc $d020
; sweep positions
lda #0
sta try_lp
sp_lp lda #0
sta try_mp
sp_mp lda #0
sta try_rp
sp_rp
; set starting positions
lda try_lp
sta left_pos
lda try_mp
sta mid_pos
lda try_rp
sta right_pos
; test crib
ldx #0
tc_loop lda crib,x
stx savex
jsr encrypt
ldx savex
cmp target,x
bne tc_fail
inx
cpx #6
bne tc_loop
; all 6 matched
lda #1
sta found
rts
tc_fail inc try_rp
lda try_rp
cmp #26
bne sp_rp
inc try_mp
lda try_mp
cmp #26
bne sp_mp
inc try_lp
lda try_lp
cmp #26
bne sp_lp
rts
The border color changes once per rotor ordering (inc $d020). With 336 orderings the border wraps through all 16 colors multiple times, a visual heartbeat that tells you the machine is working. When the color stops changing, the search is done.
The crib test at tc_loop encrypts each crib character and compares the result to the target ciphertext. If the first character doesn’t match (which happens 25 out of 26 times), we jump straight to tc_fail and advance to the next position. No wasted cycles on the remaining five characters. That early exit is why the search works on a 1 MHz machine.
Displaying the Result
When the search finds a match, we print the rotor ordering, starting positions, and the fully decrypted message:
show_found
; print found info
ldx #<s_found
ldy #>s_found
jsr print
lda #13
jsr chrout
; print rotor names
ldx #<s_rotor
ldy #>s_rotor
jsr print
ldx left_sel
jsr print_rn
ldx #<s_sep
ldy #>s_sep
jsr print
ldx mid_sel
jsr print_rn
ldx #<s_sep
ldy #>s_sep
jsr print
ldx right_sel
jsr print_rn
lda #13
jsr chrout
; print starting positions
ldx #<s_start
ldy #>s_start
jsr print
lda try_lp
clc
adc #65
jsr chrout
ldx #<s_sep
ldy #>s_sep
jsr print
lda try_mp
clc
adc #65
jsr chrout
ldx #<s_sep
ldy #>s_sep
jsr print
lda try_rp
clc
adc #65
jsr chrout
lda #13
jsr chrout
lda #13
jsr chrout
; decrypt full message
ldx #<s_decrypt
ldy #>s_decrypt
jsr print
; reset positions
lda try_lp
sta left_pos
lda try_mp
sta mid_pos
lda try_rp
sta right_pos
ldx #0
dc_loop lda cipher,x
cmp #$ff
beq dc_done
stx savex
jsr encrypt
clc
adc #65
jsr chrout
ldx savex
inx
jmp dc_loop
dc_done lda #13
jsr chrout
rts
The decrypt loop encrypts the ciphertext with the found settings. Since Enigma is self-reciprocal, encrypting ciphertext with the correct settings recovers the plaintext. The moment “WETTERVORHERSAGEBISKAYA” appears on screen is the payoff. Wettervorhersage Biskaya, “weather forecast Biscay.” A routine U-boat weather report from the Bay of Biscay, the kind of predictable traffic that kept Bletchley Park in cribs throughout the war.
Jiffy Clock Timing
The C64’s jiffy clock at $a0-$a2 counts in 60ths of a second. We zero it before the search and read it after. The jiffies article explains the format.
; zero jiffy clock
lda #0
sta $a2
sta $a1
sta $a0
; ... search runs here ...
; print elapsed seconds
ldx #<s_time
ldy #>s_time
jsr print
jsr jiffy_sec
ldx #<s_sec
ldy #>s_sec
jsr print
The jiffy_sec routine divides the 24-bit jiffy count by 60 to get seconds. It uses the standard shift-and-subtract method: rotate the dividend left one bit at a time, accumulating the quotient in the dividend bytes and the remainder in a separate byte. After 24 iterations, the low two bytes of the dividend hold the quotient.
; divide jiffy clock by 60
; prints result as decimal
jiffy_sec lda $a0
sta dividend
lda $a1
sta dividend+1
lda $a2
sta dividend+2
lda #0
sta remainder
ldx #24
jsloop asl dividend+2
rol dividend+1
rol dividend
rol remainder
lda remainder
cmp #60
bcc jsskip
sbc #60
sta remainder
inc dividend+2
jsskip dex
bne jsloop
; quotient in dividend+1/+2
lda dividend+1
ldx dividend+2
jmp $bdcd
The BASIC ROM’s linprt routine at $bdcd prints the 16-bit value in A (high byte) and X (low byte) as a decimal number. We get the divide and the decimal print for free, no hex-to-decimal conversion needed. In the Fibonacci article we used the same $bdcd trick.
The Full Listing
Here’s the complete brute-force cracker. It assembles at $c000 and runs with sys 49152. The encrypt, rotor_pass, and mod26 routines are identical to the emulator. The step routine adds a second notch check for Rotors VI-VIII. The rotor wiring tables cover all eight M3 rotors.
; ==============================
; enigma crib attack
; brute force search
; commodore 64
; rotors i-viii (m3), no plugboard
; turbo macro pro / tmpx
; by michael doornbos 2026
; mike@imapenguin.com
; ==============================
* = $c000
; --- zero page ---
ptr = $50
right_pos = $fb
mid_pos = $fc
left_pos = $fd
temp = $fe
chrout = $ffd2
; === entry point ===
; zero jiffy clock
lda #0
sta $a2
sta $a1
sta $a0
; cls, white text
lda #$93
jsr chrout
lda #5
jsr chrout
lda #13
jsr chrout
; header
ldx #<s_title
ldy #>s_title
jsr print
lda #13
jsr chrout
ldx #<s_sub
ldy #>s_sub
jsr print
lda #13
jsr chrout
lda #13
jsr chrout
; show ciphertext
ldx #<s_cipher
ldy #>s_cipher
jsr print
lda #13
jsr chrout
; show crib
ldx #<s_crib
ldy #>s_crib
jsr print
lda #13
jsr chrout
lda #13
jsr chrout
; searching message
ldx #<s_search
ldy #>s_search
jsr print
lda #13
jsr chrout
; init found flag
lda #0
sta found
; run search
jsr search_all
; restore border
lda #14
sta $d020
; check result
lda found
beq not_found
; show result
lda #13
jsr chrout
jsr show_found
jmp show_time
not_found ldx #<s_notfound
ldy #>s_notfound
jsr print
lda #13
jsr chrout
; show elapsed seconds
show_time lda #13
jsr chrout
ldx #<s_time
ldy #>s_time
jsr print
jsr jiffy_sec
ldx #<s_sec
ldy #>s_sec
jsr print
lda #13
jsr chrout
rts
; === search all orderings ===
search_all
lda #0
sta try_l
sa_left lda #0
sta try_m
sa_mid lda try_m
cmp try_l
beq sa_sm
lda #0
sta try_r
sa_right lda try_r
cmp try_l
beq sa_sr
cmp try_m
beq sa_sr
; valid permutation
jsr search_pos
lda found
bne sa_done
sa_sr inc try_r
lda try_r
cmp #8
bne sa_right
sa_sm inc try_m
lda try_m
cmp #8
bne sa_mid
inc try_l
lda try_l
cmp #8
bne sa_left
sa_done rts
; === search positions ===
search_pos
; configure rotors
lda try_l
sta left_sel
lda try_m
sta mid_sel
lda try_r
sta right_sel
; border color = progress
inc $d020
; sweep all positions
lda #0
sta try_lp
sp_lp lda #0
sta try_mp
sp_mp lda #0
sta try_rp
sp_rp
; set starting positions
lda try_lp
sta left_pos
lda try_mp
sta mid_pos
lda try_rp
sta right_pos
; test crib
ldx #0
tc_loop lda crib,x
stx savex
jsr encrypt
ldx savex
cmp target,x
bne tc_fail
inx
cpx #6
bne tc_loop
; all matched
lda #1
sta found
rts
tc_fail inc try_rp
lda try_rp
cmp #26
bne sp_rp
inc try_mp
lda try_mp
cmp #26
bne sp_mp
inc try_lp
lda try_lp
cmp #26
bne sp_lp
rts
; === show found result ===
show_found
ldx #<s_found
ldy #>s_found
jsr print
lda #13
jsr chrout
; rotors
ldx #<s_rotor
ldy #>s_rotor
jsr print
ldx left_sel
jsr print_rn
ldx #<s_sep
ldy #>s_sep
jsr print
ldx mid_sel
jsr print_rn
ldx #<s_sep
ldy #>s_sep
jsr print
ldx right_sel
jsr print_rn
lda #13
jsr chrout
; positions
ldx #<s_start
ldy #>s_start
jsr print
lda try_lp
clc
adc #65
jsr chrout
ldx #<s_sep
ldy #>s_sep
jsr print
lda try_mp
clc
adc #65
jsr chrout
ldx #<s_sep
ldy #>s_sep
jsr print
lda try_rp
clc
adc #65
jsr chrout
lda #13
jsr chrout
lda #13
jsr chrout
; decrypt full message
ldx #<s_decrypt
ldy #>s_decrypt
jsr print
; reset to found positions
lda try_lp
sta left_pos
lda try_mp
sta mid_pos
lda try_rp
sta right_pos
ldx #0
dc_loop lda cipher,x
cmp #$ff
beq dc_done
stx savex
jsr encrypt
clc
adc #65
jsr chrout
ldx savex
inx
jmp dc_loop
dc_done lda #13
jsr chrout
rts
; === encrypt ===
; a = letter (0-25) in/out
; no plugboard (identity)
encrypt pha
jsr step
pla
; right fwd
ldx right_sel
jsr set_fwd
ldx right_pos
jsr rotor_pass
; middle fwd
ldx mid_sel
jsr set_fwd
ldx mid_pos
jsr rotor_pass
; left fwd
ldx left_sel
jsr set_fwd
ldx left_pos
jsr rotor_pass
; reflector
tax
lda reflector,x
; left inv
ldx left_sel
jsr set_inv
ldx left_pos
jsr rotor_pass
; middle inv
ldx mid_sel
jsr set_inv
ldx mid_pos
jsr rotor_pass
; right inv
ldx right_sel
jsr set_inv
ldx right_pos
jsr rotor_pass
rts
; === step rotors ===
; dual notch for vi-viii
step
ldx mid_sel
lda mid_pos
cmp notch1,x
beq do_double
cmp notch2,x
bne no_double
do_double
; double step
lda left_pos
clc
adc #1
jsr mod26
sta left_pos
lda mid_pos
clc
adc #1
jsr mod26
sta mid_pos
jmp step_right
no_double
ldx right_sel
lda right_pos
cmp notch1,x
beq do_mid
cmp notch2,x
bne step_right
do_mid lda mid_pos
clc
adc #1
jsr mod26
sta mid_pos
step_right
lda right_pos
clc
adc #1
jsr mod26
sta right_pos
rts
; === mod26 ===
mod26 cmp #26
bcc m26done
sbc #26
m26done rts
; === rotor pass ===
rotor_pass
stx temp
clc
adc temp
jsr mod26
tay
lda (ptr),y
sec
sbc temp
clc
adc #26
jsr mod26
rts
; === set table pointer ===
set_fwd ldy fwd_lo,x
sty ptr
ldy fwd_hi,x
sty ptr+1
rts
set_inv ldy inv_lo,x
sty ptr
ldy inv_hi,x
sty ptr+1
rts
; === print string ===
; x=lo, y=hi, null-terminated
print stx ptr
sty ptr+1
ldy #0
ploop lda (ptr),y
beq pdone
jsr chrout
iny
bne ploop
pdone rts
; === print rotor name ===
; x = rotor index (0-7)
print_rn
lda rn_lo,x
sta ptr
lda rn_hi,x
sta ptr+1
ldy #0
rnloop lda (ptr),y
beq rndone
jsr chrout
iny
bne rnloop
rndone rts
; === jiffy clock to seconds ===
; divide 24-bit jiffy by 60
; print result as decimal
jiffy_sec lda $a0
sta dividend
lda $a1
sta dividend+1
lda $a2
sta dividend+2
lda #0
sta remainder
ldx #24
jsloop asl dividend+2
rol dividend+1
rol dividend
rol remainder
lda remainder
cmp #60
bcc jsskip
sbc #60
sta remainder
inc dividend+2
jsskip dex
bne jsloop
lda dividend+1
ldx dividend+2
jmp $bdcd
dividend .byte 0,0,0
remainder .byte 0
; === data ===
; search state
savex .byte 0
found .byte 0
try_l .byte 0
try_m .byte 0
try_r .byte 0
try_lp .byte 0
try_mp .byte 0
try_rp .byte 0
; rotor config
right_sel .byte 0
mid_sel .byte 0
left_sel .byte 0
; crib: wetter (0-25)
crib .byte 22,4,19,19,4,17
; target: ydmaoi (0-25)
target .byte 24,3,12,0,14,8
; full ciphertext (0-25, $ff term)
cipher .byte 24,3,12,0,14,8
.byte 6,12,15,16,25,15
.byte 5,21,17,2,8,6
.byte 8,8,10,9,21,$ff
; notch positions (i-viii)
notch1 .byte 16,4,21,9,25
.byte 25,25,25
; second notch ($ff = none)
notch2 .byte $ff,$ff,$ff,$ff,$ff
.byte 12,12,12
; address tables (rotors i-viii)
fwd_lo .byte <rot1_f,<rot2_f
.byte <rot3_f,<rot4_f
.byte <rot5_f,<rot6_f
.byte <rot7_f,<rot8_f
fwd_hi .byte >rot1_f,>rot2_f
.byte >rot3_f,>rot4_f
.byte >rot5_f,>rot6_f
.byte >rot7_f,>rot8_f
inv_lo .byte <rot1_i,<rot2_i
.byte <rot3_i,<rot4_i
.byte <rot5_i,<rot6_i
.byte <rot7_i,<rot8_i
inv_hi .byte >rot1_i,>rot2_i
.byte >rot3_i,>rot4_i
.byte >rot5_i,>rot6_i
.byte >rot7_i,>rot8_i
; === rotor wiring ===
; i: ekmflgdqvzntowyhxuspaibrcj
rot1_f .byte 4,10,12,5,11,6
.byte 3,16,21,25,13,19
.byte 14,22,24,7,23,20
.byte 18,15,0,8,1,17
.byte 2,9
; ii: ajdksiruxblhwtmcqgznpyfvoe
rot2_f .byte 0,9,3,10,18,8
.byte 17,20,23,1,11,7
.byte 22,19,12,2,16,6
.byte 25,13,15,24,5,21
.byte 14,4
; iii: bdfhjlcprtxvznyeiwgakmusqo
rot3_f .byte 1,3,5,7,9,11
.byte 2,15,17,19,23,21
.byte 25,13,24,4,8,22
.byte 6,0,10,12,20,18
.byte 16,14
; iv: esovpzjayquirhxlnftgkdcmwb
rot4_f .byte 4,18,14,21,15,25
.byte 9,0,24,16,20,8
.byte 17,7,23,11,13,5
.byte 19,6,10,3,2,12
.byte 22,1
; v: vzbrgityupsdnhlxawmjqofeck
rot5_f .byte 21,25,1,17,6,8
.byte 19,24,20,15,18,3
.byte 13,7,11,23,0,22
.byte 12,9,16,14,5,4
.byte 2,10
; vi: jpgvoumfyqbenhzrdkasxlictw
rot6_f .byte 9,15,6,21,14,20
.byte 12,5,24,16,1,4
.byte 13,7,25,17,3,10
.byte 0,18,23,11,8,2
.byte 19,22
; vii: nzjhgrcxmyswboufaivlpekqdt
rot7_f .byte 13,25,9,7,6,17
.byte 2,23,12,24,18,22
.byte 1,14,20,5,0,8
.byte 21,11,15,4,10,16
.byte 3,19
; viii: fkqhtlxocbjspdzramewniuygv
rot8_f .byte 5,10,16,7,19,11
.byte 23,14,2,1,9,18
.byte 15,3,25,17,0,12
.byte 4,22,13,8,20,24
.byte 6,21
; inverse tables
rot1_i .byte 20,22,24,6,0,3
.byte 5,15,21,25,1,4
.byte 2,10,12,19,7,23
.byte 18,11,17,8,13,16
.byte 14,9
rot2_i .byte 0,9,15,2,25,22
.byte 17,11,5,1,3,10
.byte 14,19,24,20,16,6
.byte 4,13,7,23,12,8
.byte 21,18
rot3_i .byte 19,0,6,1,15,2
.byte 18,3,16,4,20,5
.byte 21,13,25,7,24,8
.byte 23,9,22,11,17,10
.byte 14,12
rot4_i .byte 7,25,22,21,0,17
.byte 19,13,11,6,20,15
.byte 23,16,2,4,9,12
.byte 1,18,10,3,24,14
.byte 8,5
rot5_i .byte 16,2,24,11,23,22
.byte 4,13,5,19,25,14
.byte 18,12,21,9,20,3
.byte 10,6,8,0,17,15
.byte 7,1
rot6_i .byte 18,10,23,16,11,7
.byte 2,13,22,0,17,21
.byte 6,12,4,1,9,15
.byte 19,24,5,3,25,20
.byte 8,14
rot7_i .byte 16,12,6,24,21,15
.byte 4,3,17,2,22,19
.byte 8,0,13,20,23,5
.byte 10,25,14,18,11,7
.byte 9,1
rot8_i .byte 16,9,8,13,18,0
.byte 24,3,21,10,1,5
.byte 17,20,7,12,2,15
.byte 11,4,22,25,19,6
.byte 23,14
; ukw-b reflector
reflector .byte 24,17,20,7,16,18
.byte 11,3,15,23,13,6
.byte 14,10,12,8,4,1
.byte 5,25,2,22,21,9
.byte 0,19
; === strings (petscii) ===
; " enigma crib attack"
s_title .byte 32,32,69,78,73,71
.byte 77,65,32,67,82,73
.byte 66,32,65,84,84,65
.byte 67,75,0
; " brute force search"
s_sub .byte 32,32,66,82,85,84
.byte 69,32,70,79,82,67
.byte 69,32,83,69,65,82
.byte 67,72,0
; " ciphertext: ydmaoigmpqzpfvrcigiikjv"
s_cipher .byte 32,32,67,73,80,72
.byte 69,82,58,32,89,68
.byte 77,65,79,73,71,77
.byte 80,81,90,80,70,86
.byte 82,67,73,71,73,73
.byte 75,74,86,0
; " crib: wetter"
s_crib .byte 32,32,67,82,73,66
.byte 58,32,87,69,84,84
.byte 69,82,0
; " searching..."
s_search .byte 32,32,83,69,65,82
.byte 67,72,73,78,71,46
.byte 46,46,0
; " found!"
s_found .byte 32,32,70,79,85,78
.byte 68,33,0
; " not found"
s_notfound .byte 32,32,78,79,84
.byte 32,70,79,85,78,68
.byte 0
; " rotors: "
s_rotor .byte 32,32,82,79,84,79
.byte 82,83,58,32,32,0
; " start: "
s_start .byte 32,32,83,84,65,82
.byte 84,58,32,32,32,0
; " decrypted: "
s_decrypt .byte 32,32,68,69,67,82
.byte 89,80,84,69,68,58
.byte 32,0
; " time: "
s_time .byte 32,32,84,73,77,69
.byte 58,32,0
; " seconds"
s_sec .byte 32,83,69,67,79,78
.byte 68,83,0
; " - "
s_sep .byte 32,45,32,0
; rotor names (petscii)
rn1 .byte 73,0
rn2 .byte 73,73,0
rn3 .byte 73,73,73,0
rn4 .byte 73,86,0
rn5 .byte 86,0
rn6 .byte 86,73,0
rn7 .byte 86,73,73,0
rn8 .byte 86,73,73,73,0
rn_lo .byte <rn1,<rn2,<rn3
.byte <rn4,<rn5,<rn6
.byte <rn7,<rn8
rn_hi .byte >rn1,>rn2,>rn3
.byte >rn4,>rn5,>rn6
.byte >rn7,>rn8
The whole program is about 2 KB, with the rotor tables accounting for about 450 bytes. The actual search logic is under 100 bytes. Most of the code is display and data.
Timing
The assembly version searches all 5,909,376 candidates in about 22 minutes on a stock C64. The border color cycles through 336 orderings, wrapping the 16 C64 colors multiple times, so you can watch the progress. When the color stops changing and “FOUND!” appears, the search is done.
The BASIC version tests about 6 candidates per second, making a full search of all 336 orderings completely impractical. For one ordering (17,576 positions), expect about 52 minutes. The speed difference comes down to the same factors as the emulator benchmarks: interpreted array lookups and floating-point mod operations versus direct table addressing and two-instruction modulus.
Extra Credit
A few ways to push this further:
-
Parallelize with a second C64: Change two bytes. The outer loop runs
try_lfrom 0 to 7. Machine 1 searches 0-3 (cmp #4), machine 2 searches 4-7 (start withlda #4, end withcmp #8). Each gets 168 orderings instead of 336. No serial cables, no shared memory. Just assemble two copies with different constants and run them. Whoever prints “FOUND!” first wins. The real Bletchley Park worked the same way: each Bombe got a different slice of the search space. A VIC-20 works too since the program is only 2 KB and CHROUT is$ffd2on all Commodore 8-bits. The only change is the border color trick: swapinc $d020forinc $900for just remove it. -
Inline mod26: The
rotor_passsubroutine callsmod26twice viajsr. Inlining the comparison-and-subtract saves 12 cycles per rotor pass, 72 per character, potentially shaving a few minutes off the search. -
Optimize the crib test: Instead of calling the full
encryptroutine with its sixjsr set_fwd/set_invcalls, pre-load the table pointers once per rotor ordering and use a stripped-down encrypt that skips the pointer setup. Fewer subroutine calls, tighter inner loop.
What’s Next
We cracked Enigma without a plugboard and without ring settings. That’s a search space of nearly six million. Next up: adding ring settings to the emulator and watching the search space explode to over 100 billion. The brute-force approach hits a wall, and we’ll need to start thinking about smarter attacks. That’s where Turing’s Bombe comes in.