Fire, release the button, then fire again. The cooldown should keep counting while your finger is off the button. This component separates the passing of simulation time from the request to act, so both tapped and held inputs can use the same small timer.
The working caller requests an action every third update. A three-step period grants four requests in twelve updates. The deliberately wrong caller only ticks while a request is present: the counter freezes between taps, and just two requests are granted.

Initialise, tick, then try
Reserve four private bytes: REMAINING, REQUEST, AVAILABLE and GRANTED. Include the helper once and call cooldown_init at session start. It clears all four; the first action can be ready immediately. To start with a delay, set REMAINING deliberately after initialisation.
Call cooldown_tick once per simulation update, regardless of input. It clears the previous grant pulse and decrements a positive counter, leaving zero at zero. Then supply REQUEST and AVAILABLE and call cooldown_try. Nonzero input bytes mean true. A requested action is granted only when the timer is zero and the required resource is available. On a grant, the component sets GRANTED to 1 and reloads REMAINING from PERIOD. Rejection spends no cooldown.
Reusable initialise, tick and try routines
; SPDX-License-Identifier: MIT
; Four private bytes REMAINING/REQUEST/AVAILABLE/GRANTED. PERIOD0..255.
; CCR changes; all data/address registers survive. tick once/update always;
; try commits only a requested, available, ready action (nonzero inputs true).
; GRANTED clears on tick/try. Caller owns resource reservation/effect creation
; under one mutation owner. No hardware, input, allocation or waits.
cooldown_init:
clr.b REMAINING
clr.b REQUEST
clr.b AVAILABLE
clr.b GRANTED
rts
cooldown_tick:
clr.b GRANTED
tst.b REMAINING
beq.s cooldown_tick_done
subq.b #1,REMAINING
cooldown_tick_done:
rts
cooldown_try:
clr.b GRANTED
tst.b REQUEST
beq.s cooldown_try_done
tst.b AVAILABLE
beq.s cooldown_try_done
tst.b REMAINING
bne.s cooldown_try_done
move.b #1,GRANTED
move.b #PERIOD,REMAINING
cooldown_try_done:
rts
component_end:GRANTED is a committed permission, not proof that a projectile already exists. The caller owns ammo, object-slot reservation and effect creation. Under one update owner, establish that the resource really can be used, call try, then immediately create the effect and consume the resource if granted. Recompute availability for each subsequent action. If another writer can take it between the check and creation, use an explicit reservation/commit design rather than trusting this byte as a lock.
A caller with unlimited resources can supply AVAILABLE = 1. A full object pool or empty magazine supplies zero, preserving readiness for later. This helper never allocates objects, decrements ammo, refunds a failed spawn or reads a button.
Only CCR changes; all data and address registers survive. Read GRANTED rather than carry. A direct call uses four return-address stack bytes.
Choose what counts as a request
Supply a newly pressed bit for one action per press, or a held bit for repeat while held. The timer does not choose between those controls. Input edges explain the difference; object slots make a useful resource constraint concrete.
Consume GRANTED immediately: both tick and try clear it, so another call replaces the pulse. With a positive period, a second try before another tick sees the newly active cooldown. PERIOD 0 disables rate gating and can allow several tries in one update; it does not bypass request/availability checks or limit your caller to one attempt.
The default 3 means grants at indices 0, 3, 6 and 9 with tick-before-try ordering. The custom period 5 grants at 0 and 6 for this particular every-third-update input trace. A ready timer does not queue missed input or manufacture a request when no action is being requested.
Service time independently, then offer the current request
preview_rules:
bsr cooldown_init
clr.b accepted
clr.b blocked
moveq #0,d4
trace_loop:
lea trace_requests,a0
move.b 0(a0,d4.w),REQUEST
move.b #1,AVAILABLE
ifne BROKEN
tst.b REQUEST
beq.s trace_no_tick
endc
bsr checked_tick
trace_no_tick:
bsr checked_try
tst.b GRANTED
beq.s trace_no_grant
addq.b #1,accepted
trace_no_grant:
tst.b REQUEST
beq.s trace_after_block
tst.b GRANTED
bne.s trace_after_block
addq.b #1,blocked
trace_after_block:
cmpi.l #3,d4
bne.s trace_not_three
move.b GRANTED,at_three
trace_not_three:
cmpi.l #6,d4
bne.s trace_record
move.b GRANTED,at_six
trace_record:
move.b REMAINING,d0
bsr emit
move.b REQUEST,d0
bsr emit
move.b AVAILABLE,d0
bsr emit
move.b GRANTED,d0
bsr emit
addq.l #1,d4
cmpi.l #12,d4
bne trace_loop
move.b accepted,preview
move.b REMAINING,preview+1
move.b at_three,preview+2
move.b at_six,preview+3
move.b blocked,preview+4
move.b availability_guard,preview+5
move.b #PERIOD,preview+6
move.b GRANTED,preview+7
rtsSkip timer service during pause if cooldown should freeze with simulation. If it should continue in real time, choose an elapsed-clock policy explicitly. These counts are simulation updates, not seconds or an assumed display refresh rate. Game states and restart keep a fresh-session reset separate from resume.
Include it in your project
Buildable standalone caller and include order
section code,code_c
PERIOD = 3
BROKEN = 0
include "caller.inc"
include "exercise.inc"
component_start:
include "component.inc"
include "cases.inc"
include "display.inc"
include "../turn-deadline/font.inc"
code_end:
REMAINING: dc.b 0
REQUEST: dc.b 0
AVAILABLE: dc.b 0
GRANTED: dc.b 0
accepted: dc.b 0
blocked: dc.b 0
at_three: dc.b 0
at_six: dc.b 0
availability_guard: dc.b 0
case_kind: dc.b 0
even
ready: dc.w 0
preview: dcb.b 8,0
OUTPUT: dcb.b 1335,0
even
signature: dc.b "198XCOOLDNAMIGA",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,0The A500 OCS/PAL caller cold-boots an OFS ADF with Kickstart1.3, takes over IRQ/DMA/Copper and a private bitplane, and explicitly disarms inherited sprites. Its code/data are a relocated Hunk; it does not restore the OS. The reusable helper owns none of those resources. It has one fixed state block. For independent weapons, give each its own block and explicit bindings, or use a wrapper that selects/copies one block before service and writes it back afterwards. A pointer/indexed version can avoid that copy but is a separate implementation with its own register and timing costs. Sharing one block means sharing one cooldown.
The eight hexadecimal fields are grants, final remaining count, grant at index 3, grant at index 6, blocked requests, observed availability guard, period, final grant pulse. The working row is 04 01 01 01 00 01 03 00. The availability guard comes from an additional ready-but-unavailable request that must leave both grant and timer zero.
Make it your own
custom.asm changes PERIOD from 3 to 5. This trace then grants two actions and ends ready: 02 00 00 01 02 01 05 00. Helper code and state sizes stay the same in these layouts. The component’s byte period can represent 0–255, but the maintained executions qualify 3 and 5.
Use a five-update action period
section code,code_c
PERIOD = 5
BROKEN = 0
include "caller.inc"
include "exercise.inc"
component_start:
include "component.inc"
include "cases.inc"
include "display.inc"
include "../turn-deadline/font.inc"
code_end:
REMAINING: dc.b 0
REQUEST: dc.b 0
AVAILABLE: dc.b 0
GRANTED: dc.b 0
accepted: dc.b 0
blocked: dc.b 0
at_three: dc.b 0
at_six: dc.b 0
availability_guard: dc.b 0
case_kind: dc.b 0
even
ready: dc.w 0
preview: dcb.b 8,0
OUTPUT: dcb.b 1335,0
even
signature: dc.b "198XCOOLDNAMIGA",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,0The verification fixture packs some results into one byte with a grant bit in bit 7; it asserts that the counter fits below 128 before packing. That is a test-packet limit, not a limit on the reusable counter. Qualifying larger periods needs an unpacked packet and refreshed cases/records; simply changing the helper constant does not qualify an untested configuration.
Common mistake: only tick while Fire is held
The labelled wrong caller guards cooldown_tick with REQUEST. Time stops advancing on released updates. Its row becomes 02 03 00 00 02 01 03 00: only two grants, and both earlier request points are blocked. The program completes the entire trace, so the reduced count is an observed scheduling fault.
Deliberate fault: service the timer only when input requests an action
section code,code_c
PERIOD = 3
BROKEN = 1
include "caller.inc"
include "exercise.inc"
component_start:
include "component.inc"
include "cases.inc"
include "display.inc"
include "../turn-deadline/font.inc"
code_end:
REMAINING: dc.b 0
REQUEST: dc.b 0
AVAILABLE: dc.b 0
GRANTED: dc.b 0
accepted: dc.b 0
blocked: dc.b 0
at_three: dc.b 0
at_six: dc.b 0
availability_guard: dc.b 0
case_kind: dc.b 0
even
ready: dc.w 0
preview: dcb.b 8,0
OUTPUT: dcb.b 1335,0
even
signature: dc.b "198XCOOLDNAMIGA",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,0Move tick outside that request condition, leaving try responsible for deciding whether to grant. The repaired executable matches the working executable exactly; Spectrum tape header filenames may differ while loaded CODE matches.

Cost and verification
This layout uses 96 code bytes plus four state bytes. Tick and try each take a bounded path, with no input scan, object search, wait or display transfer. Independent state blocks cost four bytes each, plus your selection/binding wrapper. These are storage/work counts, not whole-game or physical-hardware timings. Maths for Games connects countdown boundaries and saturation; The Craft explores what repeated actions feel like to the player.
Four native executions cover normal, changed period, wrong scheduling and exact repair, with Asm198x 0.0.72 / Emu198x 0.44.2 and Build198x 0.2.9 for Amiga media. Native input tables cover all 256 remaining bytes, all 256 request bytes with and without availability, and all 256 availability bytes. An independent integer model checks the 1,335-byte packet, dirty initialisation, the availability guard and every result of the twelve-update tap/release trace.
Source/tool/configuration identities, native startup, positive completion, preserved registers, loaded code, capture hashes, pixels and exact repair are checked. The larger Spectrum test table needs a bounded 30-span ROM-entry allowance; observed loading completes in span 22, with the full loaded CODE checked before execution. Deliberately altered observations must fail the gate. Firmware is hashed rather than distributed. This is logical timer/request verification, not another input-reader or projectile-spawn test, nor qualification of every possible input combination.