Skip to content

Optional Timed Turns

Keep an optional turn deadline moving with a private A500 VBlank clock, even while foreground work slows down.

A turn can wait for the player indefinitely. Or it can end when a deadline runs out. Keep those choices separate: the clock runs continuously, while a turn only advances after a fresh Fire press or an enabled deadline expires.

This standalone A500 caller uses Up to switch the deadline on or off, Fire to finish a turn, Right to add slow foreground work, and Left to hold the displayed sample still. Its seven hexadecimal words show turns, timed expiries, active state, ticks left, sampled clock, foreground updates and configured duration. The normal duration is 150 PAL VBlank ticks; custom.asm changes it to 50.

The complete cartridge: includes and one chosen duration

demo.asm
    section code,code_c
DURATION equ 150
    include "caller.inc"
    include "clock.inc"
    include "policy.inc"
policy_code_end:
    include "../../../../../cross-platform/patterns/framework/elapsed-deadline/m68000.inc"
deadline_code_end:
POLL_ROUTINE:
    bra deadline_poll
    include "../input-to-sound/input.inc"
    include "../../input/joystick-edge-detection/edges.inc"
    include "display.inc"
    include "font.inc"
program_end:

Reuse the countdown

Include cross-platform/patterns/framework/elapsed-deadline/m68000.inc. Reserve an even-aligned 14-byte context, pass its address in A0 and supply the current 16-bit clock in D0.W. Call deadline_init once after setting the duration, deadline_poll regularly, and deadline_start after starting another timed turn. D0/D1 and condition codes change; A0 and other registers survive. The helper itself owns no interrupts or display.

Word offset Meaning
0 Duration, 0–65535 ticks; zero disables
2 Ticks remaining
4 Active, 0 or 1
6 Expired on this poll, 0 or 1
8 Elapsed ticks since previous poll
10, 12 Previous and current clock samples

Subtraction wraps modulo 65536. Poll before a whole 65536-tick revolution can pass: the same word value cannot distinguish no time from an entire revolution. Elapsed time equal to or greater than the remaining duration expires once and saturates at zero. A late poll does not invent a queue of missed turns. A zero duration still updates the sampling baseline.

The maintained 14-byte elapsed countdown

m68000.inc
; A0=even 14-byte owned context. D0.W=current tick for init/poll.
; Unsigned words; poll before 65536 ticks pass. D0/D1/CCR may change; A0 survives.
; 0 duration,2 left,4 active,6 expired,8 elapsed,10 previous,12 current scratch.
deadline_init:
    move.w d0,10(a0)
    clr.w 2(a0)
    clr.w 4(a0)
    clr.w 6(a0)
    clr.w 8(a0)
    clr.w 12(a0)
    rts
deadline_start:
    move.w (a0),d1
    move.w d1,2(a0)
    clr.w 4(a0)
    clr.w 6(a0)
    tst.w d1
    beq deadline_start_done
    move.w #1,4(a0)
deadline_start_done:
    rts
deadline_poll:
    move.w d0,12(a0)
    sub.w 10(a0),d0
    move.w d0,8(a0)
    move.w 12(a0),10(a0)
    clr.w 6(a0)
    tst.w 4(a0)
    beq deadline_done
    cmp.w 2(a0),d0
    bhs deadline_expired
    sub.w d0,2(a0)
    rts
deadline_expired:
    clr.w 2(a0)
    clr.w 4(a0)
    move.w #1,6(a0)
deadline_done:
    rts

Own the clock and interrupts

clock.inc is a separate A500 68000 adapter. This is custom-chip takeover code, not an Exec interrupt server. Install only after claiming the level-3 vector at $6C, disabling competing COPER/VERTB/BLIT interrupt sources, enabling the interrupt master and keeping CPU IPL below 3. clock_install checks those enable/mask conditions and refuses a repeated install; it cannot detect a second program writing the vector later.

