Skip to content

Give a Spectrum Z80 turn a deadline

Keep untimed play valid, then add a reusable FRAMES countdown with an explicit input and expiry policy.

Press 1: a turn passes. Hold it: nothing more happens until you release. Waiting takes no turn until 2 enables timing. Then the ROM clock supplies a deadline: 200 ticks, about four seconds on this PAL Spectrum. Q returns to BASIC.

The native caller shows 03 01 01: three turns, one expiry, timing enabled.
Two player actions, then one automatic turn. The three hexadecimal bytes show turns, expiries and enabled state.

Load the example

Run make ASM198X=/path/to/asm198x in the maintained sample folder. Load build/demo.tap through the normal 48K ROM with LOAD “”. Its generated BASIC loader reserves RAM with CLEAR 32767, loads CODE at $8000 and enters through USR. No firmware is bundled.

The complete include order

demo.asm
; SPDX-License-Identifier: MIT
    org $8000
DURATION equ 200
    include "caller.inc"
    include "clock.inc"
    include "input.inc"
    include "../../../../../cross-platform/patterns/framework/elapsed-deadline/z80.inc"
    include "dispatch.inc"
TURN: db 0
EXPIRIES: db 0
KEY: db 0
HELD: db 0
DEADLINE: defs 10,0
READY: dw 0
code_end:
    end start

The whole image occupies $8000–$8188, 393 bytes including its writable tail. TURN/EXPIRIES/KEY/HELD own four bytes, DEADLINE ten and READY two. Keep the entire image free of BASIC, buffers and other code. Relocate the origin and loader reservation together. The existing BASIC stack, IY workspace and normal IM1 service remain part of this entry contract.

Take the countdown

The maintained shared countdown

z80.inc
; DEADLINE owns 10 bytes. HL=low 16 clock bits. AF/DE/HL clobbered.
; Poll at least once per 65536 clock ticks. BC/IX/IY preserved.
deadline_init:
    ld (DEADLINE+8),hl
    ld hl,0
    ld (DEADLINE+2),hl
    ld (DEADLINE+6),hl
    xor a
    ld (DEADLINE+4),a
    ld (DEADLINE+5),a
    ret
deadline_start:
    ld hl,(DEADLINE)
    ld (DEADLINE+2),hl
    ld a,h
    or l
    jr z,deadline_off
    ld a,1
deadline_off:
    ld (DEADLINE+4),a
    xor a
    ld (DEADLINE+5),a
    ret
deadline_poll:
    ld de,(DEADLINE+8)
    ld (DEADLINE+8),hl
    or a
    sbc hl,de
    ld (DEADLINE+6),hl
    xor a
    ld (DEADLINE+5),a
    ld a,(DEADLINE+4)
    or a
    ret z
    ld hl,(DEADLINE+2)
    ld de,(DEADLINE+6)
    or a
    sbc hl,de
    jr c,deadline_expired
    ld a,h
    or l
    jr z,deadline_expired
    ld (DEADLINE+2),hl
    ret
deadline_expired:
    ld hl,0
    ld (DEADLINE+2),hl
    xor a
    ld (DEADLINE+4),a
    inc a
    ld (DEADLINE+5),a
    ret

Include it once and provide DEADLINE for ten writable bytes. Words are little-endian: duration +0, remaining +2, active +4, one-poll event +5, elapsed +6, previous +8.

Set duration to whole ticks 0..65535. Pass the current clock in HL to deadline_init once: it records the baseline and starts disabled. deadline_start copies duration to remaining and enables only when nonzero. It clears the event but keeps the most recent baseline; poll immediately before starting if that instant matters.

Each service iteration, including while disabled, passes the next sample in HL to deadline_poll. Read +5 before another poll/start clears it. Expiry clamps remaining to zero and disables the context; the caller decides what follows. AF/DE/HL change; BC/IX/IY survive. The helper uses the existing return stack, owns no I/O or IRQ resource and is not re-entrant on one context.

Unsigned subtraction makes 65526 → 10 mean twenty ticks. Poll less than one complete cycle apart, roughly 21 minutes 51 seconds at 50 Hz: an extra whole cycle cannot be recovered from two samples. Reinitialise after an external FRAMES reset. Zero duration means off; late polling emits once without catch-up. Restarting at observation time can drift. Maths for Games develops the same units, ranges and modular arithmetic.

