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 $9C01Nothing 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 stopPut 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 goesOn 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.
| Value | What it is |
|---|---|
$E0 | a real F18A |
$E8 | an 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
.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", 0Run it and the machine tells you what it has:
VIDEO: TMS9918A, NO F18A MODEor, on a card with the enhanced firmware:
VIDEO: F18A MODE ON A PICO9918Four 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.

