Skip to content

Turning it on

Write $1C to register 57. Then write $1C to register 57 again. That is the whole unlock.

lda #$1C
sta $9C01        ; the value
lda #$B9         ; 57 | $80
sta $9C01        ; the register number
lda #$1C
sta $9C01
lda #$B9
sta $9C01

Nothing may come between the two. Any other register write cancels the sequence and you start over.

Why it looks like that

The TMS9918A has eight registers. When you hand it a register number it looks at the low three bits and throws the rest away, so writing register 57 on a stock chip writes register 1 — the mode register, the one that holds the display enable, the interrupt enable and half the mode bits.

That is the trick. Register 57 is the one register number that is guaranteed to be visible on both chips, and $1C is a value chosen so that no working program would ever put it there. In register 1, $1C means 4 KB of VRAM on a card with 16 KB, display blanked, interrupts off, and both mode bits set at once — an illegal combination. It makes a real 9918A useless. Writing it twice in a row, by accident, is not something that happens.

Why there is a lock at all

The 9918A's datasheet says registers above 7 are reserved, and some software wrote to them anyway. It never mattered, because the numbers were masked down to 0–7 and landed harmlessly.

Then the F18A gave those numbers meanings. Software that had been scribbling into nowhere for thirty years started scribbling into a bitmap layer. So the card powers up locked, behaving exactly like the chip it replaces, and the only way past that is a sequence no legacy program could produce.

The damage

Those two writes went into register 1 on a card without F18A mode. The screen is now blank. Put register 1 back immediately, whether or not the unlock worked:

lda #$D0         ; 16K, display on, interrupt off, text mode
sta $9C01
lda #$81         ; 1 | $80
sta $9C01

$D0 is what the machine's own text mode uses. Restore it and a stock card never knew anything happened.

This pattern — do the enhanced thing, then repair what it did to a card that did not understand it — runs through the whole detection procedure. Two more registers need the same treatment in a moment.

Asking whether it worked

You cannot tell from the unlock itself. It writes and returns; there is nothing to read back. The test everybody uses instead is to ask the card to run a program, because only one of the two cards has anything to run it with.

Six bytes of TMS9900 machine code:

$3F00   04E0 3F00    CLR  @>3F00     ; erase this instruction
$3F04   0340         IDLE            ; and stop

Put those six bytes in VRAM at $3F00. Point the card's GPU at $3F00. Read $3F00 back.

If a GPU ran, the first byte is $00 — the program deleted its own opcode. If nothing ran, it is still $04, the top half of the CLR instruction, exactly as you wrote it.

It is a lovely test: self-modifying code whose only output is its own absence, and it works because a stock card has no way to fake it.

Where $3F00 came from

It is 256 bytes below the top of VRAM, which no display mode uses and no Kernal routine touches. Any spare address does; this is the one everybody's code uses, so it is the one to recognize.

Starting the GPU

Two registers hold the address to run from. Register 54 is the high byte, register 55 the low byte — and writing register 55 starts it. There is no separate go button. Set the high byte first, always.

lda #$3F
ldx #54
jsr SetVdpReg     ; high byte

lda #$00
ldx #55
jsr SetVdpReg     ; low byte — and away it goes

On a stock card those two are registers 6 and 7: the sprite pattern table address and the screen colors. So a failed probe has now also moved your sprite patterns and changed the colors of the screen. Put those back too.

Which card is it

The probe tells you a GPU exists. Status register 1 tells you whose.

Reading it is a two-step, because a 9918A has one status port and the F18A has sixteen status registers behind it. Write the number you want into register 15, read the port, then write 0 back into register 15.

That last step is not optional. The machine's interrupt handling reads the status port to find out what happened; leave the selection pointing at register 1 and it reads the wrong byte every frame.

ValueWhat it is
$E0a real F18A
$E8an F18A personality running on something else — on an ACE, the Pico9918

Bits 7–5 are the identity field, and 111 means F18A. Bit 3 is set when the F18A behavior is coming from something that is not F18A silicon — which on an ACE it is, because an ACE has a Pico9918. Both are F18A mode. The difference matters for a handful of features, and each chapter says which.

All of it together

asm
.setcpu "65C02"

.include "6502.inc"

.segment "CODE"

; =============================================================================
;   BASIC Startup Stub
; =============================================================================
;   A tokenized BASIC line: 10 SYS 2060
;   When this program is loaded into $0800 and RUN in BASIC, the SYS command
;   jumps to the machine code entry point at $080C (decimal 2060).
;   This stub must remain at the very start of the program.

