Skip to content

Give an Atari XL turn a VBI-clock deadline

Reuse a 6502 countdown while preserving Atari BASIC, normal VBI services and explicit high-RAM ownership.

Joystick-one fire takes a turn. Hold it and the counter stays put; release before the next action. Waiting takes no turn until SELECT enables timing. After 240 OS clock ticks, about four seconds in the named NTSC configuration, an automatic turn arrives. SELECT disables timing or starts a fresh duration. OPTION returns to BASIC with USR result zero. The first eight screen cells show turn, expiry count and enabled flag in hexadecimal.

The native caller shows 03 01 01 after one automatic turn.
Untimed actions remain valid. SELECT enables an additional way to spend a turn.

Build, reserve and load

Run make ASM198X=/path/to/asm198x here. Enter the complete generated build/load.bas in stock Atari BASIC, one line at a time. Its numbered program loads DATA bytes; RUN performs the POKEs, wait for the new READY prompt, then X=USR(32768) starts the caller. The final POKE can finish before NEXT/END returns to the editor; complete bytes alone do not prove readiness. The loader is generated from the actual assembled image, not a manually maintained byte list. No ROM is supplied.

The 424-byte image occupies $8000–$81A7. Caller-owned state occupies $8300–$8313: TURN, EXPIRIES, KEY, HELD; twelve countdown bytes; two ready-marker bytes; two guards. The assembler refuses code that reaches $8300, and the generated loader checks its payload budget too. Relocate code, data labels and reservation together. No DOS, cartridge or other routine may own this RAM concurrently.

The complete caller is larger than the 256-byte page-six area used by the earlier loop. Its loader instead sets RAMTOP (106) to 124, then selects GRAPHICS 0. BASIC and the display remain below $7C00; code starts at $8000. The 1 KiB gap matters: the reference documents clear/scroll operations reaching above RAMTOP. Do not place a routine directly at an assumed display ceiling. Native qualification checks actual MEMTOP and SAVMSC as well as RAMTOP. This is an explicit layout for the named 64 KiB machine, not a general allocator. It leaves the reservation in place after return; reset or deliberately restore your own layout when finished.

The complete caller

demo.asm
; SPDX-License-Identifier: MIT
* = $8000
DURATION = 240
!source "caller.inc"
!source "clock.inc"
!source "../../../../../cross-platform/patterns/framework/elapsed-deadline/6502.inc"
!source "dispatch.inc"
!source "input.inc"
code_end:
!if code_end > $8300 { !error "Code overlaps deadline context" }

Include and call the helper

The maintained shared countdown

6502.inc
; DEADLINE owns 12 bytes. A=clock low, X=clock high; decimal mode clear.
; Poll at least once per 65536 clock ticks. A/X/status clobbered; Y preserved.
deadline_init:
    sta DEADLINE+8
    stx DEADLINE+9
    lda #0
    sta DEADLINE+2
    sta DEADLINE+3
    sta DEADLINE+4
    sta DEADLINE+5
    sta DEADLINE+6
    sta DEADLINE+7
    rts
deadline_start:
    lda DEADLINE
    sta DEADLINE+2
    lda DEADLINE+1
    sta DEADLINE+3
    ora DEADLINE
    beq deadline_off
    lda #1
deadline_off:
    sta DEADLINE+4
    lda #0
    sta DEADLINE+5
    rts
deadline_poll:
    sta DEADLINE+10
    stx DEADLINE+11
    sec
    sbc DEADLINE+8
    sta DEADLINE+6
    txa
    sbc DEADLINE+9
    sta DEADLINE+7
    lda DEADLINE+10
    sta DEADLINE+8
    lda DEADLINE+11
    sta DEADLINE+9
    lda #0
    sta DEADLINE+5
    lda DEADLINE+4
    beq deadline_done
    sec
    lda DEADLINE+2
    sbc DEADLINE+6
    sta DEADLINE+2
    lda DEADLINE+3
    sbc DEADLINE+7
    sta DEADLINE+3
    bcc deadline_expired
    ora DEADLINE+2
    bne deadline_done
