Tempest's Copy Protection: The Authoritative Guide

The six verifier/consumer pairs, their inputs, gates, and payloads

Subject: Atari Tempest (1981), Dave Theurer. Analysis performed against revision 3 (tempest_rev3.bin) with revision 1 cross-checks (tempest1/).
Method: Direct disassembly and byte-level arithmetic verification. Every static ROM checksum in this document was recomputed against the shipping ROM image; checksums over runtime-generated data are identified separately.
Companion document: tempest_rev1_cheat_analysis_v4.html, which covers the revision 1 high-score cheat — one consequence of the system described here.
Status: All six verifier/consumer pairs located and confirmed against the program code and ROM image.

Executive summary

Tempest carries six independent anti-piracy traps. Each is built from the same two-part pattern: a verifier that computes a checksum and stores the result in a flag byte, and a consumer, physically distant in the code and running much later, that sabotages the game only when its flag is nonzero and the player has passed a progress threshold. The split is deliberate camouflage — no single moment of inspection shows cause and effect, and the failures surface weeks after a counterfeiter's bench test passes clean.

The verifiers check three classes of thing: the copyright as drawn on screen (two of them, checksumming the generated vector list), ROM ranges associated with the copyright (two of them — one spanning the copyright record and the next record's X-coordinate, one covering two drawing calls plus the following opcode), and the POKEY sound chips (two of them, probing the hardware random-number generators).

Every checksum has a seed or fixup chosen so that its expected input produces zero. For the two static ROM checksums, this can be verified directly against the shipping image. That is the whole trick, and it is what makes the system both elegant and — for anyone patching Tempest today — a live hazard: an edit inside a guarded ROM region must be paid for by re-deriving the corresponding constant.

Five details are especially important to understanding the mechanism. ZATLIV computes a checksum over a record-spanning range beginning with the copyright literal text data at $D575. The POKEY reset-compliance check flags generators that keep running when commanded to stop; generators that hold steady pass. The unpaired SED instruction is a delayed sabotage payload, not part of the score-ending mechanism. The byte $B4 is an output-latch shadow rather than a seventh protection flag. Finally, the two score gates use packed BCD and activate at 150,000 and 180,000 points.

1. A note on naming

This guide treats a complete verifier → flag → consumer chain as one protection pair. Atari's source names the six flag bytes QT1 through QT6 — declared as SECURITY bytes in ALCOMN — so this guide uses those flag names as stable pair identifiers. A verifier's source label is not, by itself, the name of the whole trap.

Term used here Meaning Example from the QT2 pair
Pair (QTn) The complete chain, identified by the flag that connects its two routines QT2
Verifier The routine that examines ROM, runtime output, or POKEY behavior and writes a result ZATC4V at $A91C
Flag The one-byte result: zero is accepted; nonzero arms the consumer QT2 at $016C
Consumer The later routine that tests the flag and a progress gate, then runs the sabotage payload ZQAT4C at $C8F5
Checked input The bytes or hardware behavior examined by the verifier; static ROM inputs may have their own source labels ZATC4S at $AACE

The verifier and consumer routines carry Z-prefixed source labels, paired by construction: ZATLIV/ZQATLI, ZATC4V/ZQAT4C, and so on. Section 3 applies this vocabulary in the single complete map of all six pairs; its first column combines each QT identifier with a plain-English description of what that pair checks.

Not every security-looking source label denotes another pair. ZATC1S ($B19C), ZATC2S ($AF93), and ZATC3S ($AE06) look like siblings of the checked region ZATC4S, but no shipped verifier computes a checksum over them; they are source-only artifacts. ZSECL0 ($AB26) is not a verifier either — it records where the copyright vectors were written so the two drawn-vector verifiers can find them.

2. Evidence base and method

Everything below was read from tempest_rev3_annotated.asm and confirmed against the raw image. Where a checksum is described as producing zero, that was not inferred from the code's shape — it was computed over the actual bytes. Two examples, both reproduced in §4:

Revision cross-checks were done by searching each revision's chips for the twelve-byte signature of the decimal-mode trap. It appears in revision 1 at 136002-120.mn1 offset $F5 (chip base $C800, so CPU $C8F5) and at the identical CPU address in tempest_rev3.bin. The chips Atari re-cut for revisions 2 and 3 — 136002-217.j1, 136002-222.r1, 136002-316.h1 — do not cover $C800, which is the mechanical proof that the trap was never revised.

Claims that could not be settled statically are flagged as such rather than smoothed over. The two vector-list checksums (§5.3) operate on runtime-generated data, so their outcome on a modified ROM cannot be computed from the image alone; §8 gives the debugger recipe instead.

3. The complete map

The table below is the sole six-pair map. Read each row from left to right: the pair's plain-English purpose; the verifier and what it checks; the flag connecting the two routines; then the consumer, its progress gate, and its sabotage payload.

Pair and purpose Verifier and checked input Flag address Consumer, gate, and payload
QT1 — copyright record in ROM ZATLIV $AEE3
17-byte record-spanning range at ZATLIS $D575, seed $85
$B5 ZQATLI $B557
Gate: wave ≥ $0A (level 11)
Payload: pin frame timer $53 = $7A; hang, watchdog reset
QT2 — copyright-drawing calls in ROM ZATC4V $A91C
11-byte span at ZATC4S $AACE: two call pairs plus the next opcode, seed $A7
$016C ZQAT4C $C8F5
Gate: wave > $13 (level 21)
Payload: SED — decimal mode, never cleared
QT3 — drawn-copyright SBC checksum ZATVG2 $B1DF
40 live vector-list bytes via ($B6),Y, seed $0E
$0455 ZQVAVG $A581
Gate: score ≥ 180,000
Payload: INC $00,X, X = score's last two digits
QT4 — POKEY RNG stop-command compliance ZPOKST $CD95
Whether both POKEY RNGs hold steady after SKCTL := 0
$0720 ZQPOKS $B7D6
Gate: wave ≥ $0D (level 14)
Payload: STA $01FF — corrupt stack top
QT5 — POKEY RNG timing relationship ZPONTS $AE20
Paired POKEY RNG reads under fixed instruction timing
$011F ZQPONS $C5B1
Gate: score ≥ 150,000
Payload: INC $0200,X
QT6 — drawn-copyright ADC checksum ZATVG1 $B27D
The same 40 live vector-list bytes, seed $F2
$011B ZQVAVG $A581 (shared with QT3)
Gate: score ≥ 180,000
Payload: same as QT3

Note the escalation in the trigger design. Nothing fires early. The earliest gate is 150,000 points; the wave gates start at displayed level 11. A bootlegger who powers a board up, plays four levels, and ships it sees a perfectly healthy game. The failures arrive on location, weeks later, scattered across a route, and every one of them is engineered to look like ordinary hardware flakiness rather than a deliberate response.

Note also that ZATVG2 and ZATVG1 share a consumer. ZQVAVG ORs the two flags together, so either verifier alone arms the trap — redundancy against a cloner who finds and neutralizes one.

There is no seventh pair. The byte $B4, which sits next to $B5 and is easy to mistake for one, is an output-latch shadow: it is written at $CA5F and $D999, read at $D763, and assembled with other output bits into the $4000 hardware latch at $D777 — not a protection flag.

4. Anatomy of a verifier: the seed-to-zero checksum

Every ROM-content check in Tempest uses the same idiom. Load a seed constant, compute a checksum by applying the same operation to each guarded byte, then store the result. The seed is chosen at build time so that authentic bytes produce exactly zero.

ZATC4V, the shortest and clearest instance:

A91C  A0 0A      LDY #$0A       ; 11 bytes, Y = 10 down to 0
A91E  A9 A7      LDA #$A7       ; the seed
A920  59 CE AA   EOR $AACE,Y    ; checksum ZATC4S
A923  88         DEY
A924  10 FA      BPL $A920
A926  8D 6C 01   STA $016C      ; zero = authentic

The eleven guarded bytes are A2 2C 20 14 AB A2 2E 20 14 AB A5. The first ten bytes disassemble to LDX #$2C / JSR $AB14 / LDX #$2E / JSR $AB14, the two calls that put the copyright on the credits screen. The eleventh byte, $A5 at $AAD8, is the opcode of the following LDA $06. Applying EOR to all eleven bytes, starting with $A7, yields $00, verified against the image.

Read what is actually being defended. ZATC4V does not guard the copyright message; it principally guards the instructions that invoke the drawing of it, with its eleventh byte extending to the following LDA $06 opcode. A bootlegger's most natural edit — replacing those JSRs with NOPs so Atari's name never appears — changes this interval and arms the trap. Editing the message text leaves it untouched.

The copyright-text checker, ZATLIV, by contrast guards the data and not the code:

AEE3  18         CLC
AEE4  A0 10      LDY #$10       ; 17 bytes
AEE6  A9 85      LDA #$85       ; the seed
AEE8  79 75 D5   ADC $D575,Y    ; checksum ZATLIS
AEEB  88         DEY
AEEC  10 FA      BPL $AEE8
AEEE  85 B5      STA $B5

This checksum uses ADC with carry propagation over $D575$D585. The copyright record itself occupies $D575$D584: its X-coordinate followed by 15 vector-alphabet character codes spelling © MCMLXXX ATARI. The seventeenth checksummed byte, $D585 = $A0, is the X-coordinate of the following CREDITS record. Starting with seed $85, the full record-spanning range produces $00.

Together these two are the complete ROM-side copyright defense, and they are complementary by design. Erase the text and ZATLIV fires. Erase the calls that draw it and ZATC4V fires. Neither check can be satisfied by defeating the other, and each arms a payload with a different signature — a watchdog reset at level 11 versus silent arithmetic corruption from level 21.

The practical corollary for anyone modifying Tempest: the seed is the price of admission. A patch that rewrites the copyright text is legitimate and safe provided it re-derives the immediate seed operand at $AEE7 so the checksum over the new bytes still produces zero. A patch record may reasonably write the complete two-byte LDA #imm instruction at $AEE6$AEE7, but $AEE6 itself is the $A9 opcode, not the seed. That is not a workaround or a defeat of the protection; it is paying the check in its own currency.

5. The six checks in detail

5.1 The two ROM-content checks

Both were dissected in §4. What matters here is their reach, because that determines which edits are safe.

ZATC4V checksums $AACE$AAD8 inclusive: eleven bytes, no more. That interval contains the ten-byte invocation sequence and the $A5 opcode beginning the following LDA $06, so its blast radius extends one byte beyond the two complete call pairs.

The literal check, ZATLIV, checksums $D575$D585 inclusive: seventeen bytes. It covers the copyright record's X-coordinate and 15 character codes through $D584, then extends one byte into the following CREDITS record at $D585. Anything that changes the copyright's position or letters lands inside, but so does a change to that next record's X-coordinate.

Neither check touches the color or size byte for the message, which lives separately in the MSGLBS table at $D121 + msg#×2. Recoloring the copyright is therefore free — no checksum notices.

5.2 The two POKEY checks

Atari's POKEY carries a 17-bit linear-feedback shift register whose output appears at register offset $0A. Tempest has two POKEYs, at $60C0 and $60D0, so the random registers are $60CA and $60DA and the control registers (SKCTL) are $60CF and $60DF. Both checks interrogate this generator, but they ask opposite questions.

The reset-compliance check, ZPOKST, at $CD95:

CD95  A9 00      LDA #$00
CD97  8D CF 60   STA $60CF      ; POKEY1 SKCTL = 0 -> hold the LFSR
CD9A  8D DF 60   STA $60DF      ; POKEY2 SKCTL = 0
CD9D  8D 20 07   STA $0720      ; flag = 0
CDA0  A2 04      LDX #$04       ; five attempts
CDA2  AD CA 60   LDA $60CA      ; snapshot both generators
CDA5  AC DA 60   LDY $60DA
CDA8  CD CA 60   CMP $60CA      ; loop: re-read, still the same?
CDAB  D0 03      BNE $CDB0
CDAD  CC DA 60   CPY $60DA
CDB0  F0 05      BEQ $CDB7      ; both unchanged -> healthy, keep looping
CDB2  8D 20 07   STA $0720      ; either one moved -> arm the flag
CDB5  A2 00      LDX #$00       ; and bail out of the loop
CDB7  CA         DEX
CDB8  10 EE      BPL $CDA8

Writing zero to SKCTL puts POKEY into its initialization state, which halts the polynomial counters. A genuine chip therefore returns the same RANDOM byte on every re-read, the comparisons all pass, and the flag stays clear. The trap arms when a generator keeps advancing after being told to stop.

This is a compliance test, not a liveness test. A dead or silent device that returns stable values passes it; an absent device on a physically floating bus is not guaranteed to do so. A substitute part, a discrete-logic clone of POKEY, or an emulator that models RANDOM as free-running noise while ignoring SKCTL fails the check.

One latent weakness is worth recording. The value written to the flag at $CDB2 is whatever is in the accumulator — the POKEY1 RANDOM byte snapshotted at $CDA2. If that byte happened to be $00, the "arm" write stores zero, which reads as authentic. If that snapshot is uniformly distributed and independent of the failure, the escape probability is roughly one in 256; it is not an unconditional hardware rate. Theurer's other verifiers all store a computed checksum and cannot misfire this way; this one is the exception.

The paired-read hash, ZPONTS, at $AE20:

AE20  AD CA 60   LDA $60CA      ; POKEY1, read 1
AE23  AC CA 60   LDY $60CA      ; POKEY1, read 2
AE26  84 29      STY $29
AE28  4A 4A 4A 4A  LSR A x4     ; read 1's high nibble -> low
AE2C  45 29      EOR $29
AE2E  85 29      STA $29
AE30  AD DA 60   LDA $60DA      ; POKEY2, read 1
AE33  AC DA 60   LDY $60DA      ; POKEY2, read 2
AE36  58         CLI            ; interrupts back on
AE37  45 29      EOR $29
AE39  29 F0      AND #$F0
AE3B  45 29      EOR $29
AE3D  85 29      STA $29
AE3F  98         TYA
AE40  0A 0A 0A 0A  ASL A x4     ; read 2's low nibble -> high
AE44  45 29      EOR $29
AE46  8D 1F 01   STA $011F

The CLI at $AE36 is the tell: interrupts were masked before the sequence began. With no interrupt able to intervene, the gap between each pair of reads is fixed by instruction timing alone, so the LFSR advances a known number of steps between them. The relationship between read 1 and read 2 is therefore predictable on genuine silicon, and the nibble-shifting EOR lattice is constructed so that the predictable part cancels and the result lands on zero.

This is the subtlest verifier in the game. It does not check that the generator exists, or that it moves, but that it moves at the right rate, in a manner no substitute could reproduce without duplicating POKEY's exact polynomial and clock. Note what it costs Theurer: the entire hash must run with interrupts off, so he paid real latency in the main loop for it.

5.3 The two drawn-vector checks

These are the cleverest, and the only ones that inspect output rather than stored bytes. The setup is a hook inside the message routine:

AB26  E0 2C      CPX #$2C       ; is this message #$2C, the copyright?
AB28  D0 08      BNE $AB32
AB2A  A5 74      LDA $74        ; VGLIST - where the vectors are being written
AB2C  85 B6      STA $B6
AB2E  A5 75      LDA $75
AB30  85 B7      STA $B7

When and only when the copyright is the message being drawn, ZSECL0 stashes the current vector-list write pointer into $B6/$B7. Later, two verifiers — ZATVG2 and, in attract mode, ZATVG1 — compute checksums over forty bytes of that generated list:

B1DF  A0 27      LDY #$27       ; 40 bytes
B1E1  A9 0E      LDA #$0E       ; seed
B1E3  38         SEC
B1E4  F1 B6      SBC ($B6),Y    ; subtractive checksum of the drawn vectors
B1E6  88         DEY
B1E7  10 FB      BPL $B1E4
B1E9  A8         TAY
B1EA  F0 02      BEQ $B1EE
...
B1F0  49 29      EOR #$29
B1F2  8D 55 04   STA $0455      ; QT3 = $0455 (ZATVG2)
B27D  A9 F2      LDA #$F2       ; seed
B27F  18         CLC
B280  A0 27      LDY #$27       ; the same 40 bytes
B282  71 B6      ADC ($B6),Y    ; additive checksum
B284  88         DEY
B285  10 FB      BPL $B282
B287  8D 1B 01   STA $011B      ; QT6 = $011B (ZATVG1)

One region, two independent checksum calculations — one ordered, borrow-propagating SBC chain and one carry-propagating ADC chain, with different seeds. The SBC result is not $0E minus an ordinary byte sum because each intermediate borrow affects the next subtraction. Finding a text substitution that preserves both checksum results simultaneously is a genuinely hard constraint, which is the point.

What these checks defend is qualitatively different from the ROM-content pair. They do not care what is in ROM; they care what the vector generator was actually told to draw. A cloner who leaves the ROM alone but intercepts the display to suppress the copyright still fails. This is the deepest layer of the system.

It is also where Atari's field-diagnosed bug lived. The byte at $B1F1 — the operand of the EOR #$29 fixup — is $2A in revision 1 and $29 in revision 2. Static analysis proves that this changes the third accepted final SBC accumulator state from $CF to $CC; Atari's fix and the reported genuine-board behavior identify the revision 1 value as the misfire behind the famous high-score cheat. The actual 40 input bytes and their checksum result exist only at runtime, so that historical outcome cannot be re-derived from the ROM image alone.

These two checksums cannot be evaluated from a ROM image. Their input is generated at runtime by the vector pipeline, so any claim about whether a modified copyright arms them must come from a debugger, not from arithmetic. §8 gives the procedure.

6. The consumers: camouflage by placement

Each payload is buried inside a routine with an unrelated job, and each was chosen to imitate a different, plausible hardware fault. That pairing is the design's real sophistication: the symptom misdirects the technician toward the wrong subsystem.

Consumer Lives inside Payload Fault it imitates
ZQATLI $B557 DISPLAY-NYMPHS Pin frame timer $53 = $7A A hung display board or bad sync
ZQPOKS $B7D6 A drawing loop STA $01FF into the stack page Failing RAM or a marginal power rail
ZQAT4C $C8F5 The slam-switch service path SED Nothing recognizable — creeping arithmetic drift
ZQVAVG $A581 PLAY - ANALYZE GAME INC $00,X Random RAM corruption
ZQPONS $C5B1 DISPLAY-PLANES OF STARS INC $0200,X The same, in a different region

The stack-write payload deserves particular note for its cruelty. $01FF is the top of the 6502 stack, where return addresses live. The write only does damage when the stack happens to be at that depth, so the crash is probabilistic — the machine runs for a while, derails at an unpredictable moment, then watchdogs. No two failures look alike. A technician chasing it will replace RAM.

The SED payload is the opposite kind of nasty: it never crashes anything. It sets the decimal flag and leaves it set, so ordinary binary arithmetic downstream silently skips $0A through $0F in every nibble. Counters drift, timers misbehave, and the machine degrades rather than fails. Tempest's legitimate BCD arithmetic — the score and several timers — always uses matched SED/CLD pairs, for example PRORAT at $914E$9156 and UPSCOR at $CA6C. The trap's SED at $C900 is the only one in the game with no partner.

7. The gates

The consumers test two different kinds of counter.

Score gates compare packed BCD. $40/$41/$42 hold the score two decimal digits per byte, $42 being the ten-thousands and hundred-thousands. ZQVAVG does LDA #$17 / CMP $42 / BCS skip, which skips while $17$42 and therefore fires at $42$18 — read as decimal digits, 180,000. ZQPONS does CPX #$15 / BCC skip, firing at $42$15150,000. These constants are packed digit pairs: $18 represents the decimal digits 18, and $15 represents 15.

Wave gates compare binary. The ZQAT4C and ZQPOKS consumers read the internal current-wave counter at $9F (CURWAV), while ZQATLI reads the player-one wave ledger at $46 (WAVEN1). Both counters are zero-based while the display is one-based, so internal wave N is displayed level N+1:

Pair Comparison Fires at internal wave Displayed as
QT1 wave ≥ $0A 10 level 11
QT4 CMP #$0D / BCC → wave ≥ 13 13 level 14
QT2 LDA #$13 / CMP $9F / BCS → wave > 19 20 level 21

Each consumer runs every frame and tests the live counter, so a trap fires on the first frame after the counter steps — and the counter steps during the between-level warp, before the new level is playable. The player therefore finishes the level displayed as 10, the tube-zoom begins, the counter ticks to 10, and the machine dies mid-transition. "It crashes at level 10" and "it crashes at level 11" describe the same event from opposite sides of a transition the game never completes. Both are correct; neither is precise without the internal/displayed distinction.

8. Verification recipes

The two static checksums, reproducible against any image:

b = open('tempest_rev3.bin','rb').read()

# ZATC4V (QT2, $016C): EOR checksum over 11 bytes at $AACE, seed $A7
a = 0xA7
for y in range(10, -1, -1):
    a ^= b[0xAACE + y]
assert a == 0x00                      # authentic

# ZATLIV (QT1, $B5): ADC checksum over 17 bytes at $D575, seed at $AEE7
a, c = b[0xAEE7], 0
for y in range(0x10, -1, -1):
    t = a + b[0xD575 + y] + c
    a, c = t & 0xFF, (t >> 8) & 1
assert a == 0x00                      # authentic

Re-deriving the seed after editing the copyright text uses the same checksum calculation solved for the seed: process the new bytes starting from zero, then choose the seed that makes the final result zero.

The two runtime checksums require a debugger, since their input does not exist in the ROM image. In MAME, let attract mode draw the copyright at least once, then read the flags:

wpset  0455,1,w        ; break when ZATVG2 writes QT3 ($0455)
wpset  011b,1,w        ; and ZATVG1 writes QT6 ($011B)

Nonzero in either means the drawn-vector checksums have detected the modification. Whether that matters depends entirely on whether $A58F still holds A6 40 F6 00.

The remaining flags can be sampled directly once attract mode has cycled: QT2 $016C (ZATC4V), QT5 $011F (ZPONTS), QT4 $0720 (ZPOKST), QT1 $B5 (ZATLIV). On a correctly patched build all four should read $00.

Conclusion

Tempest's protection is better engineering than its reputation suggests. Six pairs, three independent classes of evidence, redundant checks over the same region with different arithmetic, payloads selected to imitate distinct hardware faults, and trigger thresholds tuned so that a counterfeiter's bench test cannot reach them. The verifier/consumer split — separating detection from response by both distance in the code and hours in time — is the part worth admiring most, because it defeats the obvious countermeasure of watching what the program does when you tamper with it.

What brought the system down was not a weakness in the design but the single wrong fixup constant at $B1F1 in ZATVG2's peel-off chain. Atari's revision change and the field record identify it as the value that made the check fire on honest hardware; the runtime vector bytes themselves are not present in the ROM image. Because Theurer had engineered the response to look like random RAM corruption, it took the field — not the lab — to notice that the corruption was steerable. The protection's own camouflage delayed the diagnosis of its own defect.

For anyone working on Tempest today, the operative facts are narrow and worth stating plainly. Two guarded regions exist in ROM: eleven bytes at $AACE and seventeen at $D575. Each is defended by a seed constant that can be re-derived in a few lines of Python. The remaining verifiers consume runtime-generated or hardware data. The system is not an obstacle to legitimate modification — it is a toll, and the toll is cheap once you know where the gates are.