Skip to content

Give an Ace turn a deadline in Z80

Use the stock FORTH clock with a reusable countdown, a release-gated keyboard reader and a loader that waits for compilation.

Native caller after an automatic third turn: one expiry, timing enabled.
Two player actions, then one automatic turn. Recorded jupiter-ace assembly caller in Emu198x 0.44.2.

Press 1 for a turn. Wait: nothing changes. Press 2 and the timer starts; after 200 firmware ticks (roughly four seconds), the next turn arrives without input. The three hexadecimal bytes show turn, expiry count and enabled flag. Q returns to FORTH after the command keys are released.

Build and enter

Run make -C jupiter-ace/patterns/assembly/framework/turn-deadline ASM=/path/to/asm198x. It emits build/demo.bin and generates build/load.fth from those exact bytes. Enter each FORTH line and wait for OK before entering the next. Reset before changing complete variants. DECIMAL is explicit. TURNS calls 15616 ($3D00); RESERVE grows the dictionary to that origin before C, appends the emitted bytes. This is a keyboard route, not cassette loading.

Why wait? The keyboard tool can finish sending characters while FORTH is still interpreting them. In the native regression, an eight-byte line had written six bytes when the tool reported all 53 characters sent. Waiting three more fields in that same boot completed the exact eight bytes. The verifier now checks HERE, the empty data stack and the written prefix after each data line; it retains the final full executable-byte check. Sending the next line too early is not an established emulator defect or a line-length limit.

The unexpanded machine has 3 KiB overall, including display and character RAM. Its general-purpose RAM ends at $4000. The complete normal image is 369 bytes at $3D00–$3E70, including fourteen state bytes. FORTH owns the dictionary up to code_end, then its twelve-byte gap and data stack; the CPU/return stack grows down from RAM top. Do not treat the remaining addresses as an unowned buffer. The native fixture is larger (518 bytes); it too fits the observed stock layout. These are checked configurations and snapshots, not a peak-stack guarantee for a larger game.

Reuse the countdown

demo.asm

demo.asm
; SPDX-License-Identifier: MIT
    org $3D00
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
code_end:

z80.inc

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

The unchanged shared Z80 helper lives in cross-platform/patterns/framework/elapsed-deadline/z80.inc. Include it once and supply DEADLINE: ten writable bytes, little-endian words. Duration is +0, remaining +2, active +4, one-poll event +5, elapsed +6 and previous sample +8.

Set duration to an unsigned 0..65535, pass the clock in HL and call deadline_init once. It preserves duration, records the baseline and starts disabled. deadline_start copies duration into remaining and enables when nonzero; it clears the event and retains the latest baseline. Poll immediately before starting if that instant matters. deadline_poll takes the next clock in HL, updates the baseline even while disabled, and sets +5 on expiry. Read that event before the next poll clears it. Expiry clamps remaining to zero and disables active; the caller decides what follows.

The helper clobbers AF/DE/HL and preserves BC/IX/IY. It neither changes interrupt state nor performs I/O. Its calls use the existing return stack. This is one labelled mutable context, not a re-entrant or implicitly multi-timer API.

Unsigned subtraction wraps modulo 65536. 65526 → 10 is twenty ticks. Poll before a complete clock cycle elapses (about 21 minutes 51 seconds at nominal 50 Hz); an extra missed cycle is indistinguishable from the same samples. Zero duration means disabled. A clock reset needs reinitialisation. Late service produces one event without catch-up; restarting at observation time introduces drift. These units, ranges and wrap rules connect directly to Maths for Games.

Clock, input and game policy

clock.inc

clock.inc
; Stock FORTH FRAMES low word; retry if interrupt carries the low byte.
; HL out, A/flags clobbered; BC/DE/IX/IY and interrupt state preserved.
clock_read:
    ld a,($3C2C)
    ld h,a
    ld a,($3C2B)
    ld l,a
    ld a,($3C2C)
    cp h
    jr nz,clock_read
    ret

input.inc

input.inc
; Nonblocking sampled gate. 1=action, 2=toggle, Q=quit. Observe release to re-arm.
; A/BC/flags clobbered. Keyboard port reads also affect the Ace speaker.
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

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
; FORTH wrapper reserves dictionary through code_end. IX/IY unchanged.
start:
    push af
    push bc
    push de
    push hl
    xor a
    ld (TURN),a
    ld (EXPIRIES),a
    ld (HELD),a
    ld hl,DURATION
    ld (DEADLINE),hl
    call clock_read
    call deadline_init
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
; Keep the quit key out of FORTH's next editor line. The normal IRQ service
; must observe its release before returning to the interpreter.
quit_release:
    call read_command
    ld a,(HELD)
    or a
    jr nz,quit_release
    halt
    pop hl
    pop de
    pop bc
    pop af
    jp (iy)
show_state:
    ld a,32
    ld ($2402),a
    ld ($2405),a
    ld a,(TURN)
    ld de,$2400
    call show_byte
    ld a,(EXPIRIES)
    ld de,$2403
    call show_byte
    ld a,(DEADLINE+4)
    ld de,$2406
    call show_byte
    ret
show_byte:
    push af
    rrca
    rrca
    rrca
    rrca
    and 15
    call hex_digit
    ld (de),a
    inc de
    pop af
    and 15
    call hex_digit
    ld (de),a
    ret
hex_digit:
    cp 10
    jr c,hex_number
    add a,7
hex_number:
    add a,48
    ret