deadline_expired:
    lda #0
    sta DEADLINE+2
    sta DEADLINE+3
    sta DEADLINE+4
    lda #1
    sta DEADLINE+5
deadline_done:
    rts

Provide DEADLINE for twelve private writable bytes and include cross-platform/patterns/framework/elapsed-deadline/6502.inc once. Its little-endian words are duration +0, remaining +2, active +4, one-poll event +5, elapsed +6, previous +8 and scratch +10.

  • Set duration to integer ticks 0..65535. Pass A=clock low, X=clock high to deadline_init once. It records the baseline and starts disabled; duration survives.
  • Call deadline_start to copy duration to remaining and enable only when nonzero. It clears the event but retains the last sampled baseline. Poll immediately before starting if that instant matters.
  • Pass the next sample in A/X to deadline_poll on every service iteration, including while disabled. Read +5 before another poll/start clears it. Expiry clamps remaining to zero, disables active and emits once. The caller decides what that means.

A/X/status are clobbered; Y survives. Decimal mode must be clear. The helper owns no zero page, input, display or interrupt handler. Ordinary JSR/RTS uses the existing stack. A context belongs to one caller; it is not re-entrant. The label is an assembly-time allocation, not a runtime pointer parameter.

Elapsed subtraction wraps modulo 65536: 65526 → 10 is twenty ticks. Poll less than a complete cycle apart, roughly 18 minutes 12 seconds at nominal 60 Hz. A missed extra cycle is ambiguous. Reinitialise after an external clock reset. Zero duration means off. A late poll emits one event without catch-up; restarting from observation introduces drift. A full 24-bit clock/helper would allow longer intervals with additional arithmetic/state; it is not a drop-in claim for this implementation.

Sample the OS clock without stopping NMI

Read the VBI clock coherently

clock.inc
; RTCLOK $12..$14 belongs to the normal OS VBI (NMI, not masked by SEI).
; A=low, X=high, Y preserved. Retry if the high byte changed around the low.
sample_clock:
    ldx $13
    lda $14
    cpx $13
    bne sample_clock
    rts

clock.inc reads RTCLOK’s middle byte at $13, low byte at $14, then compares the middle byte again. If a carry changed it, the reader retries. A is the resulting low byte, X the high byte, Y is preserved and flags change. The ROM owns the full three-byte RTCLOK at $12–$14. This adapter uses only its low word and never resets it.

The normal VBlank service updates this clock under NMI. SEI does not mask NMI; it would not make a naive pair of loads atomic. The retry read avoids taking over the OS service. Leave normal VBI, display and input services running. Interrupt replacement, region changes or suspended polling need another qualification. The recorded NTSC configuration advances roughly one tick per field; PAL needs a different ticks-to-seconds conversion. A clock sample is not a display wait.

Separate controls, policy and drawing

Release-gated fire and console controls

input.inc
; 1=joystick fire, 2=SELECT, 3=OPTION. All must release to re-arm.
; A/X/flags clobbered, Y preserved. No writes to hardware/OS input shadows.
read_command:
    lda $d01f
    and #4
    beq input_quit
    lda $d01f
    and #2
    beq input_toggle
    lda $0284
    beq input_action
    lda #0
    sta HELD
    rts
input_quit:
    ldx #3
    bne input_gate
input_toggle:
    ldx #2
    bne input_gate
input_action:
    ldx #1
input_gate:
    lda HELD
    bne input_none
    inc HELD
    txa
    rts
input_none:
    lda #0
    rts

Choose what a turn means

dispatch.inc
; No I/O. Expiry beats command; byte turn and expiry counters wrap.
dispatch:
    lda DEADLINE+5
    beq dispatch_key
    lda #0
    sta KEY
    inc EXPIRIES
    inc TURN
    jmp deadline_start