Read time and commands separately

clock.inc

clock.inc
; Stock 48K ROM IM1 owns FRAMES. Atomic word instruction; IRQ between instructions.
; HL=low sixteen ticks, flags and all other registers preserved.
clock_read:
    ld hl,($5C78)
    ret

input.inc

input.inc
; Nonblocking sampled gate. 1=action, 2=toggle, Q=quit. Observe release to re-arm.
; A/BC/flags clobbered. Spectrum active-low keyboard rows.
read_command:
    ld bc,$FBFE
    in a,(c)
    bit 0,a
    jr z,input_quit
    ld b,$F7
    in a,(c)
    bit 1,a
    jr z,input_toggle
    bit 0,a
    jr z,input_action
    xor a
    ld (HELD),a
    ret
input_quit:
    ld b,3
    jr input_gate
input_toggle:
    ld b,2
    jr input_gate
input_action:
    ld b,1
input_gate:
    ld a,(HELD)
    or a
    jr nz,input_none
    inc a
    ld (HELD),a
    ld a,b
    ret
input_none:
    xor a
    ret

The clock adapter reads the low FRAMES word at $5C78 in one LD HL instruction. The stock maskable interrupt is accepted between instructions, so the word is coherent. HL changes; flags and other registers survive. Keep normal ROM/IM1 service available. Interrupt suppression can lose time; this is a nominal game clock, not a precision stopwatch.

The input adapter reuses the existing Ace reader’s row/bit logic with a Spectrum contract. It samples 1/2/Q, prioritising Q over 2 over 1. A=0 means none, 1 action, 2 toggle, 3 quit. AF/BC change and HELD belongs to the reader. All command keys must be observed released to re-arm. Short taps between polls can be missed; moving directly between held commands does not re-arm. This is sampled physical input, not buffered character delivery.

Let the caller choose the rules

dispatch.inc

dispatch.inc
; Fixed labelled game state. AF/DE/HL clobbered; BC/IX/IY preserved.
dispatch:
    ld a,(DEADLINE+5)
    or a
    jr z,dispatch_key
    xor a
    ld (KEY),a
    ld hl,EXPIRIES
    inc (hl)
    ld hl,TURN
    inc (hl)
    jp deadline_start
dispatch_key:
    ld a,(KEY)
    cp 2
    jr nz,dispatch_action
    ld a,(DEADLINE+4)
    or a
    jp z,deadline_start
    xor a
    ld (DEADLINE+4),a
    ld hl,0
    ld (DEADLINE+2),hl
    ret
dispatch_action:
    cp 1
    ret nz
    ld hl,TURN
    inc (hl)
    ld a,(DEADLINE+4)
    or a
    ret z
    jp deadline_start

caller.inc

caller.inc
; 48K BASIC USR entry, existing stack/IM1/IY/ROM retained.
start:
    push af
    push bc
    push de
    push hl
    push ix
    push iy
    call $0DAF
    xor a
    ld (TURN),a
    ld (EXPIRIES),a
    ld (HELD),a
    ld hl,DURATION
    ld (DEADLINE),hl
    call clock_read
    call deadline_init
    ld hl,$CAFE
    ld (READY),hl
main:
    call clock_read
    call deadline_poll
    call read_command
    ld (KEY),a
    call dispatch
    call show_state
    ld a,(KEY)
    cp 3
    jr nz,main
quit_release:
    call read_command
    ld a,(HELD)
    or a
    jr nz,quit_release
    halt
    pop iy
    pop ix
    pop hl
    pop de
    pop bc
    pop af
    ret
show_state:
    ld de,$4000
    ld a,(TURN)
    call show_byte
    ld a,32
    call show_char
    ld a,(EXPIRIES)
    call show_byte
    ld a,32
    call show_char
    ld a,(DEADLINE+4)
show_byte:
    push af
    rrca
    rrca
    rrca
    rrca
    and 15
    call digit
    call show_char
    pop af
    and 15
    call digit
    jp show_char
digit:
    cp 10
    jr c,digit_number
    add a,7
digit_number:
    add a,48
    ret
; A=ASCII, DE=top scanline cell. CHARS points 256 bytes before glyph 32.
; AF/BC/HL clobbered; DE advances one character column.
show_char:
    push de
    ld l,a
    ld h,0
    add hl,hl
    add hl,hl
    add hl,hl
    ld bc,($5C36)
    add hl,bc
    ld b,8
