Code status: the maintained matrix reader is reused by executed 48K callers. The single-code adapter is an additional caller; its priority and state contracts are shown here.
Audit execution record · Scope and remaining checks
Overview
Read keyboard input by polling the I/O ports directly. The Spectrum’s keyboard is arranged in a matrix of 8 rows, each accessible via a specific port address. Keys are active low (0 = pressed). Use when you need responsive input without relying on the ROM.
Code
; QAOP + Space. One bit per held control: U D L R Fire = 1 2 4 8 16.
; read_controls: returns A and controls; clobbers AF/BC, preserves DE/HL/IX/IY.
; Read once per update. Interrupt code must not use this state or these ports.
read_controls:
ld c,0
ld a,$fb
in a,($fe)
bit 0,a
jr nz,input_no_q
set 0,c
input_no_q:
ld a,$fd
in a,($fe)
bit 0,a
jr nz,input_no_a
set 1,c
input_no_a:
ld a,$df
in a,($fe)
bit 1,a
jr nz,input_no_o
set 2,c
input_no_o:
bit 0,a
jr nz,input_no_p
set 3,c
input_no_p:
ld a,$7f
in a,($fe)
bit 0,a
jr nz,input_no_space
set 4,c
input_no_space:
ld a,c
ld (controls),a
ret
controls: defb 0For a menu that wants one code rather than simultaneous bits, include this adapter as well:
; Include the maintained framework/input-to-sound/input.inc once.
; read_keyboard: A and key_pressed = 0 none,1 Q,2 A,3 O,4 P,5 Space.
; Priority is Q,A,O,P,Space; use controls for simultaneous held controls.
; Changes AF/BC; preserves DE/HL/IX/IY. No key is a held state, not an edge.
read_keyboard:
call read_controls
ld b,a
xor a
bit 0,b
jr z,keyboard_not_q
ld a,1
jr keyboard_store
keyboard_not_q:
bit 1,b
jr z,keyboard_not_a
ld a,2
jr keyboard_store
keyboard_not_a:
bit 2,b
jr z,keyboard_not_o
ld a,3
jr keyboard_store
keyboard_not_o:
bit 3,b
jr z,keyboard_not_p
ld a,4
jr keyboard_store
keyboard_not_p:
bit 4,b
jr z,keyboard_store
ld a,5
keyboard_store:
ld (key_pressed),a
ret
key_pressed: defb 0Usage:
main_loop:
halt ; Wait for frame
call read_keyboard
ld a, (key_pressed)
or a
jr z, main_loop ; No key pressed
cp 1
jr z, handle_up
cp 2
jr z, handle_down
; ... etc
jr main_loopTrade-offs
| Aspect | Cost |
|---|---|
| CPU | Not measured for this caller; include the selected input, drawing and scheduling costs |
| Memory | Assemble the selected routines and caller to obtain code and state sizes |
| Limitation | No debouncing (keys may auto-repeat) |
When to use: Any game needing keyboard input. ROM-free approach works on all Spectrums.
When to avoid: When you need debounced input for menus or single-press detection.
Keyboard Matrix Reference
| Port | Bit 0 | Bit 1 | Bit 2 | Bit 3 | Bit 4 |
|---|---|---|---|---|---|
| $F7 | 1 | 2 | 3 | 4 | 5 |
| $EF | 0 | 9 | 8 | 7 | 6 |
| $FB | Q | W | E | R | T |
| $FD | A | S | D | F | G |
| $DF | P | O | I | U | Y |
| $BF | Enter | L | K | J | H |
| $7F | Space | Sym | M | N | B |
| $FE | Shift | Z | X | C | V |
Keyboard ghosting
The Spectrum’s keyboard is a passive matrix: switches connect eight half-rows to five shared input bits. A phantom key can appear when three pressed keys occupy three corners of a rectangle in that matrix. The closed switches give the scan a path to the fourth corner, even though that key is released. Three keys in one half-row alone do not create this phantom; neither do two keys alone.
For example, Q and P share bit 0, while P and O share a half-row. Holding Q, P and O can therefore also report W, the fourth corner. This follows from the wiring in the Sinclair service manual, section 1.8. Choosing keys in different rows alone does not prevent ghosting. Check the actual simultaneous chords your game accepts, including direction plus fire; the familiar QAOP/Space layout is not a guarantee for every chord or for other keys read alongside it.
Reading multiple keys at once
The pattern above returns the first key it finds. To check all directions plus fire on the same frame, store each result rather than returning early:
read_all_keys:
ld a, 0
ld (key_state), a
; Check Q (up): row $FB, bit 0
ld a, $fb
in a, ($fe)
bit 0, a
jr nz, .not_q
ld hl, key_state
set 0, (hl) ; Bit 0 of state = up
.not_q:
; ... continue for each key, setting different bits ...
ret
key_state: defb 0 ; Bit 0=up, 1=down, 2=left, 3=right, 4=fireRelated
Patterns: Game Loop (HALT)
Vault: ULA — port $FE keyboard matrix | ZX Spectrum
One sample, two representations
read_controls returns A and controls as simultaneous held bits: Q/A/O/P/Space are 1/2/4/8/16. read_keyboard calls it once and returns A plus key_pressed as 0 for none, 1/2/3/4 for directions and 5 for Space. If several are held, its priority is Q, A, O, P, Space. A no-key result is explicitly zero, never the last raw port value.
Both change AF/BC and preserve DE/HL/IX/IY. State must be writable; a valid stack is required. The result is held state, not a new press. Use the edge detector for one action per press. If the frame already sampled input, adapt its stored controls rather than reading the matrix again.
Keep held, pressed and released controls separate
Add release state to the maintained press detector
; Call after read_controls once per update, alongside the maintained presses.inc.
; AF/BC change; DE/HL/IX/IY survive. One private released byte.
detect_control_edges:
ld a,(previous_controls)
ld (released_controls),a
call detect_presses
ld a,(controls)
ld b,a
ld a,(released_controls)
call release_bits
ld (released_controls),a
ret
; Pure entry: A=previous, B=current normalised five-bit masks.
; A=released; B/DE/HL/IX/IY survive. C/flags change.
release_bits:
ld c,a
ld a,b
cpl
and c
ret
edges_code_end:
released_controls: defb 0The unchanged press detector
; Call after read_controls, once per update. AF/B change; other registers survive.
; Initialise previous_controls to zero, or seed it with controls on scene entry.
detect_presses:
ld a,(previous_controls)
cpl
ld b,a
ld a,(controls)
and b
ld (new_presses),a
ld a,(controls)
ld (previous_controls),a
ret
previous_controls: defb 0
new_presses: defb 0The tape entry and include order
org $8000
ACTION_MASK equ $10
include "edges-caller.inc"
include "../../framework/input-to-sound/input.inc"
include "../../framework/input-to-sound/presses.inc"
include "edges.inc"
include "edges-display.inc"
POLL_ROUTINE:
jp detect_control_edges
end startSample once; keep action mapping in the caller
; 48K PAL, stock ROM/IY retained for IM1/CLS. Own stack and screen; reset to leave.
start:
di
ld sp,$ff00
call $0daf
xor a
ld (previous_controls),a
ld (released_controls),a
ld (actions),a
ld hl,trace
ld b,4
clear_trace:
ld (hl),a
inc hl
djnz clear_trace
ld a,165
ld (ready),a
wait_frame:
ei
halt
di
sample:
call read_controls
call POLL_ROUTINE
ld a,(new_presses)
or a
jr z,no_press
ld (last_new),a
ld a,(press_events)
inc a
ld (press_events),a
no_press:
ld a,(released_controls)
or a
jr z,no_release
ld (last_release),a
ld a,(release_events)
inc a
ld (release_events),a
no_release:
ld a,(new_presses)
and ACTION_MASK
jr z,show_state
ld a,(actions)
inc a
ld (actions),a
show_state:
ld de,$4000
ld a,(controls)
call show_byte
ld a,32
call show_char
ld a,(new_presses)
call show_byte
ld a,32
call show_char
ld a,(released_controls)
call show_byte
ld a,32
call show_char
ld a,(actions)
call show_byte
frame_done:
jr wait_frame
trace:
last_new: defb 0
last_release: defb 0
press_events: defb 0
release_events: defb 0
actions: defb 0
ready: defb 0QAOP and Space are represented as normalised held bits: Up=1, Down=2, Left=4, Right=8, Fire=16. The maintained matrix reader and presses.inc are reused unchanged. edges.inc adds releases as previous AND NOT current, retaining their interfaces.
Include input.inc, presses.inc, edges.inc once, in that order. After read_controls, call detect_control_edges once per update. controls is held, new_presses is newly pressed, released_controls is newly released. AF/BC change; DE/HL/IX/IY survive. The wrapper owns one extra release byte. Its pure release_bits entry takes A=previous, B=current normalised five-bit masks and returns A=released; C/flags change, B/DE/HL/IX/IY survive. One state set is not re-entrant. These are bit sets, not a key-priority code.
Initialise previous_controls/released_controls to zero before the first sample. Held keys on entry then count as presses. To ignore them when a scene opens, read/detect once and discard the result, then let the next sample compare against it. Share one sample with all consumers: polling twice consumes an edge. Short pulses, ghosting, switch bounce, deliberate repeat and opposite-direction policy remain separate issues. The accepted configuration exercises QAOP/Space; it is not a guarantee for every electrical keyboard chord.
Run make ASM198X=/path/to/asm198x. LOAD the generated build/edges-demo.tap through the normal 48K ROM. END start makes its BASIC loader enter the caller through USR. The payload is 297 bytes at $8000; the loader reserves RAM below it. The caller owns a stack below $FF00 and the screen, resets its state, retains the stock IY/IM1 environment and uses EI/HALT/DI around the normal ROM frame interrupt. Reset to leave; it does not promise a BASIC return. The helper itself owns no port, IRQ or display resource. The reader still reads the four relevant matrix half-rows, once each; O/P share one read.