Call clock_install: D0 is 1 on success, 0 on refusal. It saves the vector, clears its counter and pending VERTB request, then enables its own VBlank source. The handler increments an atomic word counter, acknowledges its request and returns with RTE without changing general registers. read_clock returns the word in D0. wait_clock_frame waits for a new counter value and changes D1/flags; it requires the clock and interrupts to remain enabled.

Do not combine that wait with another routine which polls and acknowledges the same INTREQ bit. Two consumers can steal each other’s event. clock_stop disables and acknowledges this source, restores the saved vector and returns 1; repeated stop returns 0. It leaves the interrupt master and the surrounding OS/custom-chip restoration to its owner. This caller deliberately stays in its own loop.

The original 68000 permits the SR read used by the precondition check; later processors differ. Do not drop this exact clock adapter into an accelerated machine. An Exec server is another implementation of the clock interface, not a reason to copy this takeover routine into an OS application. See the Commodore hardware reference interrupt-control chapter and the Motorola MC68000 manual for the hardware contracts.

Own an exclusive A500 VBlank clock

clock.inc
; A500 68000 ONLY: caller owns level-3 vector/custom-chip takeover.
; Install requires master enabled, COPER/VERTB/BLIT disabled, IPL<3.
; No competing vector writer or OS interrupt owner. Not an Exec server.
clock_install:
    tst.w clock_installed
    bne clock_fail
    move.w sr,d0
    andi.w #$0700,d0
    cmpi.w #$0300,d0
    bhs clock_fail
    move.w $dff01c,d0
    andi.w #$4070,d0
    cmpi.w #$4000,d0
    bne clock_fail
    move.l $6c,clock_old_vector
    clr.w clock_ticks
    move.w #$0020,$dff09c
    move.w #$0020,$dff09c
    lea clock_handler,a0
    move.l a0,$6c
    move.w #1,clock_installed
    move.w #$8020,$dff09a
    moveq #1,d0
    rts
clock_fail:
    moveq #0,d0
    rts
; D0=0/1; only this routine's VERTB enable/request and saved vector are restored.
; Master and surrounding OS/custom-chip state remain the caller's responsibility.
clock_stop:
    tst.w clock_installed
    beq clock_fail
    move.w #$0020,$dff09a
    move.w #$0020,$dff09c
    move.w #$0020,$dff09c
    move.l clock_old_vector,$6c
    clr.w clock_installed
    moveq #1,d0
    rts
; Atomic word read on 68000. D0/CCR change; other registers survive.
read_clock:
    moveq #0,d0
    move.w clock_ticks,d0
    rts
; Wait for a NEW IRQ tick, without polling/acknowledging INTREQ. D1/CCR change.
; Requires this clock still running and interrupts unmasked.
wait_clock_frame:
    move.w clock_ticks,d1
clock_wait:
    cmp.w clock_ticks,d1
    beq clock_wait
    rts
; Exclusive level3 owner: no general register changes; RTE restores status.
clock_handler:
    addq.w #1,clock_ticks
    move.w #$0020,$dff09c
    move.w #$0020,$dff09c
    rte
clock_code_end:
    even
clock_state:
clock_old_vector: dc.l 0
clock_ticks: dc.w 0
clock_installed: dc.w 0
clock_state_end:

Include and call from a project

demo.asm lists the includes. caller.inc shows where the unchanged joystick edge detector, clock read, countdown poll and turn policy are called. policy.inc contains only policy: handle expiry first, then a fresh Up toggle, then a fresh Fire action. Expiry suppresses a simultaneous action for that update; toggling also takes precedence over Fire. Releasing a button enables its next press; holding it creates no repeated turn.

Keep the countdown context, edge state and clock state private to this caller. Clear/initialise them at startup. The context is not re-entrant. Do not let an interrupt call the same policy or rewrite the countdown while the foreground is updating it. Only the clock word is shared with the interrupt here.