show_scan:
    ld a,(hl)
    ld (de),a
    inc hl
    inc d
    djnz show_scan
    pop de
    inc e
    ret

The dispatcher performs no I/O. Expiry wins over that iteration’s command, including quit/toggle. Otherwise 1 advances and restarts only active timing; untimed actions stay untimed. Disabling discards the remainder; enabling starts a full duration. Byte counters wrap. Replace the increment with your world update. Preserved pause, queued commands and carried lateness are useful alternatives with different state and policy.

The wrapper saves AF/BC/DE/HL/IX/IY, calls the ROM CLS routine and draws eight text cells through CHARS. It uses nested return-stack bytes; the ROM IRQ needs its own stack space too. Q waits for release and a normal interrupt before restoring registers and RET. The screen is not restored. IRQs must be enabled on entry. Continuous drawing is simple feedback, not frame pacing: a game can redraw on changes or add an independent wait.

Change it, break it, repair it

Half the duration, the same helper

customise.asm
; SPDX-License-Identifier: MIT
    org $8000
DURATION equ 100
    include "caller.inc"
    include "clock.inc"
    include "input.inc"
    include "../../../../../cross-platform/patterns/framework/elapsed-deadline/z80.inc"
    include "dispatch.inc"
TURN: db 0
EXPIRIES: db 0
KEY: db 0
HELD: db 0
DEADLINE: defs 10,0
READY: dw 0
code_end:
    end start

Deliberately broken: erase the baseline before polling

reset-baseline.inc
; Stock 48K ROM IM1 owns FRAMES. Atomic word instruction; IRQ between instructions.
; HL=low sixteen ticks, flags and all other registers preserved.
clock_read:
    ld hl,($5C78)
    ld (DEADLINE+8),hl
    ret

The custom build changes DURATION=200 to 100, about four seconds to two, with the same code/state sizes. The faulty adapter stores now as previous before polling: elapsed stays zero, remaining stays at 200 and the automatic turn never arrives. Restore clock.inc and rebuild; a fresh repaired run reaches 03 01 01. The fault adds three bytes. Never include both adapters in a working image.

The native faulty caller stays at 01 00 01: timing enabled but no automatic turn.
The clock moves; the replaced baseline prevents the countdown from consuming elapsed time.

There is a separate loader trap: END start supplies the address for RANDOMIZE USR. Without it, the exact code still loads, then BASIC reports 0 OK, 30:1. A load address does not prove execution.

Deliberately broken: no USR entry in the generated loader

no-entry.asm
; SPDX-License-Identifier: MIT
    org $8000
DURATION equ 200
    include "caller.inc"
    include "clock.inc"
    include "input.inc"
    include "../../../../../cross-platform/patterns/framework/elapsed-deadline/z80.inc"
    include "dispatch.inc"
TURN: db 0
EXPIRIES: db 0
KEY: db 0
HELD: db 0
DEADLINE: defs 10,0
READY: dw 0
code_end:

Costs and qualification

The countdown is 97 emitted bytes plus ten context bytes; the clock adds four bytes, input fifty, dispatcher sixty-one and caller/presentation 165. Sixteen writable image bytes bring the whole payload to 393. These are byte counts, not cycle measurements, peak-stack bounds or a whole-game budget.

Seven independent ROM-tape-loaded cases on Emu198x 0.44.2 with the recorded stock 48K PAL ROM cover working/custom/wrong/repaired callers, arithmetic boundaries, wrong equality and missing entry. The debugger stops naturally at USR to compare every loaded byte before execution; it does not set the PC or write game state. A second caller supplies twelve clock pairs and three policy cases. Its wrong-equality control completes its guards but fails the unsigned oracle. Supplied samples do not establish an observed 22-minute rollover.

Native bitmap/font bytes and the saved PNG pixels agree. Real keys include a long held action; return to BASIC is followed by arithmetic. Source/tool/ROM/capture identities and falsification controls are retained. This is configuration-specific execution evidence, not physical-hardware or complete-emulator qualification.

Sinclair Research, ZX Spectrum BASIC Programming, chapter 18, explains FRAMES and the interrupted-read hazard; Appendix A documents the workspace. The adapters, countdown and game policy here are our implementation choices.

Maintained source · Native execution record · Independent basic route · Choosing progression and clocks