Four hexadecimal bytes show held, pressed, released and action count. Space spends one action per press. Diagnostic latches/counters retain one-update edge pulses after the beam has drawn a frame; the current pressed/released display can already be zero in a stable screenshot. The normal sample sequence ends with count 03, including an all-five-key chord; native latches record which bits arrived together.
Make it your own
Map O rather than Space to an action
org $8000
ACTION_MASK equ $04
include "edges-caller.inc"
include "../../framework/input-to-sound/input.inc"
include "../../framework/input-to-sound/presses.inc"
include "edges.inc"
include "edges-display.inc"
POLL_ROUTINE:
jp detect_control_edges
end startedges-custom.asm changes ACTION_MASK from $10 (Space) to $04 (O/Left), with unchanged code/state/payload sizes. The mapping is separate from electrical reading and edge detection. A wider action mask counts one event if any selected bit is new; counting per button is another caller policy.
Common mistake
Deliberately broken: erase the previous mask
org $8000
ACTION_MASK equ $10
include "edges-caller.inc"
include "../../framework/input-to-sound/input.inc"
include "../../framework/input-to-sound/presses.inc"
include "edges.inc"
include "edges-display.inc"
POLL_ROUTINE:
xor a
ld (previous_controls),a
jp detect_control_edges
end startThe explicitly labelled edges-wrong.asm clears previous_controls immediately before detecting edges. A held Space becomes new every update, the action count rises during the hold and release events disappear. Restore the normal jump to detect_control_edges. The independently executed repair has the same payload as the working version. The fault adds four bytes. Do not use the wrong build as the normal tape.

