Skip to content

Give a turn a deadline on the Acorn Atom

Keep untimed turns untimed, then add a reusable clock-driven deadline that can expire without input.

Press Space and the turn counter advances. Wait a while: nothing happens. Now tap T. Keep your hands off the keyboard and the next turn arrives by itself. A turn can wait for a decision, or it can have a deadline. Those are separate choices.

Build and try it

Shell
make -C acorn-atom/patterns/assembly/framework/turn-deadline

Use Asm198x 0.0.71, then enter the complete generated build/loader.bas in the machine’s BASIC and RUN. Start with short Space and T taps. Reset leaves the example. The internal turn count is 16-bit; the two displayed hexadecimal digits show its low byte and wrap after FF.

This caller needs the expanded 32 KiB Atom. Its deadline context is at $5F00, ten caller-state bytes at $5F20, and the 479-byte program at $6000. The typed BASIC loader lives below these reservations. The stock 2.5 KiB Atom is outside this layout. The caller owns keyboard sampling and the text display until Break/reset.

Native Acorn Atom output showing turn 02 after an untimed action and a later automatic deadline expiry.
One action advanced the first turn. The enabled deadline advanced the next one without input. Original example captured in Emu198x 0.44.1.

The reusable part

Reserve a consecutive DEADLINE context of 12 bytes, then include this routine after your caller. The duration is an unsigned little-endian word at offsets 0 and 1. Set it once, sample your clock and call deadline_init. The timer starts disabled.

Pass the current clock to deadline_poll each service iteration, including while untimed. A holds the low clock byte and X the high byte; Y is preserved. Clear decimal mode. A/X and flags are clobbered across the interface. A call uses two return-address stack bytes. The helper does not change interrupt state or allocate memory. It is one mutable context, so do not call it concurrently.

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
Offset Meaning
0–1 Duration: 0 disables; otherwise 1..65535 ticks
2–3 Remaining ticks
4 Enabled flag
5 Expired in this poll
6–7 Elapsed ticks consumed
8–9 Previous clock sample
10–11 6502 temporary clock storage

Call deadline_start to copy the duration into remaining time and enable it. Writing zero to offset 4 disables it. Read offset 5 immediately after polling: an expiry is an event for this poll, rather than a flag that stays set forever. The helper disables the expired timer and holds remaining time at zero. It does not end a game turn or decide whether another deadline should start.

Connect it to a caller

The caller polls time first. If expiry and an action arrive together, expiry wins: it commits one automatic turn, starts a new deadline, and discards that iteration’s input. Otherwise Space commits a turn and restarts an already enabled timer. An untimed action stays untimed. T switches the timing policy.

dispatch.inc
dispatch:
    lda DEADLINE+5
    beq consider_action
    inc STATE+6
    lda #2
    sta STATE+7
    jsr commit_turn
    jmp deadline_start
consider_action:
    lda STATE+8
    and #1
    beq dispatch_done
    lda #1
    sta STATE+7
    jsr commit_turn
    lda DEADLINE+4
    beq dispatch_done
    jsr deadline_start
dispatch_done:
    rts

The clock and input adapters provide the machine-specific part:

The Atom adapter samples the flyback signal at $B002. Each observed falling edge adds one tick. In the checked PAL configuration that gives 50 ticks per second while the loop keeps sampling. A 100-tick deadline is roughly two seconds under that condition.

There is a catch: if drawing or sound keeps the CPU away for a whole flyback, that tick is lost. The counter does not know how much time it missed. This is an honest, small polling clock, useful when you can bound the work between samples. An interrupt-maintained clock would need a separate design and ownership agreement.

Space and T are sampled through the keyboard matrix. The adapter restores the PPI selector and preserves the graphics-mode bits. A held Space key counts once; a press between samples may be missed. The display uses stock character output.

demo.asm
!source "settings.inc"
OSWRCH = $fff4
* = $6000
start:
    cld
    lda #0
    ldx #9
clear_state:
    sta STATE,x
    dex
    bpl clear_state
    lda #<DURATION
    sta DEADLINE
    lda #>DURATION
    sta DEADLINE+1
    lda #<INITIAL_CLOCK
    sta STATE
    lda #>INITIAL_CLOCK
    sta STATE+1
    lda $b002
    and #128
    sta STATE+2
    lda STATE
    ldx STATE+1
    jsr deadline_init
    jsr display
next_sample:
    jsr sample_clock
    jsr deadline_poll
    jsr sample_keys
    lda DEADLINE+5
    bne no_toggle
    lda STATE+8
    and #2
    beq no_toggle
    lda DEADLINE+4
    beq enable_timer
    lda #0
    sta DEADLINE+4
    beq no_toggle
enable_timer:
    jsr deadline_start
no_toggle:
    jsr dispatch
    lda STATE+7
    beq next_sample
    jsr display
    lda #0
    sta STATE+7
    jmp next_sample
; Count observed falling edges of PC7. No wait; missed flybacks are lost.
sample_clock:
    lda $b002
    and #128
    cmp STATE+2
    beq clock_sampled
    sta STATE+2
    cmp #0
    bne clock_sampled
    inc STATE
    bne clock_sampled
    inc STATE+1
clock_sampled:
    lda STATE
    ldx STATE+1
    rts
