Tempest's Copy Protection: The Authoritative Guide
The six verifier/consumer pairs, their inputs, gates, and payloads
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:
ZATC4V's EOR checksum over the eleven guarded bytes at$AACE— ten bytes containing twoLDX/JSRpairs plus the first opcode byte of the following instruction — produces$00when initialized with seed$A7.ZATLIV's ADC checksum over the seventeen-byte range at$D575— the copyright record's X-coordinate and 15 character codes plus the followingCREDITSrecord's X-coordinate at$D585— produces$00when initialized with seed$85.
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 $AEE317-byte record-spanning range at ZATLIS $D575, seed $85 |
$B5 |
ZQATLI $B557Gate: wave ≥ $0A (level 11)Payload: pin frame timer $53 = $7A; hang, watchdog reset |
QT2 — copyright-drawing calls in ROM |
ZATC4V $A91C11-byte span at ZATC4S $AACE: two call pairs plus the next opcode, seed $A7 |
$016C |
ZQAT4C $C8F5Gate: wave > $13 (level 21)Payload: SED — decimal mode, never cleared |
QT3 — drawn-copyright SBC checksum |
ZATVG2 $B1DF40 live vector-list bytes via ($B6),Y, seed $0E |
$0455 |
ZQVAVG $A581Gate: score ≥ 180,000 Payload: INC $00,X, X = score's last two digits |
QT4 — POKEY RNG stop-command compliance |
ZPOKST $CD95Whether both POKEY RNGs hold steady after SKCTL := 0 |
$0720 |
ZQPOKS $B7D6Gate: wave ≥ $0D (level 14)Payload: STA $01FF — corrupt stack top |
QT5 — POKEY RNG timing relationship |
ZPONTS $AE20Paired POKEY RNG reads under fixed instruction timing |
$011F |
ZQPONS $C5B1Gate: score ≥ 150,000 Payload: INC $0200,X |
QT6 — drawn-copyright ADC checksum |
ZATVG1 $B27DThe 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 ≥ $15 — 150,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.