Skip to content

Menu Key Repeat on Amiga

A menu should move once when you press Down, pause briefly, then scroll while you hold it.

A menu should move once when you press Down, pause briefly, then scroll while you hold it. Reading the held button every update is too fast; reading only new presses never repeats. This helper adds the delay and repeat cadence between those two choices.

Amiga shows the working result 01 01 01 00 00 00 00 00.
The completed native caller shows hexadecimal state bytes. The field order below explains the result; the full verification packet retains every call.

Include, initialise and call

Bind HELD, WAS_HELD, TIMER, PULSE as 4 contiguous private bytes in that order. Include the helper once. Call input_repeat_init at the deliberate reset boundary: it clears all named bytes, including inputs. Set your starting values afterwards. Call input_repeat_step when this service is needed.

Initialise once when the menu gains control. Feed a held level every update, then consume PULSE immediately. Use a separate block for each independently repeating direction. Resolve opposite directions before service, according to your menu policy. The joystick reader provides the machine-facing input path. Select the particular held control and convert any active-low raw bit before supplying HELD; a newly pressed pulse alone cannot sustain repeat.

Supply HELD as any nonzero value for a held control, zero for release. Step replaces PULSE, normalises WAS_HELD, and retains TIMER. The first held call pulses immediately and loads REPEAT_DELAY. Later held calls decrement a positive timer; reaching zero pulses and reloads REPEAT_RATE. Release clears the history and timer. Both configured delays must be 1–255 simulation updates.

Reusable initialisation and service

component.inc
; SPDX-License-Identifier: MIT
; Menu Key Repeat. Bind these contiguous private bytes: HELD, WAS_HELD, TIMER, PULSE.
; init clears the whole block; step uses current inputs. No I/O, waits or IRQ owner.
; D0-D3/A0-A1/CCR may change; D4-D7/A2-A6 survive.
input_repeat_init:
    clr.b HELD
    clr.b WAS_HELD
    clr.b TIMER
    clr.b PULSE
    rts
input_repeat_step:
    clr.b PULSE
    tst.b HELD
    beq.s repeat_release
    tst.b WAS_HELD
    beq.s repeat_first
    tst.b TIMER
    beq.s repeat_again
    subq.b #1,TIMER
    bne.s repeat_held
repeat_again:
    move.b #REPEAT_RATE,TIMER
    bra.s repeat_pulse
repeat_first:
    move.b #REPEAT_DELAY,TIMER
repeat_pulse:
    move.b #1,PULSE
repeat_held:
    move.b #1,WAS_HELD
    rts
repeat_release:
    clr.b TIMER
    clr.b WAS_HELD
    rts
component_end:

State and ownership

HELD is an input. WAS_HELD and TIMER are persistent private history. PULSE is a one-call output. The helper reads no keyboard or controller and assumes a regular service cadence.

D0–D3/A0–A1/CCR may change; D4–D7/A2–A6 survive. A direct BSR/JSR uses four return-address stack bytes. The program is a 68000 implementation with no division instruction requirement.

This implementation has fixed symbol bindings and one mutation owner. Independent users need distinct state blocks and scoped/renamed bindings; including another copy with the same global labels is not sufficient. Sequential queries may reuse a scratch block only after the previous result has been consumed. Do not let an interrupt and the main loop mutate the same block without a separately designed ownership policy.

The standalone boot program takes over Copper/bitplane/interrupt/DMA resources, explicitly disarms all eight sprites, and does not restore Workbench. Those are diagnostic presentation choices; the helper itself owns no chipset registers.

A small caller you can adapt

The standalone program includes the helper and a separate client. Its read-only table exercises 96 valid inputs, then this client initialises once and retains state between calls. Replace the supplied input changes with your own game values; the display and packet machinery are diagnostics rather than dependencies of the component.

Buildable caller, bindings and include order

demo.asm
    section code,code_c
CUSTOM = 0
BROKEN = 0
SCORE_CAP = 65535
QUEUE_CAP = 4
REPEAT_DELAY = 4
REPEAT_RATE = 2
    include "caller.inc"
    include "exercise.inc"
component_start:
    include "component.inc"
    include "cases.inc"
    include "display.inc"
    include "../turn-deadline/font.inc"
code_end:
STATE:
HELD: dc.b 0
WAS_HELD: dc.b 0
TIMER: dc.b 0
PULSE: dc.b 0
    even
ready: dc.w 0
preview: dcb.b 8,0
OUTPUT: dcb.b 1300,0
    even
signature: dc.b "198XGAMEPLAY000",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

A separate client retaining state between calls

