Skip to content

Wrap and Skip Menu Options on C64

A menu should wrap cleanly at either end and skip options the player cannot choose.

A menu should wrap cleanly at either end and skip options the player cannot choose. Keep the selection rule separate from drawing and key-repeat timing, and the same menu can work with a keyboard, joystick or controller.

C64 shows the working diagnostic row 00 03 05 01 01 01 00 00.
A native completed caller shows its state as hexadecimal bytes. The field order below explains the row; the full packet retains every call.

Include, initialise and call

Reserve SELECTED, OPTION_COUNT, ENABLED, DELTA, MOVED, VALID as 6 contiguous private bytes in that order. Include the helper once. Call menu_navigation_init at the intended reset boundary: it clears every named byte, including input fields. Set the starting values afterwards, then call menu_navigation_step for each intended service.

Initialise once, set the option count, mask and starting selection, then offer one navigation action at a time. Use the existing input-repeat routine to decide when held input should produce another action. Resolve opposed directions before supplying DELTA. A disabled starting selection can move to an enabled candidate; the routine does not draw or activate the option.

OPTION_COUNT is 1–8 and SELECTED must be a valid index below it. ENABLED is a bit mask: bit zero enables option zero, and bits above the count are ignored. DELTA is 1 forward, 255 backward or zero for no movement. Invalid count/index/delta preserves selection and clears VALID/MOVED. A valid call sets VALID and searches at most OPTION_COUNT candidates, wrapping and choosing the first enabled option. With none enabled it leaves the selection unchanged; MOVED reports an actual selection change.

Reusable initialisation and service

6502.inc
; SPDX-License-Identifier: MIT
; Wrap and Skip Menu Options. Contiguous private bytes: SELECTED, OPTION_COUNT, ENABLED, DELTA, MOVED, VALID.
; init clears all fields; set inputs afterwards. No I/O, waits or IRQ ownership.
; A/X/flags change; Y and decimal mode survive. D=0 for binary arithmetic.
menu_navigation_init:
    lda #0
    sta SELECTED
    sta OPTION_COUNT
    sta ENABLED
    sta DELTA
    sta MOVED
    sta VALID
    rts
menu_navigation_step:
    lda #0
    sta MOVED
    sta VALID
    lda OPTION_COUNT
    beq menu_done
    cmp #9
    bcs menu_done
    lda SELECTED
    cmp OPTION_COUNT
    bcs menu_done
    lda DELTA
    beq menu_still
    cmp #1
    beq menu_start
    cmp #255
    bne menu_done
menu_start:
    lda #1
    sta VALID
    ldx SELECTED
    lda OPTION_COUNT
    pha
menu_search:
    lda DELTA
    cmp #1
    bne menu_previous
    inx
    cpx OPTION_COUNT
    bcc menu_test
    ldx #0
    jmp menu_test
menu_previous:
    cpx #0
    bne menu_dec
    ldx OPTION_COUNT
menu_dec:
    dex
menu_test:
    lda menu_masks,x
    and ENABLED
    bne menu_found
    pla
    sec
    sbc #1
    beq menu_done
    pha
    jmp menu_search
menu_found:
    pla
    cpx SELECTED
    beq menu_done
    stx SELECTED
    inc MOVED
    rts
menu_still:
    inc VALID
menu_done:
    rts
component_end:

Caller-owned immutable lookup/encoded data

data.inc
data_start:
menu_masks:
    !byte 1,2,4,8,16,32,64,128
data_end:

State, registers and ownership

Six bytes, including the caller’s direction input and the replaced MOVED/VALID flags. A no-movement DELTA does not repair a disabled selected option. A valid count/layout is separate from the question of whether an option is currently enabled.

A/X/flags may change; Y and decimal mode survive. Clear decimal mode before binary arithmetic; the caller uses CLD. A direct call needs two return-address stack bytes. This helper can also use one temporary stack byte, so allow three bytes for its call, excluding its caller and interrupts.

The implementation binds fixed symbols and has one mutation owner. Independent users need separate state blocks and scoped/renamed labels, including routine labels; sharing the same bindings means sharing the state. Do not let an interrupt and the main loop mutate the same block without an explicit ownership policy.