BasicStartup: .byte $0A, $08, $0A, $00, $A5, $32, $30, $36, $30, $00, $00, $00

; =============================================================================
;   What kind of video card is this? ($080C)
; =============================================================================
;   Unlocks F18A mode, asks the card to prove it has a GPU, and prints what it
;   found.  Every F18A program starts with some version of this, because every
;   step of it does damage on a card that turns out not to be an F18A.
;
;   THE PROBE
;   ---------
;   Six bytes of TMS9900 machine code go into VRAM at $3F00:
;
;       $3F00   04E0 3F00   CLR  @>3F00      ; erase this instruction
;       $3F04   0340        IDLE             ; and stop
;
;   Writing register 55 hands that address to the card's GPU and starts it.
;   If a GPU ran, the first byte of the program is $00 -- it deleted itself.
;   If nothing ran, it is still $04, the top byte of the CLR opcode.
;
;   THE DAMAGE
;   ----------
;   A card that only has eight registers looks at the low three bits of a
;   register number and ignores the rest.  So on a stock TMS9918A:
;
;       register 57  ->  register 1   the two unlock writes blank the screen
;       register 54  ->  register 6   sprite pattern table address
;       register 55  ->  register 7   screen colors
;
;   All three are put back below before anything is printed.  Skip that and a
;   detection routine leaves a blank screen behind on exactly the machines it
;   was supposed to leave alone.
; =============================================================================

; --- VDP registers, by number ---
VDP_R1          = 1                 ; Mode control 2 (blanking lives here)
VDP_R6          = 6                 ; Sprite pattern table base
VDP_R7          = 7                 ; Screen colors
VDP_R15         = 15                ; Status register select
VDP_R54         = 54                ; GPU program counter, high byte
VDP_R55         = 55                ; GPU program counter, low byte -- triggers
VDP_R57         = 57                ; Unlock

F18A_UNLOCK     = $1C               ; Written to register 57, twice

; --- The three values the BIOS sets up text mode with ---
R1_TEXT         = $D0               ; 16K VRAM, display on, interrupt off, text
R6_TEXT         = $00               ; Sprite patterns at VRAM $0000
R7_TEXT         = (TMS_BLACK * 16) | TMS_WHITE

; --- The probe ---
PROBE_ADDR      = $3F00             ; High in VRAM, out of the way of text mode
PROBE_LEN       = 6

; --- Identity, from status register 1 ---
ID_MASK         = %11100000         ; Bits 7-5: 111 says F18A
ID_F18A         = %11100000
ID_EMULATED     = %00001000         ; Bit 3: an F18A personality, not the silicon

; =============================================================================
;   Start
; =============================================================================

Start:
  lda HW_PRESENT
  and #HW_VID                       ; No video card at all?
  beq NoCard

  sei                               ; Every access below is a *pair* of writes
                                    ; to one address. Anything that gets in
                                    ; between leaves the card half-told.

  jsr Unlock
  jsr LoadProbe
  jsr RunProbe
  jsr ReadProbe                      ; A = $00 if a GPU erased it
  pha

  jsr RestoreRegisters               ; Undo the damage, whatever the answer

  pla
  cmp #$00
  bne Stock                          ; Nothing ran the program -- stock card

  jsr ReadIdentity                   ; A = status register 1
  tax
  and #ID_MASK
  cmp #ID_F18A
  bne Stock                          ; A GPU but no F18A identity: not ours

  txa
  and #ID_EMULATED
  beq RealF18A

  cli
  lda #<PicoMsg
  ldy #>PicoMsg
  bra Report

RealF18A:
  cli
  lda #<F18AMsg
  ldy #>F18AMsg
  bra Report

Stock:
  cli
  lda #<StockMsg
  ldy #>StockMsg
  bra Report

NoCard:
  lda #<NoCardMsg
  ldy #>NoCardMsg

Report:
  jsr PrintStr
  jsr PrintCRLF
  rts

; =============================================================================
;   Unlock — write $1C to register 57, twice, with nothing in between
; =============================================================================

Unlock:
  lda #F18A_UNLOCK
  ldx #VDP_R57
  jsr SetVdpReg
  lda #F18A_UNLOCK
  ldx #VDP_R57
  jmp SetVdpReg

; =============================================================================
;   LoadProbe — six bytes of TMS9900 code into VRAM at $3F00
; =============================================================================

LoadProbe:
  lda #<PROBE_ADDR
  ldx #>PROBE_ADDR
  jsr SetVramWrite
  ldx #0