The single-bitplane renderer and display_row latch are optional diagnostics. A game can call the same routines without drawing any text. Left freezes only the last displayed row: clock reads and deadline polls continue. The renderer is small and readable; these runs do not establish tear-free live rendering or a worst-case frame budget.

Standalone caller with an independent display latch

caller.inc
start:
    lea $dff000,a5
    move.w #$7fff,$09a(a5)
    move.w #$7fff,$09c(a5)
    move.w #$7fff,$096(a5)
    bclr #7,$bfe201
    lea plane,a0
    move.l a0,d0
    lea copbpl,a1
    move.w d0,6(a1)
    swap d0
    move.w d0,2(a1)
    lea copperlist,a0
    move.l a0,$080(a5)
    move.w #0,$088(a5)
    move.w #$8380,$096(a5)
    move.w #$c000,$09a(a5)
    bsr clock_install
    cmpi.w #1,d0
    bne failed
    lea deadline,a0
    move.w #DURATION,(a0)
    bsr read_clock
    bsr deadline_init
    clr.w joy_prev
    move.w #$cafe,ready
main_loop:
    bsr read_joystick_edge
    move.w d0,pressed
    lea deadline,a0
    bsr read_clock
    bsr POLL_ROUTINE
    bsr dispatch
    addq.w #1,updates
    move.w turns,status_row
    move.w expiries,status_row+2
    move.w deadline+4,status_row+4
    move.w deadline+2,status_row+6
    move.w deadline+12,status_row+8
    move.w updates,status_row+10
    move.w #DURATION,status_row+12
; Deliberate foreground work; IRQ clock continues independently.
    move.w controls,d0
    andi.w #2,d0
    beq wait_display
    move.w #30000,d0
work_loop:
    dbra d0,work_loop
wait_display:
    bsr wait_clock_frame
    move.w controls,d0
    andi.w #$0200,d0
    bne frame_done
    bsr draw_status
frame_done:
    bra main_loop
commit_turn:
    addq.w #1,turns
    rts
failed:
    move.w #$f00,$180(a5)
    bra failed
caller_code_end:
signature: dc.b "198XAMIGATIMER!",0
    even
ready: dc.w 0
turns: dc.w 0
expiries: dc.w 0
updates: dc.w 0
pressed: dc.w 0
deadline: dcb.w 7,0
status_row: dcb.w 7,0
display_row: dcb.w 7,0
    even
copperlist:
    dc.w $08e,$2c81,$090,$2cc1,$092,$0038,$094,$00d0
    dc.w $100,$1200,$102,0,$104,0,$108,0,$10a,0
    dc.w $180,$012,$182,$fff
copbpl:
    dc.w $0e0,0,$0e2,0
    dc.w $ffff,$fffe
plane: dcb.b 10240,0

Expiry-first word-sized turn policy

policy.inc
; A0=deadline context. Same expiry-first policy as the maintained 6502 caller.
; pressed is a fresh joystick mask; turns/expiries are caller-owned words.
; D0/D1/CCR may change; A0 survives. No catch-up queue.
dispatch:
    tst.w 6(a0)
    beq consider_input
    addq.w #1,expiries
    bsr commit_turn
    bra deadline_start
consider_input:
    move.w pressed,d0
    andi.w #$0100,d0
    beq consider_action
    tst.w 4(a0)
    beq enable_timer
    clr.w 4(a0)
    clr.w 2(a0)
    rts
enable_timer:
    bra deadline_start
consider_action:
    move.w pressed,d0
    andi.w #$0010,d0
    beq dispatch_done
    bsr commit_turn
    tst.w 4(a0)
    beq dispatch_done
    bsr deadline_start
dispatch_done:
    rts

Common error: renew the baseline before every poll

wrong.asm stores the current clock into the previous-sample field before polling. That makes elapsed time zero, so the clock display rises but the enabled countdown never expires. This adds four code bytes to the caller. Removing those stores fixes the fault; repaired.asm produces the same executable as the working version.

