Cholo GMA87

From Software Archive
Revision as of 00:18, 25 August 2026 by Enigma (talk | contribs) (Created page with "= Cholo (C64) — GMA87 copy protection, full technical write-up = This article documents, in full technical detail, the copy protection scheme used by the Commodore 64 relea...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to navigation Jump to search

Cholo (C64) — GMA87 copy protection, full technical write-up

This article documents, in full technical detail, the copy protection scheme used by the Commodore 64 release of Cholo (1986/87). Cholo carries a GMA87 disk label — named after a credit string embedded in the loader itself, "GMA WAS HERE 1986" — and its exact algorithm is shared, byte-for-byte in structure, by at least twenty other C64 titles linking to the GMA87 wiki page (full list at the end of this article). Every address below is Cholo's own loader build (gm1.prg at $C000) unless noted.

Summary

  • The protection does not simply check-and-refuse. It reads a physically
 unclonable property of the original disk — sync length variation in a
 deliberately malformed sync region on track 38 — folds it into a
 single byte, and uses that byte as an XOR decryption key for essentially
 the entire game (~25.5 KB, $4461$A7FF).
  • A disk copy that doesn't reproduce the exact sync pattern doesn't get
 refused with an error — it decrypts the game into garbage and crashes
 when execution jumps into the decrypted region. There is no
 compare-and-branch to defeat by patching.
  • Measured key for this disk capture: $B1 (177 decimal),
  • This is the "standard" GMA87 mechanism — key measured on the drive, sent
 to the C64 as one byte, decrypted on the C64 in a single in-place pass.
 Compare this to Eagles, that
 moves the decryption step itself onto the drive's own 6502 instead.

Disk layout

  • Tracks 1–35 are a normal CBM DOS layout (verified: standard $08
 header / $07 data blocks, correct track/sector/id checksums
 throughout).
  • Tracks 36–40 are non-standard — outside the range a stock-formatted
 disk ever uses, reachable only by a loader that steps the drive head there
 directly. Decoded, each of these tracks (except track 38's third block)
 contains three near-identical repeating groups:
sync 41 (or 40)
bits 0110100101
bytes <2-byte per-track "salt"> 8e 8d 55 55 ... 55 57
bits 111111

sync 40
gcr 09                      <- non-standard block-ID; not $07 or $08
bytes 59 59 59 59 59 55 ... (mostly $55 filler)
bits 111111
 The actual protection-check works on raw, byte-aligned reads straight off
 the drive's GCR shift register and it only ever looks at track 38.
  • Track 38 additionally has a distinctive tail after its third
 sync/data group: several short blocks with unusual, non-standard sync
 lengths (87 and 136 "1"-bits, vs. the normal ~40), tagged
 gcr 0f, ending in a long run of $aa filler.

Loader chain

b (boot stub, relocates itself, loads GM1) → gm1.prg (loaded to $C000) → gma2.prg (loaded to $C800).

gm1.prg's main entry:

$C000  4C 27 C0    JMP $C027          ; cold entry
$C003  4C 56 C0    JMP $C056          ; (alternate entry, not traced this pass)
...
$C027  A9 00       LDA #$00
$C029  20 90 FF    JSR $FF90          ; SETMSG (suppress KERNAL messages)
$C02C  A9 FF       LDA #$FF
$C02E  8D 29 03    STA $0329

then, continuing into the protection sequence itself:

$C030  ...
$C034  20 FD C2    JSR $C2FD          ; install the GETIN vector hijack (see below)
$C037  20 28 C3    JSR $C328          ; build the $0100 lookup table (see below)
$C03A  A9 02       LDA #$02
$C03C  20 89 C0    JSR $C089          ; (status/border helper, not traced)
$C03F  20 00 C8    JSR $C800          ; upload + execute the drive-side check,
                                       ;   collect the result byte in A
$C042  48          PHA                ; stash the measured key byte
$C043  A9 00       LDA #$00
$C045  8D 24 C0    STA $C024
$C048  A9 0D       LDA #$0D
$C04A  8D 25 C0    STA $C025
$C04D  20 89 C0    JSR $C089
$C050  20 00 0D    JSR $0D00          ; (fast-loader Y/N prompt lives around here)
$C053  4C 03 C1    JMP $C103          ; -> pull the key back and decrypt (see below)

The GETIN vector hijack: the C64-side receiver

$C2FD repurposes the KERNAL GETIN vector ($032A/$032B) to point at $C308, unconditionally — regardless of the separate "use fast loader?" Y/N prompt shown during boot. gma2.prg's upload-and-execute routine ($C800, below) later calls the standard GETIN entry ($FFE4) purely to trigger this hijacked path and collect the drive's answer in A.

$C308  08          PHP
$C309  78          SEI
$C30A  A5 01       LDA $01            ; processor port (memory config)
$C30C  48          PHA                ; save it
$C30D  29 FD       AND #$FD
$C30F  09 05       ORA #$05           ; bank out BASIC ROM, keep I/O visible
$C311  85 01       STA $01
$C313  AD 00 DD    LDA $DD00          ; read CIA2 port A once...
$C316  29 07       AND #$07
$C318  85 44       STA $44            ; ...build the "release" bit-pattern
$C31A  09 08       ORA #$08
$C31C  85 43       STA $43            ; ...and the "request" bit-pattern
$C31E  20 E3 C1    JSR $C1E3          ; do the actual raster-synced handshake+read
$C321  A8          TAY
$C322  68          PLA
$C323  85 01       STA $01            ; restore memory config
$C325  98          TYA
$C326  28          PLP
$C327  60          RTS

The real cycle-accurate handshake lives in the shared subroutine $C1E3:

$C1E3  A5 43       LDA $43
$C1E5  8D 00 DD    STA $DD00          ; write to CIA2 port A (drive-facing)
$C1E8  AD 00 DD    LDA $DD00
$C1EB  10 FB       BPL $C1E8          ; wait for bit 7 - a response from the drive
$C1ED  AD 12 D0    LDA $D012          ; raster line
$C1F0  C9 31       CMP #$31
$C1F2  90 06       BCC $C1FA
$C1F4  29 06       AND #$06
$C1F6  C9 02       CMP #$02
$C1F8  F0 F3       BEQ $C1ED          ; cycle-accurate raster-synced wait
$C1FA  A5 44       LDA $44
$C1FC  8D 00 DD    STA $DD00
$C1FF  EA (x9)     NOP                ; fixed-length timing pad
$C209  AE 00 DD    LDX $DD00          ; read the port value...
$C20C  BD 00 01    LDA $0100,X        ; ...as an INDEX into a lookup table
$C20F  AE 00 DD    LDX $DD00
       ...                            ; (three more reads, three more tables,
                                       ;  OR'd together - structurally identical
                                       ;  to Eagles' $05EB continuation)

This reads CIA2's $DD00 port with cycle-accurate raster-line synchronization and decodes the drive's response into a byte using four 256-entry lookup tables as a nibble/bit decoder. Those tables are fixed, not disk-derived — built once by a plain tiling loop:

$C328  A2 00       LDX #$00
$C32A  A0 00       LDY #$00
$C32C  A9 08       LDA #$08
$C32E  8D 66 C3    STA $C366
$C331  BD 4A C3    LDA $C34A,X        ; fixed 28-byte source table at $C34A
$C334  99 00 01    STA $0100,Y
$C337  C8          INY
$C338  CE 66 C3    DEC $C366
$C33B  D0 F7       BNE $C334          ; each source byte repeated 8 times
$C33D  E8          INX
$C33E  E0 1C       CPX #$1C           ; 28 source bytes total
$C340  90 EA       BCC $C32C

They only gate whether the handshake can complete at all; they don't encode a disk-specific value into the transferred byte.

The drive-side check: uploaded and executed on the 1541's own CPU

gma2.prg, from $C86C, first opens the drive's command channel and sends the "M-" prefix of a standard 1541 DOS M-W command, exactly the same KERNAL sequence documented for Eagles' upload mechanism:

$C86C  A9 00       LDA #$00
$C86E  20 BD FF    JSR $FFBD          ; SETNAM (empty name)
$C871  A9 0F       LDA #$0F           ; A = 15
$C873  A8          TAY                ; Y = 15 (secondary address = command channel)
$C874  A2 08       LDX #$08           ; X = 8 (device 8)
$C876  20 BA FF    JSR $FFBA          ; SETLFS(LFN=15, dev=8, SA=15)
$C879  20 C0 FF    JSR $FFC0          ; OPEN
$C87C  A2 0F       LDX #$0F
$C87E  20 C9 FF    JSR $FFC9          ; CHKOUT(15)
$C881  A9 4D       LDA #$4D / JSR $FFD2   ; CHROUT 'M'
$C886  A9 2D       LDA #$2D / JSR $FFD2   ; CHROUT '-'
$C88B  60          RTS

Immediately following, at $C88C$C93C, is the 177-byte payload of that M-W command — which is, at the same time, the literal source bytes of the drive-side program about to be uploaded (this region disassembles identically to the live drive-RAM copy at $0300 shown below, because it is that copy, still sitting in C64 RAM before transmission):

$C88C  AD 00 1C    LDA $1C00
$C88F  29 9F       AND #$9F
$C891  8D 00 1C    STA $1C00
...                                    ; (identical to drive $0300 below)
$C91A  A9 01       LDA #$01
$C91C  4C 69 F9    JMP $F969
$C91F  A9 26       LDA #$26            ; $26 = 38 decimal - THE TARGET TRACK
$C921  85 06       STA $06
$C923  A9 01       LDA #$01
$C925  85 07       STA $07
$C927  20 18 C1    JSR $C118           ; (ROM call)
$C92A  A9 E0       LDA #$E0
$C92C  85 00       STA $00             ; submit EXECUTE job (buffer 0)
$C92E  A5 00       LDA $00
$C930  30 FC       BMI $C92E           ; wait for job completion
$C932  C9 02       CMP #$02
$C934  90 06       BCC $C93C           ; status < 2 (success) -> RTS normally
$C936  4C AA 03    JMP $03AA           ; status >= 2 (FAILURE) -> jump into the
                                       ;   just-uploaded drive copy's own hang loop
$C939  20 2C C1    JSR $C12C           ; (unreached in the traced path)
$C93C  60          RTS

After the M-W data is sent, gma2.prg issues an M-E $0393 command — the standard 1541 DOS "memory-execute" command — to actually run the freshly-uploaded code on the drive, starting at its job-setup entry point ($0393, see below).

Full annotated disassembly: the uploaded drive routine ($0300$03B0)

; ---- sync-marker search, 90-attempt retry budget ----
$0300  AD 00 1C    LDA $1C00
$0303  29 9F       AND #$9F
$0305  8D 00 1C    STA $1C00
$0308  A0 5A       LDY #$5A            ; Y = 90 - sync-retry budget
$030A  88          DEY                 ; <-- retry entry point
$030B  D0 05       BNE $0312
$030D  A9 02       LDA #$02            ; error $02 = HEADER NOT FOUND
$030F  4C 69 F9    JMP $F969           ; ERRR - retries exhausted, report failure
$0312  2C 00 1C    BIT $1C00
$0315  30 FB       BMI $0312           ; sync-wait poll
$0317  AD 01 1C    LDA $1C01           ; discard first raw byte after sync
$031A  B8          CLV
$031B  A2 04       LDX #$04
$031D  50 FE       BVC $031D           ; CLV/BVC byte-ready wait (classic 1541 trick)
$031F  B8          CLV
$0320  AD 01 1C    LDA $1C01           ; read raw GCR byte
$0323  9D 00 05    STA $0500,X         ; store into $0500-$0504 (5 bytes)
$0326  CA          DEX
$0327  10 F4       BPL $031D
$0329  C9 A9       CMP #$A9            ; last byte read must be $A9
$032B  D0 DD       BNE $030A           ; mismatch -> retry
$032D  AD 04 05    LDA $0504           ; first byte read (2nd overall)
$0330  C9 69       CMP #$69            ; must be $69
$0332  D0 D6       BNE $030A           ; mismatch -> retry
                                        ; signature "69 xx xx xx A9" confirmed

; ---- 10-sample raw pulse-width (flux-jitter) measurement ----
$0334  A0 00       LDY #$00
$0336  2C 00 1C    BIT $1C00
$0339  30 FB       BMI $0336           ; wait for next sync
$033B  A2 00       LDX #$00
$033D  E8          INX                 ; <-- pulse-width measurement loop
$033E  2C 00 1C    BIT $1C00
$0341  10 FA       BPL $033D           ; count iterations (X) while bit7=0
$0343  8A          TXA
$0344  99 00 05    STA $0500,Y         ; store sample[Y]
$0347  C8          INY
$0348  C0 0A       CPY #$0A            ; 10 samples total
$034A  D0 EA       BNE $0336

; ---- fold 10 samples into an 8-bit result via CMP/ROL ----
$034C  A2 02       LDX #$02            ; sample[0] discarded, sample[1] = reference
$034E  BD 00 05    LDA $0500,X
$0351  CD 01 05    CMP $0501           ; compare sample[X] to reference
$0354  2E 0A 05    ROL $050A           ; roll carry (>=ref->1, <ref->0) into result byte
$0357  E8          INX
$0358  E0 0A       CPX #$0A            ; samples[2..9], 8 comparisons -> 8-bit result
$035A  D0 F2       BNE $034E
$035C  AE 0A 05    LDX $050A           ; X = computed 8-bit result: $B1 for this disk

; ---- send the result byte to the C64 via VIA1 $1800, nibble-out ----
$035F  2C 00 18    BIT $1800
$0362  10 FB       BPL $035F           ; wait for IEC bus ready
$0364  A9 10       LDA #$10
$0366  8D 00 18    STA $1800
$0369  2C 00 18    BIT $1800
$036C  30 FB       BMI $0369
$036E  8A          TXA                 ; A = result byte
$036F  4A          LSR A               ; \  send high nibble
$0370  4A          LSR A               ;  |
$0371  4A          LSR A               ;  |
$0372  4A          LSR A               ; /
$0373  8D 00 18    STA $1800
$0376  0A          ASL A
$0377  29 0F       AND #$0F
$0379  8D 00 18    STA $1800
$037C  8A          TXA
$037D  29 0F       AND #$0F            ; \  send low nibble
$037F  8D 00 18    STA $1800           ;  |
$0382  0A          ASL A               ;  |
$0383  29 0F       AND #$0F            ;  |
$0385  8D 00 18    STA $1800           ; /
$0388  A9 0F       LDA #$0F
$038A  EA          NOP
$038B  8D 00 18    STA $1800           ; final bus state
$038E  A9 01       LDA #$01            ; status $01 = SUCCESS
$0390  4C 69 F9    JMP $F969           ; ERRR (used generically as "set status, return")

; ---- job setup: target track 38, submit EXECUTE job, check result (M-E entry point) ----
$0393  A9 26       LDA #$26            ; $26 = 38 decimal - THE TARGET TRACK
$0395  85 06       STA $06
$0397  A9 01       LDA #$01
$0399  85 07       STA $07
$039B  20 18 C1    JSR $C118           ; (ROM call)
$039E  A9 E0       LDA #$E0
$03A0  85 00       STA $00             ; submit EXECUTE job (buffer 0)
$03A2  A5 00       LDA $00
$03A4  30 FC       BMI $03A2           ; wait for job completion
$03A6  C9 02       CMP #$02
$03A8  90 06       BCC $03B0           ; status < 2 (success) -> RTS normally
$03AA  4C AA 03    JMP $03AA           ; status >= 2 (FAILURE) -> INFINITE SELF-LOOP
$03AD  20 2C C1    JSR $C12C           ; (unreached in the traced path)
$03B0  60          RTS

This is instruction-for-instruction identical in structure to the track-39 routine independently found and documented for Eagles, down to the exact same offsets within the block — only the target track differs ($26/38 here vs. Eagles' $27/39). Both failure modes are deliberate, permanent hangs, never wrong output: a marker mismatch retries up to 90 times before giving up with error $02; a failed job status falls into an infinite self-loop at $03AA.

Measured result byte for this disk capture: $B1 (177 decimal).

Decryption

Back on the C64, the measured byte returned in A from JSR $C800 is stashed ($C042: PHA), carried through the fast-loader prompt handling, and pulled back at $C103:

$C103  68          PLA
$C104  AA          TAX                ; X = the measured key byte ($B1 for this disk)
$C105  20 DC C0    JSR $C0DC          ; run the decrypt loop
$C108  20 6A C0    JSR $C06A          ; (post-decrypt cleanup, not traced this pass)
$C10B  4C 61 44    JMP $4461          ; jump straight into the freshly-decrypted code

The decrypt loop itself is a single repeating-byte XOR key applied to essentially the entire game, via a self-modifying pair of operands that walk upward through memory:

$C0DC  A9 FE       ... (memory-config setup, banks out BASIC ROM)
$C0E1  85 01       STA $01
$C0E3  8A          TXA
$C0E4  4D 61 44    EOR $4461          ; self-modifying operand, walks $4461 -> ~$A7FF
$C0E7  8D 61 44    STA $4461
$C0EA  EE E5 C0    INC $C0E5          ; bump low byte of the EOR operand
$C0ED  EE E8 C0    INC $C0E8          ; bump low byte of the STA operand
$C0F0  D0 F1       BNE $C0E3
$C0F2  EE E6 C0    INC $C0E6          ; bump high byte
$C0F5  EE E9 C0    INC $C0E9
$C0F8  AD E9 C0    LDA $C0E9
$C0FB  C9 A8       CMP #$A8
$C0FD  D0 E4       BNE $C0E3
$C0FF  68          PLA
$C100  85 01       STA $01            ; restore memory config
$C102  60          RTS

Decrypting ~25.5 KB ($4461$A7FF) in place, then jumping directly into it. No comparison against a stored "correct" value ever happens — a wrong key just produces garbage code and the machine crashes on JMP $4461.

Other games using this identical algorithm

The following titles, all linking to the GMA87 wiki page, were independently verified to use the exact same scheme documented above: measurement on track 38, drive-side upload targeting $C800 on the C64 / $0300 on the drive, and a single repeating-byte C64-side XOR decrypt. Only the surrounding loader's own memory layout (and, of course, each disk's measured key) differs between them.

  • Triaxos — confirmed identical algorithm and fixed drive-upload target;
 only the loader's memory layout differs (gm1.prg at
 $1000 instead of $C000, decrypted range
 $6400$CFFF)
 differing keys)
 release, listed separately under its German title on the wiki)
 key value on this game's own wiki page)
 Airwolf 2, same key

Not on this list: Eagles, which carries the same disk label and measures its signature the same way, but moves the actual decryption step onto the drive's own CPU instead of sending the key to the C64 — see the dedicated Eagles article for the full mechanism.

See also