TMS9918A and BIOS 1.6 — for the COB, DEV, KIM, VCS, PicoCalc and ACEs still on the original video card editionCurrent docs →
Skip to content

The clock, and memory that lasts

The clock card carries a DS1511Y: a real-time clock with its own battery, and 256 bytes of memory that survive being switched off. The battery is why your ACE knows the date after two weeks in a closet, and the 256 bytes are the best place to keep a high score.

Reading the time

RtcReadTimeA = hours, X = minutes, Y = seconds
RtcReadDateA = day, X = month, Y = year — and the century lands in RTC_BUF_CENT
RtcWriteTimeThe same three, going in
RtcWriteDateThe same, plus RTC_BUF_CENT set first

Every one of those numbers is ordinary binary. The chip itself stores packed decimal, and the Kernal converts in both directions so you never have to think about it. Hours are 0 to 23; the year is 0 to 99 with the century kept separately, which is how you get 20 and 26 rather than an argument about what year 26 means.

Setting it, reading it, and leaving a note

asm
; Setting the clock and reading it back, then leaving a note in the 256 bytes
; of memory the clock card keeps alive on its battery.
;
; The clock hands over plain binary numbers — hours, minutes, day, month — so
; nothing here has to unpack anything. Printing two digits with a leading zero
; is the only real work.

.setcpu "65C02"

.include "6502.inc"

.segment "CODE"

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

NOTE_SLOT = 0                   ; which of the 256 battery-backed bytes to use

Start:
  lda #20                       ; the century, kept apart from the year
  sta RTC_BUF_CENT
  lda #26                       ; day
  ldx #12                       ; month
  ldy #26                       ; year within the century
  jsr RtcWriteDate

  lda #9                        ; hours
  ldx #30                       ; minutes
  ldy #0                        ; seconds
  jsr RtcWriteTime

  lda #<Now
  ldy #>Now
  jsr PrintStr

  jsr RtcReadTime               ; A = hours, X = minutes, Y = seconds
  phy                           ; PrintTwo needs X and Y for itself
  phx
  jsr PrintTwo                  ; hours
  lda #':'
  jsr Chrout
  pla
  jsr PrintTwo                  ; minutes
  lda #':'
  jsr Chrout
  pla
  jsr PrintTwo                  ; seconds

  lda #<OnThe
  ldy #>OnThe
  jsr PrintStr

  jsr RtcReadDate               ; A = day, X = month, Y = year
  phy
  phx
  jsr PrintTwo                  ; day
  lda #'/'
  jsr Chrout
  pla
  jsr PrintTwo                  ; month
  lda #'/'
  jsr Chrout
  lda RTC_BUF_CENT              ; the century the read left behind
  jsr PrintTwo
  pla
  jsr PrintTwo                  ; year
  jsr PrintCRLF

; Those 256 bytes survive a power cut. Write one, read it straight back, and
; it will still be there next week.
  lda #30
  ldx #NOTE_SLOT
  jsr RtcWriteNVRAM

  lda #<Remembered
  ldy #>Remembered
  jsr PrintStr
  ldx #NOTE_SLOT
  jsr RtcReadNVRAM
  jsr PrintTwo
  jsr PrintCRLF
  rts

; Print A as two decimal digits, leading zero included. Clobbers X.
PrintTwo:
  ldx #'0'
@tens:
  cmp #10
  bcc @units
  sbc #10
  inx
  bra @tens
@units:
  ora #'0'
  pha                           ; the units digit, out of the way
  txa
  jsr Chrout                    ; tens
  pla
  jmp Chrout                    ; units

Now:        .byte "THE TIME IS ", $00
OnThe:      .byte " ON ", $00
Remembered: .byte "AND THE CLOCK CARD REMEMBERS ", $00
RUN
THE TIME IS 09:30:00 ON 26/12/2026
AND THE CLOCK CARD REMEMBERS 30

OK
Whatever the clock card says, at the moment you start it. Open the full emulator

PrintTwo at the bottom is the routine you will keep: PrintDecU16 prints 9 as 9, and a clock wants 09. Repeated subtraction is the cheapest way to split a number under 100 into two digits, and the pha around the first Chrout is there because a routine that prints is allowed to use your registers.

Seconds move while you are reading

The three fields come back from one read, so they are consistent with each other. But if you read the time, do some work, and read the date, midnight can happen in between. Read the date first when it matters.

The 256 bytes

RtcReadNVRAMAddress in X, byte back in A
RtcWriteNVRAMAddress in X, byte in A

Addresses 0 to 255, no structure at all — the card gives you the bytes and what they mean is up to you. A high score is two bytes. A settings block is a handful. A "have they seen the tutorial" flag is one bit.