Costs and qualification
The wrapper/pure release leaf occupy 28 instruction bytes plus one release byte. The existing reader/press detector retain their own three state bytes; the caller adds four diagnostic bytes, an action counter and ready marker. The full 297-byte working payload includes its tables, code and state. Code size is not a frame-budget or contended-memory timing claim. Normalised five-bit state is a precondition for the pure helper.
The caller renders the normalised state
show_byte:
push af
rrca
rrca
rrca
rrca
and 15
call digit
call show_char
pop af
and 15
call digit
jp show_char
digit:
cp 10
jr c,digit_number
add a,7
digit_number:
add a,48
ret
; A=ASCII, DE=top scanline cell. CHARS points 256 bytes before glyph 32.
; AF/BC/HL clobbered; DE advances one character column.
show_char:
push de
ld l,a
ld h,0
add hl,hl
add hl,hl
add hl,hl
ld bc,($5C36)
add hl,bc
ld b,8
show_scan:
ld a,(hl)
ld (de),a
inc hl
inc d
djnz show_scan
pop de
inc e
retSix ROM-tape-loaded cases on Emu198x 0.44.2/Asm198x 0.0.72 and the recorded stock 48K PAL ROM cover normal/custom/wrong/repaired callers and a second guest-owned caller with all 1,024 previous/current pairs. A deliberately wrong OR operation completes its guards but fails independent bit-set arithmetic. Native host keys, event counters, complete immutable TAP image, bitmap/font/PNG pixels and source/tool/ROM identities are retained. Thirteen corruptions/pixel controls are required to fail. Read scripts/verify-spectrum-input-edges.py and check-spectrum-input-edges.py. These are configuration-specific native observations, not physical-hardware or all-ROM/region qualification.
Primary source: Sinclair ZX Spectrum BASIC Programming (1982), chapter 23, keyboard port half-rows and active-low input. The state/arbitration policies are this implementation’s choices. Bit-set difference, sampling and one-event actions link naturally to Maths for Games and the shared clock/input discussion.
Maintained code and contract · Native evidence/configuration. Maths for Games explains bit sets and sampling; clock and loop choices keeps input events separate from progression.