The standalone PRG owns screen/font/colour RAM, disables interrupt sources and does not restore BASIC. Its private scratch uses $70–$76 and the state binding starts at $80; its diagnostic output starts at $C000.

A caller you can adapt

The buildable program supplies bindings and include order. Its separate client retains state between calls. checked_step calls the public menu_navigation_step entry and checks register sentinels; emit_state records diagnostics. Your game can call the public entry directly, remove the diagnostics and consume the documented outputs.

Bindings, buildable caller and include order

demo.asm
SELECTED=$80
OPTION_COUNT=$81
ENABLED=$82
DELTA=$83
MOVED=$84
VALID=$85
STATE=$80
input_ptr=$70
cases_left=$72
ptr=$74
saved_y=$76
OUTPUT=$c000
preview=OUTPUT+1400
ready=OUTPUT+1408
CUSTOM=0
BROKEN=0
COYOTE_STEPS=4
BUFFER_STEPS=4
TEXT_CAP=8
PROPERTY_COUNT=16
STREAM_BYTES=13
CRC_POLY=7
!source "caller.inc"
!source "exercise.inc"
component_start:
!source "component.inc"
!source "data.inc"
!source "cases.inc"
hex_font:
    !byte 0,0,0,0,0,0,0,0,0,56,40,40,40,56,0,0,0,16,48,16,16,56,0,0,0,56,8,56,32,56,0,0,0,56,8,56,8,56,0,0,0,40,40,56,8,8,0,0,0,56,32,56,8,56,0,0,0,56,32,56,40,56,0,0,0,56,8,8,8,8,0,0,0,56,40,56,40,56,0,0,0,56,40,56,8,56,0,0,0,16,40,56,40,40,0,0,0,48,40,48,40,48,0,0,0,56,32,32,32,56,0,0,0,48,40,40,40,48,0,0,0,56,32,48,32,56,0,0,0,56,32,48,32,32,0,0
code_end:

A separate client retaining state between calls

exercise.inc, excerpt
preview_rules:
!if CUSTOM {
    lda #2
    sta SELECTED
    lda #3
    sta OPTION_COUNT
    lda #7
    sta ENABLED
    lda #255
    sta DELTA
!if BROKEN {
    lda SELECTED
    clc
    adc DELTA
    sta SELECTED
    lda #1
    sta MOVED
    lda #1
    sta VALID
} else {
    jsr checked_step
}
    jsr emit_state
} else {
    lda #2
    sta SELECTED
    lda #3
    sta OPTION_COUNT
    lda #5
    sta ENABLED
    lda #1
    sta DELTA
!if BROKEN {
    lda SELECTED
    clc
    adc DELTA
    sta SELECTED
    lda #1
    sta MOVED
    lda #1
    sta VALID
} else {
    jsr checked_step
}
    jsr emit_state
}
    rts

The display fields are SELECTED, OPTION_COUNT, ENABLED, DELTA, MOVED, VALID; remaining display fields are zero. Each is one hexadecimal byte. The normal row is 00 03 05 01 01 01 00 00. This is a completed state observation, not a recording of an actual character jumping, a camera scrolling or an asset being displayed.

Make it your own

The normal example starts at option two in a three-option menu with only options zero and two enabled, then wraps forward to zero. The custom caller enables all three and requests a backward move, selecting option one.

Maintained custom configuration or caller

custom.asm
SELECTED=$80
OPTION_COUNT=$81
ENABLED=$82
DELTA=$83
MOVED=$84
VALID=$85
STATE=$80
input_ptr=$70
cases_left=$72
ptr=$74
saved_y=$76
OUTPUT=$c000
preview=OUTPUT+1400
ready=OUTPUT+1408
CUSTOM=1
BROKEN=0
COYOTE_STEPS=2
BUFFER_STEPS=2
TEXT_CAP=4
PROPERTY_COUNT=16
STREAM_BYTES=13
CRC_POLY=49
!source "caller.inc"
!source "exercise.inc"
component_start:
!source "component.inc"
!source "data.inc"
!source "cases.inc"
hex_font:
    !byte 0,0,0,0,0,0,0,0,0,56,40,40,40,56,0,0,0,16,48,16,16,56,0,0,0,56,8,56,32,56,0,0,0,56,8,56,8,56,0,0,0,40,40,56,8,8,0,0,0,56,32,56,8,56,0,0,0,56,32,56,40,56,0,0,0,56,8,8,8,8,0,0,0,56,40,56,40,56,0,0,0,56,40,56,8,56,0,0,0,16,40,56,40,40,0,0,0,48,40,48,40,48,0,0,0,56,32,32,32,56,0,0,0,48,40,40,40,48,0,0,0,56,32,48,32,56,0,0,0,56,32,48,32,32,0,0