asm
  ldx #HIGH_SCORE_LOW
  lda ScoreLow
  jsr RtcWriteNVRAM
  ldx #HIGH_SCORE_HIGH
  lda ScoreHigh
  jsr RtcWriteNVRAM

A fresh card holds garbage, not zero

Battery-backed memory that has never been written contains whatever it powered up with. Do not trust byte 0 to be 0. If you use the raw bytes, keep a signature of your own alongside your data and treat everything as unset until you read it back. The save slots below do that job for you, with a checksum.

The same 256 bytes are what BASIC's NVRAM reaches, so a program in each language can leave notes for the other.

Save slots

Two programs that both use byte 0 will ruin each other's high score. So the Kernal also divides the 256 bytes into 16 save slots of 16 bytes each, and every program that uses them shares the card safely.

ByteHolds
0The owner ID: one byte your program picks. $00 means the slot is free
1A checksum the Kernal works out
2–1514 bytes that are yours

Slot n starts at byte n × 16. The checksum covers the owner ID and the 14 bytes: start at $A6, then for each byte rotate left one bit and exclusive-OR the byte in. A slot is free, valid (the checksum agrees) or damaged (it does not). Each slot is checked on its own, so one damaged slot never costs you the others.

EntryInOut
NvStat
X = slot (0-15)
A = NV_EMPTY / NV_VALID / NV_BAD, Y = owner ID, C clear
C set on no RTC or bad slot (A, Y undefined)
NvRead
X = slot (0-15), A/Y = destination lo/hi
A = status, Y = owner ID; C clear and the buffer written only if A = NV_VALID
C set on no RTC or bad slot (A, Y undefined), or a slot that is not valid (A = its status, Y = its owner ID) — the buffer is untouched
NvWrite
X = slot (0-15), A/Y = source lo/hi, NV_ID = owner ID ($01-$FF)
C clear; C set on no RTC, bad slot, or NV_ID = 0 (nothing written)
NvErase
X = slot (0-15)
C clear; C set on no RTC or bad slot
NvFind
A = owner ID
X = slot, C clear; C set if no slot matched (X undefined)
NvFormat
nothing
C clear; C set on no RTC
asm
; A game's save, kept in one of the clock card's sixteen save slots: find the
; slot, write a record, read it back, then damage one byte and watch the
; Kernal refuse to load it.
;
; The Kernal does the checksum. All the game decides is its owner ID and what
; its 14 bytes mean: here a level, a two-byte score and an eleven-letter name.

.setcpu "65C02"

.include "6502.inc"

.segment "CODE"

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

GAME_ID = $5A                   ; any value but $00, which means "free"

Start:
; On a ROM older than v1.6 these six entries are reserved slots: a bare RTS
; that leaves carry as it found it. Ask the ROM before trusting the answers.
  jsr KernalVersion             ; A = major, X = minor
  cmp #1
  bcc @old
  bne @new
  cpx #6
  bcs @new
@old:
  lda #<TooOld
  ldy #>TooOld
  jmp PrintStr

@new:
  lda #GAME_ID
  jsr NvFind                    ; X = our slot, carry set if we have none
  bcc @ours
  lda #$00
  jsr NvFind                    ; X = the first free slot
  bcc @free
  lda #<NoRoom
  ldy #>NoRoom
  jmp PrintStr

@ours:
  stx Slot
  lda #<Found
  ldy #>Found
  bra @report
@free:
  stx Slot
  lda #<NoSave
  ldy #>NoSave
@report:
  jsr PrintStr
  jsr PrintSlot
  jsr PrintCRLF

; Save. The owner ID goes in NV_ID; the 14 bytes go by address.
  lda #GAME_ID
  sta NV_ID
  ldx Slot
  lda #<Record
  ldy #>Record
  jsr NvWrite
  bcs Failed

; Load it into a different buffer, so what is printed really came back.
  ldx Slot
  jsr NvStat                    ; A = NV_EMPTY, NV_VALID or NV_BAD
  cmp #NV_VALID
  bne Failed
  lda #<Loaded
  ldy #>Loaded
  jsr NvRead
  bcs Failed
  jsr PrintRecord

; Now damage it: flip every bit of the level byte, going around the Kernal.
  lda Slot                      ; slot n starts at NVRAM n * 16
  asl
  asl
  asl
  asl
  ora #2                        ; + 2 skips the owner ID and the checksum
  sta Where
  tax
  jsr RtcReadNVRAM
  eor #$FF
  ldx Where
  jsr RtcWriteNVRAM

  ldx Slot
  lda #<Loaded
  ldy #>Loaded
  jsr NvRead                    ; carry set: A = its status, Y = its owner
  bcc Failed
  cmp #NV_BAD
  bne Failed
  phy
  lda #<Damaged
  ldy #>Damaged
  jsr PrintStr
  jsr PrintSlot
  lda #<Owner
  ldy #>Owner
  jsr PrintStr
  pla
  ldx #0
  jsr PrintDecU16
  jmp PrintCRLF