dispatch_key:
    lda KEY
    cmp #2
    bne dispatch_action
    lda DEADLINE+4
    beq dispatch_enable
    lda #0
    sta DEADLINE+4
    sta DEADLINE+2
    sta DEADLINE+3
    rts
dispatch_enable:
    jmp deadline_start
dispatch_action:
    cmp #1
    bne dispatch_done
    inc TURN
    lda DEADLINE+4
    beq dispatch_done
    jmp deadline_start
dispatch_done:
    rts

USR entry, service and return

caller.inc
TURN = $8300
EXPIRIES = $8301
KEY = $8302
HELD = $8303
DEADLINE = $8304
READY = $8310
GUARDS = $8312
start:
    pla                       ; stock BASIC USR zero-argument count, not a JSR helper
    php
    txa
    pha
    tya
    pha
    cld
    lda #0
    sta TURN
    sta EXPIRIES
    sta HELD
    lda #<DURATION
    sta DEADLINE
    lda #>DURATION
    sta DEADLINE+1
    jsr sample_clock
    jsr deadline_init
    lda #165
    sta GUARDS
    lda #90
    sta GUARDS+1
    lda #$fe
    sta READY
    lda #$ca
    sta READY+1
main:
    jsr sample_clock
    jsr deadline_poll
    jsr read_command
    sta KEY
    jsr dispatch
    jsr draw_state
    lda KEY
    cmp #3
    bne main
quit_release:
    jsr read_command
    lda HELD
    bne quit_release
    pla
    tay
    pla
    tax
    lda #0
    sta $d4                   ; USR returns unsigned integer zero through FR0
    sta $d5
    plp
    rts
draw_state:
    ldy #0
    lda TURN
    jsr draw_byte
    lda #0
    sta ($58),y
    iny
    lda EXPIRIES
    jsr draw_byte
    lda #0
    sta ($58),y
    iny
    lda DEADLINE+4
draw_byte:
    pha
    lsr
    lsr
    lsr
    lsr
    tax
    lda digits,x
    sta ($58),y
    iny
    pla
    and #15
    tax
    lda digits,x
    sta ($58),y
    iny
    rts
digits:
    !byte 16,17,18,19,20,21,22,23,24,25,33,34,35,36,37,38

input.inc reads the OS-maintained active-low STRIG0 shadow at $0284 and the GTIA CONSOL register at $D01F. OPTION has priority over SELECT over fire. A=0 means none, 1 action, 2 toggle, 3 quit. A/X/flags change; Y survives. HELD suppresses further commands until all three controls are observed released. A short tap between polls can be missed; moving directly between held commands does not re-arm. No keyboard buffer is consumed and no input shadow/register is written.

dispatch.inc has no I/O. An expiry wins over that iteration’s command, including quit/toggle. Otherwise fire advances once and restarts only active timing; untimed actions stay untimed. SELECT discards the remainder on disable and starts a full duration on enable. TURN/EXPIRIES wrap as bytes. Replace the increment with your own world update. Preserved pause, queued commands or carried lateness are separate useful policies, with additional state and different behaviour.

The BASIC USR wrapper consumes the zero-argument count byte before saving X/Y/status. Do not put that PLA inside an ordinary JSR helper. It sets decimal mode clear, retains the existing stack and restores the saved registers/status on return. OPTION waits for command release; FR0’s low two bytes at $D4/$D5 are set to zero for BASIC’s USR result. No private stack or interrupt handler is installed. BASIC, the OS and nested calls still need stack/workspace; the emitted-byte counts are not peak-stack bounds.

Drawing follows SAVMSC at $58/$59 in GRAPHICS 0, rather than assuming a fixed screen address. It writes only the first eight character cells using ANTIC screen codes, changes A/X/Y/flags and leaves the remaining BASIC screen visible. The caller redraws continuously for simple feedback. A game can redraw only on changes, or add independent display pacing while still servicing deadlines often enough.