clock.inc reads the low word of the stock ROM’s four-byte FRAMES at $3C2B. A high/low/high read retries if the high byte changed during a low-byte carry. Interrupts stay enabled; HL is output, A/flags change, other registers are preserved. Keep the normal ROM interrupt service running. The manual gives a physical period slightly different from an exact fiftieth; BEEP/tape activity can stop the ROM clock. This is not a precision civil-time clock or a replacement ISR.

input.inc samples 1, 2 and Q through the keyboard matrix, with Q taking priority over 2 then 1. A=0 means none/ignored, 1 action, 2 toggle, 3 quit. It clobbers A/BC/flags and owns HELD. A nonzero command is delivered once, then all command keys must be observed released to re-arm. A press wholly between polls can be missed; changing directly between held command keys does not re-arm. This is a sampled release gate, unlike buffered character delivery on some other machines. Ace keyboard-port reads also affect the speaker; this example makes no silence/audio-driver claim.

dispatch.inc handles expiry before the key and discards a simultaneous command, including quit/toggle. Otherwise 1 advances once and restarts only an active deadline. Untimed actions stay untimed. 2 discards the remainder on disable and gives a fresh duration on enable. The byte turn/expiry counters wrap; replace their effects with your game rules. Preserved pause, queued commands, carried lateness or several contexts are useful adaptations with different costs and policies.

The top-level caller preserves AF/BC/DE/HL plus IX/IY, uses the existing CPU stack, and returns via the FORTH CALL convention, JP (IY). It waits for command-key release and one normal IRQ before returning, so Q does not leak into the next editor line. An earlier candidate returned while Q was down and spoiled the next FORTH command; native return-to-interpreter arithmetic catches that independently of timer behaviour. IRQs must be enabled on entry. No private stack or new interrupt handler is installed.

Presentation writes only the first eight cells at $2400–$2407, including spaces between the byte values in the normal text display. It uses A/DE/flags and temporary return-stack bytes. The default ROM character set must remain present. CLS happens once before entry; the screen is not restored on exit. This is continuous status feedback, not a frame wait.

Customise and diagnose

customise.asm

customise.asm
; SPDX-License-Identifier: MIT
    org $3D00
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
code_end:

Deliberately broken: reset-baseline.inc

reset-baseline.inc
; Stock FORTH FRAMES low word; retry if interrupt carries the low byte.
; HL out, A/flags clobbered; BC/DE/IX/IY and interrupt state preserved.
clock_read:
    ld a,($3C2C)
    ld h,a
    ld a,($3C2B)
    ld l,a
    ld a,($3C2C)
    cp h
    jr nz,clock_read
    ld (DEADLINE+8),hl
    ret

customise.asm changes DURATION=200 to 100, roughly four seconds to two, without changing image/state size. The helper stays unchanged.

mistakes/reset-baseline.asm deliberately selects a clock adapter that overwrites the previous sample before polling. The real clock advances but elapsed always becomes zero. The screen stays at 01 00 01: timing enabled, no automatic turn. Remaining stays at 200. Restore clock.inc and rebuild; the fresh repaired run reaches 03 01 01. The faulty adapter adds three bytes; never include both versions in a working image.

Native faulty caller with one turn, zero expiries and timing enabled.
The clock advances, but the deliberately reset baseline prevents the deadline from counting down.

Costs and qualification

The shared helper is 97 emitted bytes plus ten context bytes. The clock reader adds fifteen bytes, input fifty, dispatcher sixty-one, and caller/presentation 132. Four additional game/input state bytes bring the complete image to 369. IRQ, keyboard, presentation and your own work determine service latency; no cycle or whole-game frame budget follows from byte counts.

Emu198x 0.44.2 with the recorded stock ROM and 3 KiB configuration; Asm198x 0.0.72. Six independent keyboard-entered callers cover normal, half duration, baseline fault, repair, boundaries and wrong equality. Every loader line must finish dictionary/stack work before the next; the complete payload must match assembly before CALL. The same-boot early-read regression must reject incomplete bytes, then observe them finish without changing the text.

Twelve supplied clock pairs test zero/one/maximum duration, no elapsed time, exact/overshot expiry and wrap. Three separate policy cases test expiry+action, expiry+toggle and untimed action. Removing the exact-equality branch completes the fixture but fails the unsigned oracle. Supplied samples qualify arithmetic/policy, not an observed 22-minute rollover. Return to FORTH is followed by real 2 3 + arithmetic and an empty data-stack check. Snapshot dictionary/stack bounds, captures, tool/firmware/source hashes and loaded bytes are retained.

Run scripts/verify-ace-loader.py and scripts/verify-ace-assembly-deadlines.py with released executables and your own ROM. Run python3 scripts/check-ace-assembly-deadlines.py and python3 -m unittest discover -s scripts -p test_ace_assembly_deadlines.py for retained evidence checks. Eighteen deliberate corruptions must be rejected. No original-hardware, tape or complete-emulator qualification is claimed.

Primary references: Jupiter Ace User Manual (1982), chapter 24, printed pp.141–145 (memory, HERE/STKBOT, SPARE, FRAMES and stack separation), chapter 25 (interrupt/CALL conventions). The pointer handshake and low-word adapter are this implementation’s choices. The separate existing FORTH deadline entry remains an independent language route.

Maintained source · Native execution record · Untimed command loop · Choosing progression and clocks

The FORTH deadline implementation is a separate language route with its own dictionary and data-stack contract.