Skip to content

A Bounded Event Queue on Amiga

When three things happen before the game gets around to handling them, one event byte can only remember the last one.

When three things happen before the game gets around to handling them, one event byte can only remember the last one. A small FIFO keeps their order. This queue has an explicit full result, so a burst cannot silently overwrite an event that still matters.

Amiga shows the working result 03 03 00 33 02 01 11 22.
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 HEAD, TAIL, COUNT, VALUE, MODE, OK, B0, B1, B2, B3 as 10 contiguous private bytes in that order. Include the helper once. Call event_queue_init at the deliberate reset boundary: it clears all named bytes, including inputs. Set your starting values afterwards. Call event_queue_step when this service is needed.

Initialise once, then offer each event and check OK. If a push fails, choose a game policy: retry, discard a dispensable effect, or stop producing work. Do not pretend the event was queued. Pop until OK becomes zero, with a per-update work budget if producers could keep adding events.

HEAD and TAIL are indices into contiguous B0–B3. COUNT is the number of occupied entries. MODE 1 pushes VALUE, MODE 2 pops into VALUE; other modes do nothing. OK is replaced with 1 on success or 0 on rejection. A full push and an empty pop preserve the queue and VALUE. Use only state produced by init/step: indices are below QUEUE_CAP, COUNT is 0–QUEUE_CAP and TAIL equals (HEAD + COUNT) modulo capacity.

Reusable initialisation and service

component.inc
; SPDX-License-Identifier: MIT
; A Bounded Event Queue. Bind these contiguous private bytes: HEAD, TAIL, COUNT, VALUE, MODE, OK, B0, B1, B2, B3.
; 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.
event_queue_init:
    clr.b HEAD
    clr.b TAIL
    clr.b COUNT
    clr.b VALUE
    clr.b MODE
    clr.b OK
    clr.b B0
    clr.b B1
    clr.b B2
    clr.b B3
    rts
event_queue_step:
    clr.b OK
    move.b MODE,d0
    cmp.b #1,d0
    beq.s queue_push
    cmp.b #2,d0
    bne.s queue_done
    tst.b COUNT
    beq.s queue_done
    moveq #0,d0
    move.b HEAD,d0
    lea B0,a0
    move.b 0(a0,d0.w),VALUE
    addq.b #1,d0
    cmp.b #QUEUE_CAP,d0
    blo.s queue_pop_index
    moveq #0,d0
queue_pop_index:
    move.b d0,HEAD
    subq.b #1,COUNT
    bra.s queue_success
queue_push:
    cmp.b #QUEUE_CAP,COUNT
    bhs.s queue_done
    moveq #0,d0
    move.b TAIL,d0
    lea B0,a0
    move.b VALUE,0(a0,d0.w)
    addq.b #1,d0
    cmp.b #QUEUE_CAP,d0
    blo.s queue_push_index
    moveq #0,d0
queue_push_index:
    move.b d0,TAIL
    addq.b #1,COUNT
queue_success:
    move.b #1,OK
queue_done:
    rts
component_end:

State and ownership

Six control/input/output bytes precede four reserved buffer bytes. A queue stores byte event IDs, not payloads or pointers. Popped buffer cells retain stale data; COUNT decides which entries are live. Head equals tail can mean either empty or full, so COUNT must be retained.

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:
HEAD: dc.b 0
TAIL: dc.b 0
COUNT: dc.b 0
VALUE: dc.b 0
MODE: dc.b 0
OK: dc.b 0
B0: dc.b 0
B1: dc.b 0
B2: dc.b 0
B3: 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 #17,VALUE
    move.b #1,MODE
    ifne BROKEN
    cmp.b #1,MODE
    bne.s trace_skip_reset_0_custom
    move.b #0,HEAD
    move.b #0,TAIL
    move.b #0,COUNT
trace_skip_reset_0_custom:
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #34,VALUE
    move.b #1,MODE
    ifne BROKEN
    cmp.b #1,MODE
    bne.s trace_skip_reset_1_custom
    move.b #0,HEAD
    move.b #0,TAIL
    move.b #0,COUNT
trace_skip_reset_1_custom:
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #51,VALUE
    move.b #1,MODE
    ifne BROKEN
    cmp.b #1,MODE
    bne.s trace_skip_reset_2_custom
    move.b #0,HEAD
    move.b #0,TAIL
    move.b #0,COUNT