Customise, break and repair

Halve the duration

customise.asm
; SPDX-License-Identifier: MIT
* = $8000
DURATION = 120
!source "caller.inc"
!source "clock.inc"
!source "../../../../../cross-platform/patterns/framework/elapsed-deadline/6502.inc"
!source "dispatch.inc"
!source "input.inc"
code_end:
!if code_end > $8300 { !error "Code overlaps deadline context" }

Deliberately broken baseline

reset-baseline.inc
; RTCLOK $12..$14 belongs to the normal OS VBI (NMI, not masked by SEI).
; A=low, X=high, Y preserved. Retry if the high byte changed around the low.
sample_clock:
    ldx $13
    lda $14
    cpx $13
    bne sample_clock
    sta DEADLINE+8
    stx DEADLINE+9
    rts

customise.asm changes DURATION=240 to 120: roughly four seconds becomes two, with unchanged payload/state sizes. Choose units before choosing a number. Maths for Games explains the same units, ranges and modular arithmetic.

mistakes/reset-baseline.asm deliberately selects a clock adapter that stores now as previous immediately before polling. The clock moves, but elapsed is always zero, remaining stays at 240 and the screen remains 01 00 01. Restore clock.inc and rebuild; an independently run repair reaches 03 01 01. The broken adapter adds six bytes. Never include both adapters in a working image.

The faulty caller stays at 01 00 01 with no automatic turn.
The OS clock still moves. The broken adapter throws away every elapsed interval.

A loader that writes past its reservation can damage BASIC or the screen rather than producing a useful deadline example. Keep the emitted-size checks and actual display pointers; a convenient address from another machine or caller is not a memory-ownership contract.

Costs, verification and sources

The maintained shared helper is 140 emitted bytes plus twelve context bytes. The coherent clock reader adds nine bytes, input forty-eight, dispatcher sixty-two, and caller/presentation 165. The whole image is 424 bytes plus twenty separately reserved state/marker/guard bytes. These are emitted sizes and ownership, not cycle measurements or a whole-game frame budget.

Six independent keyboard-loaded native cases cover working/custom/wrong/repaired callers and a second arithmetic/policy caller with a wrong-equality control. The loaded code must match assembly before USR. The second caller supplies twelve clock pairs for zero/one/maximum duration, wrap and exact/overshot expiry, plus three controlled policies: expiry+action, expiry+toggle and untimed action. A reversed zero branch completes the guards but fails the independent unsigned oracle. Supplied samples do not establish an observed eighteen-minute rollover.

Real controls include a long held fire button. The native ready/guard check and debugger’s natural main-loop stop keep observations outside unfinished updates; the debugger does not set the PC or write game state. Return is followed by real BASIC arithmetic and a zero USR-result check. PNG pixels are compared with the native screen codes and ROM glyphs. Native sources, tools, firmware identities, output and captures are retained.

Run scripts/verify-atari-xl-assembly-deadlines.py with released tools and your own OS/BASIC. Recheck with scripts/check-atari-xl-assembly-deadlines.py and its unittest corruption controls. Qualification names Emu198x 0.44.2, Asm198x 0.0.72, 800XL NTSC, OS Rev 2 and the recorded BASIC ROM. No original-hardware, every region/ROM or whole-emulator claim follows.

Primary references: Atari, Atari BASIC Reference Manual, USR calling/return conventions and GRAPHICS; Ian Chadwick, Mapping the Atari, RTCLOK 18–20, SAVMSC 88–89, RAMTOP 106, MEMTOP 741–742, STRIG0 644, CONSOL 53279 and FR0 212–217. Public scan: https://www.atarimania.com/documents/Mapping-the-Atari.pdf . The countdown, retry adapter and policy are this implementation’s choices.

Compare the clock and game-loop choices before choosing timing for a game. The Spectrum Z80 version uses the same countdown policy with a different interrupt and calling contract.

Source and loading instructions. Native evidence and configuration.