exercise.inc, excerpt
preview_rules:
    ifne CUSTOM
    move.b #1,HELD
    ifne BROKEN
    move.b #0,WAS_HELD
    move.b #0,TIMER
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #1,HELD
    ifne BROKEN
    move.b #0,WAS_HELD
    move.b #0,TIMER
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #1,HELD
    ifne BROKEN
    move.b #0,WAS_HELD
    move.b #0,TIMER
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #1,HELD
    ifne BROKEN
    move.b #0,WAS_HELD
    move.b #0,TIMER
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #1,HELD
    ifne BROKEN
    move.b #0,WAS_HELD
    move.b #0,TIMER
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #1,HELD
    ifne BROKEN
    move.b #0,WAS_HELD
    move.b #0,TIMER
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #1,HELD
    ifne BROKEN
    move.b #0,WAS_HELD
    move.b #0,TIMER
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #1,HELD
    ifne BROKEN
    move.b #0,WAS_HELD
    move.b #0,TIMER
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    else
    move.b #1,HELD
    ifne BROKEN
    move.b #0,WAS_HELD
    move.b #0,TIMER
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #1,HELD
    ifne BROKEN
    move.b #0,WAS_HELD
    move.b #0,TIMER
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #1,HELD
    ifne BROKEN
    move.b #0,WAS_HELD
    move.b #0,TIMER
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #1,HELD
    ifne BROKEN
    move.b #0,WAS_HELD
    move.b #0,TIMER
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #1,HELD
    ifne BROKEN
    move.b #0,WAS_HELD
    move.b #0,TIMER
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #1,HELD
    ifne BROKEN
    move.b #0,WAS_HELD
    move.b #0,TIMER
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #1,HELD
    ifne BROKEN
    move.b #0,WAS_HELD
    move.b #0,TIMER
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #1,HELD
    ifne BROKEN
    move.b #0,WAS_HELD
    move.b #0,TIMER
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    endif
    rts

The eight display fields follow HELD, WAS_HELD, TIMER, PULSE; unused display fields are zero. Each field is a hexadecimal byte. The working row is 01 01 01 00 00 00 00 00. The output is a completed diagnostic state, not a recording of movement, input or sound hardware.

Make it your own

The custom build changes the first delay from four to six updates and the repeat interval from two to three. The immediate press still pulses at index zero. The eight held calls now pulse at indices 0 and 6 rather than 0, 4 and 6.

Maintained custom configuration or caller

custom.asm
    section code,code_c
CUSTOM = 1
BROKEN = 0
SCORE_CAP = 999
QUEUE_CAP = 2
REPEAT_DELAY = 6
REPEAT_RATE = 3
    include "caller.inc"
    include "exercise.inc"
component_start:
    include "component.inc"
    include "cases.inc"
    include "display.inc"
    include "../turn-deadline/font.inc"
code_end:
STATE:
HELD: dc.b 0
WAS_HELD: dc.b 0
TIMER: dc.b 0
PULSE: dc.b 0
    even
ready: dc.w 0
preview: dcb.b 8,0
OUTPUT: dcb.b 1300,0
    even
signature: dc.b "198XGAMEPLAY000",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

The observed custom row is 01 01 02 00 00 00 00 00. The retained records qualify these named configurations; a new input domain or larger layout needs its own bounds and fresh execution checks.

Common mistake and repair

The wrong client clears WAS_HELD and TIMER before every held sample. Every update looks like a fresh press, so all eight updates pulse. Retain repeat history between calls; reset it only on intentional focus/session changes.

Deliberately wrong client selection

wrong.asm
    section code,code_c
CUSTOM = 0
BROKEN = 1
SCORE_CAP = 65535
QUEUE_CAP = 4
REPEAT_DELAY = 4
REPEAT_RATE = 2
    include "caller.inc"
    include "exercise.inc"
component_start:
    include "component.inc"
    include "cases.inc"
    include "display.inc"
    include "../turn-deadline/font.inc"
code_end:
STATE:
HELD: dc.b 0
WAS_HELD: dc.b 0
TIMER: dc.b 0
PULSE: dc.b 0
    even
ready: dc.w 0
preview: dcb.b 8,0
OUTPUT: dcb.b 1300,0
    even
signature: dc.b "198XGAMEPLAY000",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
Amiga shows the deliberate fault result 01 01 04 01 00 00 00 00.
The wrong client completes and records 01 01 04 01 00 00 00 00. Compare its state with the working row and the explained failure.

The wrong branch is in the displayed client above, selected by BROKEN. repaired.asm selects the original client again. Its executable matches demo exactly; Spectrum tape filenames can differ while loaded CODE is identical. The bad caller is a labelled experiment, not an alternative recommended implementation.

Cost and verification

The measured helper occupies 114 code bytes plus 4 named state bytes in this layout. Return addresses, alignment, tables and display diagnostics are additional. The sizes cover init and step together, not the whole standalone program.

One constant-size state machine runs per control per update. No busy wait or input scan occurs here. Very short intervals feel fast only if the menu is actually serviced at the intended rate.

A faster held-scroll stage can shorten the repeat interval after a threshold, but needs another counter and a clear acceleration policy. A real-time repeat service can use elapsed ticks instead; this version deliberately counts service calls and freezes when they stop.

Four native runs use A500 OCS PAL with Kickstart 1.3 and an OFS boot disk; Asm198x 0.0.72 and Emu198x 0.44.2, with Build198x 0.2.9 for disk mastering. Every run checks 96 table results, dirty initialisation and every state in a separate sequential trace against an independent integer model. This is boundary/varied-input qualification, not exhaustive coverage of every byte combination. Source/tool/firmware hashes, native startup and completion, preserved-register sentinels, immutable loaded code, actual helper extent, capture bytes and pixels, and exact repair are checked. Deliberately changed observations must fail. Firmware is hashed, never distributed.

Related reusable technique · Maths for Games · The Craft.

Source and native evidence.