@Loop:
  lda Probe,x
  sta VC_DATA
  inx
  cpx #PROBE_LEN
  bne @Loop
  rts

; =============================================================================
;   RunProbe — point the GPU at $3F00 and start it
; =============================================================================
;   Register 54 takes the high byte.  Writing register 55 loads the low byte
;   *and* starts the GPU, so it goes last.

RunProbe:
  lda #>PROBE_ADDR
  ldx #VDP_R54
  jsr SetVdpReg
  lda #<PROBE_ADDR
  ldx #VDP_R55
  jmp SetVdpReg

; =============================================================================
;   ReadProbe — read back the first byte of the program
; =============================================================================

ReadProbe:
  lda #<PROBE_ADDR
  ldx #>PROBE_ADDR
  jsr SetVramRead
  lda VC_DATA
  rts

; =============================================================================
;   ReadIdentity — status register 1, then put the selection back
; =============================================================================
;   Leaving the selection anywhere but 0 breaks the machine: the Kernal's
;   interrupt handler reads the status port to acknowledge the frame interrupt,
;   and any register but 0 acknowledges nothing.

ReadIdentity:
  lda #1
  ldx #VDP_R15
  jsr SetVdpReg
  lda VC_STATUS
  pha
  lda #0
  ldx #VDP_R15
  jsr SetVdpReg
  pla
  rts

; =============================================================================
;   RestoreRegisters — put back what a stock card just had done to it
; =============================================================================

RestoreRegisters:
  lda #R6_TEXT
  ldx #VDP_R6
  jsr SetVdpReg
  lda #R7_TEXT
  ldx #VDP_R7
  jsr SetVdpReg
  lda #R1_TEXT
  ldx #VDP_R1
  jmp SetVdpReg

; =============================================================================
;   Talking to the card
; =============================================================================
;   A register write is the value, then the register number with bit 7 set.
;   A VRAM address is the low byte, then the high byte with bit 6 set to write
;   or clear to read.  Both are two writes to the same address, which is why
;   interrupts are off around all of this.

SetVdpReg:
  sta VC_REG                        ; Value first
  txa
  ora #$80                          ; Then register number | $80
  sta VC_REG
  rts

SetVramWrite:
  sta VC_REG                        ; Address low byte
  txa
  ora #$40                          ; High byte | $40 = write
  sta VC_REG
  rts

SetVramRead:
  sta VC_REG                        ; Address low byte
  txa
  and #$3F                          ; High byte, top bits clear = read
  sta VC_REG
  rts

; =============================================================================
;   Data
; =============================================================================

Probe:
  .byte $04, $E0, $3F, $00          ; CLR  @>3F00
  .byte $03, $40                    ; IDLE

StockMsg:   .byte "VIDEO: TMS9918A, NO F18A MODE", 0
F18AMsg:    .byte "VIDEO: F18A", 0
PicoMsg:    .byte "VIDEO: F18A MODE ON A PICO9918", 0
NoCardMsg:  .byte "VIDEO: NO CARD FITTED", 0

Run it and the machine tells you what it has:

VIDEO: TMS9918A, NO F18A MODE

or, on a card with the enhanced firmware:

VIDEO: F18A MODE ON A PICO9918

Four things in that program are worth pulling out.

It checks for a card at all first. HW_PRESENT says what the machine found at power-on. Probing a slot with nothing in it wastes a hundred cycles and prints nonsense. What's fitted is the chapter on this.

Interrupts are off for the whole thing. Every access to this card is a pair of writes to one address — value then register, low byte then high byte. Anything that gets between the two halves leaves the card holding one of them and waiting for the wrong thing. sei around the block removes the entire class of problem for the cost of one byte.

The repair happens before the report, not after the success. Registers 1, 6 and 7 go back whichever way the test came out. Written the other way around — restore only on failure — the code is one branch shorter and leaves a stock machine with a blank screen every time the branch is missed.

It reads the identity only after the probe passed. Register 15 is an enhanced register. On a card that failed the probe there is nothing there to select, and asking would just clobber register 7 again.

Locking it again

  • Write anything to register 57 again.
  • Write a value with bit 7 set to register 50. Registers return to power-on defaults and the palette survives.
  • Write $C0 to register 50 to reset the palette as well (Pico9918).

The middle one is the useful one. Writing a value with bit 7 set to register 50 puts every register back to its power-on default and relocks the card, which is the quickest way to hand a clean machine back to BASIC when your program exits. Your palette survives it — see Colors, where that turns out to matter more than you would expect.

Written for BIOS v1.5. Released under the MIT License.