Failed:
  lda #<Trouble
  ldy #>Trouble
  jmp PrintStr

; "LEVEL 3, SCORE 1250, ADA" from the 14 bytes at Loaded.
PrintRecord:
  lda #<Level
  ldy #>Level
  jsr PrintStr
  lda Loaded
  ldx #0
  jsr PrintDecU16
  lda #<Score
  ldy #>Score
  jsr PrintStr
  lda Loaded + 1
  ldx Loaded + 2
  jsr PrintDecU16
  lda #<Comma
  ldy #>Comma
  jsr PrintStr
  lda #<(Loaded + 3)            ; the name is zero-padded, so it prints as is
  ldy #>(Loaded + 3)
  jsr PrintStr
  jmp PrintCRLF

PrintSlot:
  lda Slot
  ldx #0
  jmp PrintDecU16

Slot:    .byte 0
Where:   .byte 0

; The record as the game keeps it: exactly 14 bytes, the name zero-padded to
; eleven.
Record:
  .byte 3                       ; level
  .word 1250                    ; score
  .byte "ADA", 0, 0, 0, 0, 0, 0, 0, 0

; Where it comes back to. The byte past the 14 ends an eleven-letter name.
Loaded:  .res 15, 0

TooOld:  .byte "THIS ROM HAS NO SAVE SLOTS", $0D, $0A, $00
NoRoom:  .byte "EVERY SLOT IS TAKEN", $0D, $0A, $00
NoSave:  .byte "NO SAVE YET - USING FREE SLOT ", $00
Found:   .byte "FOUND OUR SAVE IN SLOT ", $00
Level:   .byte "LOADED LEVEL ", $00
Score:   .byte ", SCORE ", $00
Comma:   .byte ", ", $00
Damaged: .byte "SLOT ", $00
Owner:   .byte " IS DAMAGED - OWNER ", $00
Trouble: .byte "THE CLOCK CARD DID NOT ANSWER", $0D, $0A, $00
RUN
NO SAVE YET - USING FREE SLOT 0
LOADED LEVEL 3, SCORE 1250, ADA
SLOT 0 IS DAMAGED - OWNER 90

OK
This clock card forgets when you leave the page. Run it twice and the second run finds its own save, damaged by the first. Open the full emulator

The rules are the same for all six:

  • Carry set means nothing happened. No clock card, a slot number of 16 or more, NvWrite with NV_ID at 0 (use NvErase for that), NvFind with no match, or NvRead on a slot that is not valid.
  • A failed NvRead still answers. A holds the slot's status and Y its owner ID, so a game can tell "no save yet" from "your save is damaged". Your buffer is left alone.
  • NvFind matches damaged slots too, lowest slot first. A game that finds its ID and then gets carry from NvRead knows its save was damaged, rather than starting over as if it never had one.
  • NV_ID is an input to NvWrite and nothing else. Nothing writes it back; NvStat and NvRead give you the owner in Y.
  • X survives NvStat, NvRead, NvWrite and NvErase, so a loop over the slots needs no reload. NvFind and NvFormat change it.
  • NvRead and NvWrite use STR_PTR ($02$03), just as PrintStr does.
  • Decimal mode and the interrupt flag come back as you left them. A score kept in decimal mode saves safely. Interrupts are held off for the moment a copy takes, because the copy streams bytes through the chip and nothing else may touch it in between. For the same reason an NMI handler must never touch the clock card's memory.

Check the version first

On a ROM older than v1.6 these six addresses are reserved slots: a bare RTS that leaves carry however you had it, which can look like success. Ask KernalVersion for 1.6 or later before trusting an answer, as the listing does. See which ROM am I on?

No clock card, no slots

The slots live on the clock card, so a machine without one, like a KIM built on its own from COB cards, gets carry set from every one of the six. NvFormat empties all 16 at once.

BASIC can read and write the same slots, so a save manager written in BASIC can list your game's saves. See save slots from BASIC.

The registers underneath

$8800 upwards, one per field, all in packed decimal — $59 means 59, not 89. Seconds, minutes, hours, day of week, date, month, year, century, then four alarm registers and a watchdog.

The alarm is worth knowing about: set it and the card can pull the interrupt line at a chosen time. Nothing in the Kernal uses it, so the whole thing is free for you — see Interrupts for how to catch it.

asm
  lda RTC_SEC                   ; packed decimal, straight from the chip
  and #$0F                      ; the units digit

Next: interrupts.

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