; Space=bit 0, T=bit 1. Preserve the display-mode nibble and old selector.
sample_keys:
    lda $b000
    pha
    and #$f0
    ora #9
    sta $b000
    lda $b001
    eor #255
    and #1
    sta STATE+9
    lda $b000
    and #$f0
    ora #7
    sta $b000
    lda $b001
    and #32
    bne no_t
    lda STATE+9
    ora #2
    sta STATE+9
no_t:
    pla
    sta $b000
    lda STATE+3
    eor #255
    and STATE+9
    sta STATE+8
    lda STATE+9
    sta STATE+3
    rts
commit_turn:
    inc STATE+4
    bne turn_committed
    inc STATE+5
turn_committed:
    rts
!source "dispatch.inc"
!source "../../../../../cross-platform/patterns/framework/elapsed-deadline/6502.inc"
display:
    lda #12
    jsr OSWRCH
    ldx #0
print_label:
    lda label,x
    beq print_value
    jsr OSWRCH
    inx
    bne print_label
print_value:
    lda STATE+4
    lsr
    lsr
    lsr
    lsr
    jsr hex_digit
    lda STATE+4
    and #15
hex_digit:
    tax
    lda digits,x
    jmp OSWRCH
label: !text "SPACE:TURN T:TIMER TURN "
    !byte 0
digits: !text "0123456789ABCDEF"

The screen redraws only after a turn changes. Replace that feedback with your own drawing or sound, while respecting the memory, port and timing owners. The example clears its text screen and does not restore the previous picture on exit.

Make it your own

Change DURATION from 100 to 50 and rebuild/re-enter the loader. The independently executed customisation uses this shorter value. The turn expires after half as many ticks, with the same helper code and state allocation.

settings.inc
DEADLINE = $5f00
STATE = $5f20
DURATION = 100
INITIAL_CLOCK = 0

Clock units matter: 100 ticks means different amounts of time with a 50 Hz field counter and a 300 Hz firmware clock. Think of duration as a quantity with a unit, not just a number. The Maths for Games material develops the same habit of choosing a representation and checking its range; no extra lesson is required to use this routine.

Common mistake: waiting for input before handling expiry

Deliberately broken include below. This adds a command test before the expiry test. With no input, the timer reaches zero and disables itself, but the displayed turn never advances. The clock kept working; the caller discarded its event.

input-gated-expiry.inc
; DELIBERATELY WRONG: ignores expiry until a command arrives.
dispatch:
    lda STATE+8
    and #1
    beq dispatch_done
    lda DEADLINE+5
    beq consider_action
    inc STATE+6
    lda #2
    sta STATE+7
    jsr commit_turn
    jmp deadline_start
consider_action:
    lda STATE+8
    and #1
    beq dispatch_done
    lda #1
    sta STATE+7
    jsr commit_turn
    lda DEADLINE+4
    beq dispatch_done
    jsr deadline_start
dispatch_done:
    rts
Native faulty Acorn Atom caller still displaying turn 01 after the timer has expired without a new command.
The faulty caller stays on turn 01. The native record confirms remaining=0 and timer disabled. Restore dispatch.inc to handle expiry independently of input; a fresh repaired run advances to turn 02.

Keep this wrong include separate from normal builds. The fix is the working dispatch above: check expiry before asking whether a command arrived.

Limits and useful next steps

The helper subtracts consecutive 16-bit clock samples modulo 65536. For example, a move from 65530 to 5 means eleven ticks, provided a whole rollover was not missed. A 100-tick deadline then has 89 ticks left. Exact and overshot deadlines both become zero rather than wrapping into a long wait.

It reports one expiry, even after a long overrun. Restarting from the observation time does not replay all the turns that might have happened. A fixed-step accumulator, queued actions, longer clocks and carrying lateness into the next deadline are useful extensions when the game needs them; they are not hidden guarantees here. See choosing progression and clocks for the wider distinction.

The measured helper is 140 executable bytes with 12 context bytes. The complete caller is 479 bytes, plus ten caller-state bytes, two guards, stack, firmware, display and loader requirements. Polling does bounded work rather than looping once per elapsed tick. Firmware calls and display work still need measurement before claiming a whole-game frame budget.

What was checked

Code status: assembled and emulator-executed. Five fresh stock-firmware callers cover normal use, shorter duration, the input-gated mistake and a separate repaired run, plus a near-wrap observed clock. The verifier types the complete loader, compares every installed code byte, checks guards and observes independent action, deadline expiry, restart and disable behaviour. The observed clock advances 30 ticks across 30 native frames.

Separate original reset-ROM fixtures execute twelve arithmetic cases, including wrap, exact/overshot expiry and zero/maximum duration. A completed wrong-branch fixture is rejected by the independent arithmetic oracle. Those CPU/RAM fixtures do not establish stock-firmware loading or display timing. No original-hardware result or general emulator certification is claimed.

The maintained caller and retained evidence include the repeatable commands. The shared routine contract explains the state layout and alternatives.

Primary references

David Johnson-Davies, Atomic Theory and Practice (1980), §25.5 (PPI ports and flyback), §25.6 (memory map), §13.14 (OS calls) and §14.2 (LINK). The PAL field rate above is the named execution observation.