trace_skip_reset_2_custom:
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #2,MODE
    ifne BROKEN
    cmp.b #1,MODE
    bne.s trace_skip_reset_3_custom
    move.b #0,HEAD
    move.b #0,TAIL
    move.b #0,COUNT
trace_skip_reset_3_custom:
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #2,MODE
    ifne BROKEN
    cmp.b #1,MODE
    bne.s trace_skip_reset_4_custom
    move.b #0,HEAD
    move.b #0,TAIL
    move.b #0,COUNT
trace_skip_reset_4_custom:
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #2,MODE
    ifne BROKEN
    cmp.b #1,MODE
    bne.s trace_skip_reset_5_custom
    move.b #0,HEAD
    move.b #0,TAIL
    move.b #0,COUNT
trace_skip_reset_5_custom:
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    else
    move.b #17,VALUE
    move.b #1,MODE
    ifne BROKEN
    cmp.b #1,MODE
    bne.s trace_skip_reset_0
    move.b #0,HEAD
    move.b #0,TAIL
    move.b #0,COUNT
trace_skip_reset_0:
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #34,VALUE
    move.b #1,MODE
    ifne BROKEN
    cmp.b #1,MODE
    bne.s trace_skip_reset_1
    move.b #0,HEAD
    move.b #0,TAIL
    move.b #0,COUNT
trace_skip_reset_1:
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #51,VALUE
    move.b #1,MODE
    ifne BROKEN
    cmp.b #1,MODE
    bne.s trace_skip_reset_2
    move.b #0,HEAD
    move.b #0,TAIL
    move.b #0,COUNT
trace_skip_reset_2:
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #2,MODE
    ifne BROKEN
    cmp.b #1,MODE
    bne.s trace_skip_reset_3
    move.b #0,HEAD
    move.b #0,TAIL
    move.b #0,COUNT
trace_skip_reset_3:
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #2,MODE
    ifne BROKEN
    cmp.b #1,MODE
    bne.s trace_skip_reset_4
    move.b #0,HEAD
    move.b #0,TAIL
    move.b #0,COUNT
trace_skip_reset_4:
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #2,MODE
    ifne BROKEN
    cmp.b #1,MODE
    bne.s trace_skip_reset_5
    move.b #0,HEAD
    move.b #0,TAIL
    move.b #0,COUNT
trace_skip_reset_5:
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    endif
    rts

The eight display fields follow HEAD, TAIL, COUNT, VALUE, MODE, OK, B0, B1; the full packet also retains B2, B3. Each field is a hexadecimal byte. The working row is 03 03 00 33 02 01 11 22. The output is a completed diagnostic state, not a recording of movement, input or sound hardware.

Make it your own

The custom build lowers QUEUE_CAP from four to two while retaining the four-byte storage layout. A third push is rejected, and the two original events still pop in order. This demonstrates a capacity limit rather than a RAM-saving layout change.

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:
HEAD: dc.b 0
TAIL: dc.b 0
COUNT: dc.b 0
VALUE: dc.b 0
MODE: dc.b 0
OK: dc.b 0
B0: dc.b 0
B1: dc.b 0
B2: dc.b 0
B3: 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 00 00 00 22 02 00 11 22. 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 resets HEAD, TAIL and COUNT before every push. The later event replaces the history of earlier submissions. The first pop now returns 51 instead of 17. Initialise at session creation or an intentional flush, not when submitting an event.

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:
HEAD: dc.b 0
TAIL: dc.b 0
COUNT: dc.b 0
VALUE: dc.b 0
MODE: dc.b 0
OK: dc.b 0
B0: dc.b 0
B1: dc.b 0
B2: dc.b 0
B3: 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 00 33 02 00 33 00.
The wrong client completes and records 01 01 00 33 02 00 33 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 192 code bytes plus 10 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.

Each push/pop touches one buffer cell and a few counters, without moving the other entries. This buys constant work at the cost of indices, count and explicit overflow handling. Draining the entire queue is a caller loop and needs its own budget.

Larger queues need wider indices or a bounded capacity and updated storage. A power-of-two capacity can use a mask to wrap, but this version uses comparison so its policy is visible. An IRQ producer and foreground consumer need a separately designed single-producer/single-consumer protocol or critical section; this is a single-owner queue.

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.