Watch both values when debugging. A stopped clock, a disabled countdown and a baseline overwritten on every update can all look like a frozen timer, but they need different fixes. The native fixtures exercise all three distinctions.

Deliberate fault: overwrite the baseline before polling

wrong.asm
    section code,code_c
DURATION equ 150
    include "caller.inc"
    include "clock.inc"
    include "policy.inc"
policy_code_end:
    include "../../../../../cross-platform/patterns/framework/elapsed-deadline/m68000.inc"
deadline_code_end:
POLL_ROUTINE:
    move.w d0,10(a0)
    bra deadline_poll
    include "../input-to-sound/input.inc"
    include "../../input/joystick-edge-detection/edges.inc"
    include "display.inc"
    include "font.inc"
program_end:

Make it your own: 50 ticks instead of 150

custom.asm
    section code,code_c
DURATION equ 50
    include "caller.inc"
    include "clock.inc"
    include "policy.inc"
policy_code_end:
    include "../../../../../cross-platform/patterns/framework/elapsed-deadline/m68000.inc"
deadline_code_end:
POLL_ROUTINE:
    bra deadline_poll
    include "../input-to-sound/input.inc"
    include "../../input/joystick-edge-detection/edges.inc"
    include "display.inc"
    include "font.inc"
program_end:

Costs and verification

The assembled countdown is 108 code bytes plus 14 state bytes. The clock is 196 code bytes plus 8 state bytes, including installation, refusal paths and restoration. These exclude the policy, joystick reader, caller and display. The complete working executable is 11,444 bytes, including its 10,240-byte bitplane and 136-byte original hexadecimal font. Reserve stack space for your call chain and interrupt entry; these figures are storage counts, not cycle budgets.

Seven cold-boot ADF runs cover the working, shorter-duration, faulty and repaired callers, 12 independent arithmetic vectors, 16 simultaneous-policy combinations, a wrong arithmetic caller, and clock installation/refusal/stop/restoration. The foreground workload yielded 186 clock ticks over 62 updates, versus 34 ticks over 34 ordinary updates. Another native interval produced 100 IRQ ticks in 100 frames. Readback checks relocated executable bytes, state and the actual bitplane; complete native pixels must agree with the displayed words and own font. Corruption tests must fail when code, events, arithmetic, lifecycle, costs or captures change.

Qualification used Asm198x 0.0.72 and Build198x 0.2.9. Normal frame-clock operation was also checked on released Emu198x 0.44.2. Exact debugger observation used an unreleased build based on that release with a framebuffer-refresh performance fix; verification/configuration.json identifies that build and pins its executable hash. This is emulator execution evidence, not a physical-hardware timing measurement.

Make it your own

Change DURATION in custom.asm, preserving the same units as your clock. Choose whether expiry should suppress simultaneous input by changing the small dispatcher, then add a test for that choice. Replace the exclusive IRQ adapter with a suitable OS-owned clock when your program runs under Exec. Poll promptly even during slow work; splitting expensive jobs across updates improves responsiveness, while the elapsed countdown keeps its meaning.

Modular subtraction and saturation are useful Maths for Games connections. PAL and NTSC ticks have different durations: choose region-specific tick counts or a finer clock when wall-clock seconds matter. An untimed adventure needs no ticking turn policy at all; a timed puzzle can use precisely the same command handling with this optional deadline.

Native output

The working Amiga caller shows turns, expiries, active state, remaining ticks and the sampled IRQ clock.
Left holds the last displayed sample still for the capture. The clock and deadline service continue; each word is shown as four hexadecimal digits.
The faulty caller has zero timed expiries and its remaining field stays 0096 although the IRQ clock has advanced.
Resetting the previous-sample field makes elapsed time zero. The native trace and repaired run distinguish this from a stopped clock.

Pinned code, contracts and complete callers · Native configuration and evidence. Maths for Games connects modular elapsed time and saturating subtraction; loop choices keeps an optional clock distinct from turn progression, and the clock adapter here owns its interrupt source explicitly.