code_end:

The custom row is 01 03 07 FF 01 01 00 00. These records qualify the named builds and inputs; another layout, format or input domain needs explicit bounds and fresh execution evidence.

Common mistake and repair

The wrong client simply adds DELTA to SELECTED. Moving forward from the last index two produces invalid index three, because byte wrap is not menu wrap. Bound the index by the option count and search the enabled candidates.

Select the deliberately wrong client

wrong.asm
SELECTED=$80
OPTION_COUNT=$81
ENABLED=$82
DELTA=$83
MOVED=$84
VALID=$85
STATE=$80
input_ptr=$70
cases_left=$72
ptr=$74
saved_y=$76
OUTPUT=$c000
preview=OUTPUT+1400
ready=OUTPUT+1408
CUSTOM=0
BROKEN=1
COYOTE_STEPS=4
BUFFER_STEPS=4
TEXT_CAP=8
PROPERTY_COUNT=16
STREAM_BYTES=13
CRC_POLY=7
!source "caller.inc"
!source "exercise.inc"
component_start:
!source "component.inc"
!source "data.inc"
!source "cases.inc"
hex_font:
    !byte 0,0,0,0,0,0,0,0,0,56,40,40,40,56,0,0,0,16,48,16,16,56,0,0,0,56,8,56,32,56,0,0,0,56,8,56,8,56,0,0,0,40,40,56,8,8,0,0,0,56,32,56,8,56,0,0,0,56,32,56,40,56,0,0,0,56,8,8,8,8,0,0,0,56,40,56,40,56,0,0,0,56,40,56,8,56,0,0,0,16,40,56,40,40,0,0,0,48,40,48,40,48,0,0,0,56,32,32,32,56,0,0,0,48,40,40,40,48,0,0,0,56,32,48,32,56,0,0,0,56,32,48,32,32,0,0
code_end:
C64 shows the deliberate fault row 03 03 05 01 01 01 00 00.
The wrong client completes with 03 03 05 01 01 01 00 00. Its code and the explanation above identify the cause; the capture observes the result.

The BROKEN branch is visible in the client. repaired.asm restores the normal selection; its executable matches demo exactly. Spectrum tape filenames may differ while loaded CODE remains identical. The fault is a labelled experiment, not a suggested alternative.

Cost, limits and verification

The measured layout has 109 code bytes, 8 immutable data bytes and 6 private state bytes. Alignment, stack usage, diagnostic tables and display machinery are additional. The extents come from the loaded program’s symbols, not from counting source lines.

At most eight candidate tests. The 6502/Z80 examples use an eight-byte mask table; 68000 tests a register bit directly and needs no table. Keeping a count bounds the search even when every option is disabled.

More than eight options need a larger availability structure and wider or bounded indices. Two-dimensional layouts need an explicit neighbour/navigation policy. Selection and activation should remain distinct, especially when a key that opened a menu is still held.

Four native runs use PAL C64 with stock BASIC/KERNAL PRG startup and RUN; Asm198x 0.0.72 and Emu198x 0.44.2. Each checks 64 boundary/varied/error cases, dirty initialisation and every state of a separate sequential client against an independent integer model. This is not exhaustive coverage of every possible byte combination. Source/tool/firmware hashes, immutable loaded code/data, native startup/completion, preserved-register sentinels, helper/data sizes, screenshot bytes/pixels and exact repair are checked. Deliberately altered observations must fail. Firmware is hashed, never distributed.

Related technique · Maths for Games · The Craft.

Source and native evidence.