Skip to content

Controller Reading

Sample eight controller buttons once, distinguish holds, presses and releases, and keep the DMC and strobe contracts explicit.

The NES controller sends eight button states through one serial input. Strobe once, read eight times and rotate each returned bit into a byte. After eight shifts the previous byte has been shifted out, so it does not need clearing first.

Include, initialise and sample

Include framework/input-to-sound/input.inc once. Its controls byte belongs in writable RAM; the ca65 caller supplies the BSS segment. Set up the stack and APU first, and leave DMC sample DMA disabled for this simple reader.

input.inc
; Controller 1, ca65 syntax. Allocate state in writable RAM.
; A=$80 B=$40 Select=$20 Start=$10 Up=$08 Down=$04 Left=$02 Right=$01.
; read_controls changes A/X/flags, preserves Y. Call once per update.
; This simple reader assumes no DMC sample DMA; it is not a DMC-safe reader.
.segment "BSS"
controls: .res 1
.segment "CODE"
read_controls:
    lda #1
    sta $4016
    lda #0
    sta $4016
    ldx #8
input_shift:
    lda $4016
    lsr
    rol controls
    dex
    bne input_shift
    rts

read_controls changes A/X and flags, preserves Y and stores held input in controls. Its masks are:

Button Mask
A / B $80 / $40
Select / Start $20 / $10
Up / Down $08 / $04
Left / Right $02 / $01

Call once at the chosen update point and let movement, menus and press detection consume the same sample. Holding A keeps its bit set; this reader does not turn that into a single new press.

The complete caller shows reset, frame ownership and use of the sampled byte. Its NMI announces a frame; the main loop reads the controller and advances the game.

Ownership and limits

Writing the strobe to $4016 latches both controllers. A two-controller reader should latch once and consume both serial streams under one owner. Two independent readers that strobe at unrelated times can disturb each other.

DMC sample DMA can interfere with serial reads on relevant NES hardware. This reader’s contract excludes it. Repeated reads need a separate bounded timing and failure policy; merely naming a retry loop “safe” does not establish correctness. Use and verify a DMC-compatible implementation before combining these facilities.

Code status: maintained reader reused by executed NTSC NROM callers. Their real-input checks establish press, hold and release on that configuration with DMC disabled. This is not a DMC workaround or original-hardware qualification.

Spend one action, keep a hold, or react to release

A is held for movement or charging; a new A press can spend one action; releasing A can finish that charge. These are three different events from one serial input sample. The maintained input.inc and presses.inc remain unchanged. edges.inc adds released bits as previous AND NOT current.

Include and call

Add released state to the maintained reader

edges.inc
; Include the maintained reader and presses.inc once before this wrapper.
; A/X/flags change; Y survives. Keep DMC DMA off for the simple reader.
.segment "BSS"
released_controls: .res 1
edge_scratch: .res 1
.segment "CODE"
detect_control_edges:
    lda previous_controls
    sta released_controls
    jsr detect_presses
    lda released_controls
    ldx controls
    jsr release_bits
    sta released_controls
    rts
; Pure entry: A=previous, X=current byte; A=released, X/Y survive.
release_bits:
    sta edge_scratch
    txa
    eor #$ff
    and edge_scratch
    rts
edges_code_end:

The unchanged press detector

presses.inc
; Call once after read_controls. Changes A/flags; preserves X/Y.
; Clear previous_controls on startup; RAM is not initialised by .res.
.segment "BSS"
previous_controls: .res 1
new_presses: .res 1
.segment "CODE"
detect_presses:
    lda previous_controls
    eor #$ff
    and controls
    sta new_presses
    lda controls
    sta previous_controls
    rts

Provide writable BSS and the normal reset/stack setup. Include the maintained framework/input-to-sound/input.inc, then presses.inc and edges.inc once. Initialise previous_controls and released_controls to zero. Each chosen update calls read_controls then detect_control_edges once. controls is held, new_presses is newly pressed, released_controls is newly released. The wrapper changes A/X/flags and preserves Y. Its two private bytes are released_controls and edge_scratch. It uses the existing JSR stack; one state set is not re-entrant.

The pure release_bits entry takes A=previous and X=current byte and returns A=released; X/Y survive, flags/private scratch change. All eight bits are meaningful: A=$80, B=$40, Select=$20, Start=$10, Up=$08, Down=$04, Left=$02, Right=$01. A byte is a bit set, not a priority code.

Zero initial history counts already-held buttons as first presses. For a scene that should ignore them, take/discard an initial sample before accepting actions. Share the result rather than polling independently in each subsystem. A second poll consumes an edge. Very short pulses, physical bounce, intentional repeat and contradictory-direction policy remain separate problems.

The simple reader writes the shared $4016 strobe and reads controller 1 eight times. A two-controller owner must latch once and consume both streams; unrelated readers can disturb each other. Keep DMC sample DMA disabled. This wrapper does not make the existing reader DMC-safe; that needs a separately verified sampling policy.

Run the caller

Reset, include order and cartridge layout

edges-demo.asm
.segment "HEADER"
    .byte "NES",$1a,2,1,0,0,0,0,0,0,0,0,0,0
ACTION_MASK = $80
.include "edges-caller.inc"
.include "../../framework/input-to-sound/input.inc"
.include "../../framework/input-to-sound/presses.inc"
.include "edges.inc"
POLL_ROUTINE:
    jmp detect_control_edges
code_end:
.segment "VECTORS"
    .word nmi,reset,irq
.segment "CHARS"
.include "edges-font.inc"
    .res 8192-272,0

One sample shared by input, actions and display

edges-caller.inc
.segment "BSS"
actions: .res 1
last_new: .res 1
last_release: .res 1
press_events: .res 1
release_events: .res 1
ready: .res 1
.segment "CODE"
reset:
    sei
    cld
    ldx #$ff
    txs
    inx
    stx $2000
    stx $2001
    stx $4010
    stx $4015
    lda #$40
    sta $4017
    lda #0
clear_ram:
    sta $0000,x
    sta $0100,x
    sta $0200,x
    sta $0300,x
    sta $0400,x
    sta $0500,x
    sta $0600,x
    sta $0700,x
    inx
    bne clear_ram
    jsr wait_vblank
    jsr wait_vblank
    bit $2002
    lda #$20
    sta $2006
    lda #0
    sta $2006
    ldy #4
    ldx #0
clear_names:
    sta $2007
    inx
    bne clear_names
    dey
    bne clear_names
    lda #$3f
    sta $2006
    lda #0
    sta $2006
    lda #$0f
    sta $2007
    lda #$30
    sta $2007
    lda #165
    sta ready
    lda #$0a
    sta $2001
wait_frame:
    jsr wait_vblank
sample:
    jsr read_controls
    jsr POLL_ROUTINE
    lda new_presses
    beq no_press
    sta last_new
    inc press_events
no_press:
    lda released_controls
    beq no_release
    sta last_release
    inc release_events
no_release:
    lda new_presses
    and #ACTION_MASK
    beq show_state
    inc actions
show_state:
    bit $2002
    lda #$20
    sta $2006
    lda #$22
    sta $2006
    lda controls
    jsr show_byte
    lda new_presses
    jsr show_byte
    lda released_controls
    jsr show_byte
    lda actions
    jsr show_byte
    lda #0
    sta $2000
    sta $2005
    sta $2005
frame_done:
    jmp wait_frame
show_byte:
    pha
    lsr
    lsr
    lsr
    lsr
    clc
    adc #1
    sta $2007
    pla
    and #15
    clc
    adc #1
    sta $2007
    lda #0
    sta $2007
    rts
wait_vblank:
    bit $2002
    bpl wait_vblank
    rts
nmi:
irq:
    rti

Run make ASM198X=/path/to/asm198x and load build/edges-demo.nes. This is NTSC NROM-256: 32 KiB PRG ROM, 8 KiB CHR ROM and horizontal mirroring. No external firmware is required. The caller owns reset/stack, its RAM, PPU/NMI setup, APU/DMC disable state and the controller strobe. Reset to leave; it does not restore a surrounding engine.

A press spends one action; a hold does not repeat it. Four hexadecimal bytes show held, pressed, released and the action count. The original small geometric hexadecimal font uses 17 tiles/272 CHR bytes. The caller clears the nametable while rendering is off, waits for vblank, writes a short status row, then restores the scroll state. It uses polling rather than an NMI worker. Rendering/input transfer must stay within the chosen vblank budget; this diagnostic is not a general VRAM scheduler.

Guest diagnostic latches/counters preserve short press/release pulses after a field is drawn. They are caller instrumentation, not another helper requirement. The stable final picture shows 00 00 00 03 after the normal sequence, including an all-button chord. Its still does not show the timing of every edge.

The native NTSC caller shows 00 00 00 03 after the normal input sequence.
The final 03 counts accepted actions. Guest latches retain the short press/release transitions.

Make it your own

Let Left rather than A spend an action

edges-custom.asm
.segment "HEADER"
    .byte "NES",$1a,2,1,0,0,0,0,0,0,0,0,0,0
ACTION_MASK = $02
.include "edges-caller.inc"
.include "../../framework/input-to-sound/input.inc"
.include "../../framework/input-to-sound/presses.inc"
.include "edges.inc"
POLL_ROUTINE:
    jmp detect_control_edges
code_end:
.segment "VECTORS"
    .word nmi,reset,irq
.segment "CHARS"
.include "edges-font.inc"
    .res 8192-272,0

edges-custom.asm changes ACTION_MASK from $80 (A) to $02 (Left), with identical code/state and cartridge sizes. The controller reader and edge helper stay unchanged. A wider selected mask counts one event if any chosen bit is new; counting individual buttons requires another action policy.

Common mistake

Deliberately broken: reset input history before polling

edges-wrong.asm
.segment "HEADER"
    .byte "NES",$1a,2,1,0,0,0,0,0,0,0,0,0,0
ACTION_MASK = $80
.include "edges-caller.inc"
.include "../../framework/input-to-sound/input.inc"
.include "../../framework/input-to-sound/presses.inc"
.include "edges.inc"
POLL_ROUTINE:
    lda #0
    sta previous_controls
    jmp detect_control_edges
code_end:
.segment "VECTORS"
    .word nmi,reset,irq
.segment "CHARS"
.include "edges-font.inc"
    .res 8192-272,0

The labelled edges-wrong.asm clears previous_controls before every edge check. A held A then counts repeatedly, and releases disappear because the previous set is always empty. Restore the normal jump to detect_control_edges. A fresh repaired run has the same cartridge as the working caller. The fault adds 5 instruction bytes but does not increase the fixed NROM file size. Keep the wrong cartridge separate from the normal one.

The faulty native caller spends extra actions during an A-button hold.
Clearing previous_controls turns a held bit into repeated new presses and prevents release events.

Costs and verification

The wrapper/pure release leaf occupy 32 instruction bytes and 2 state bytes, alongside the existing reader/press detector’s 3 state bytes. The complete caller/helper code prefix is 314 bytes, plus 6 vector bytes. Its six caller diagnostic/state bytes are separate from those five input-state bytes. The font consumes 272 bytes inside an 8 KiB CHR allocation; PRG/CHR padding makes each complete cartridge 40,976 bytes including its 16-byte header. Smaller source code does not shrink this fixed mapper layout. These byte counts are not CPU-cycle, vblank-budget or complete-game memory measurements.

Six native cases on released Emu198x 0.44.2/Asm198x 0.0.72 cover working/custom/wrong/repaired callers and an independent guest-owned release caller. The latter streams all 65,536 previous/current byte pairs through 256 complete rows; debugger stops observe the results without changing the PC or guest state. Each row index and a final marker must complete. A wrong OR leaf completes those guards but fails independent bit-set arithmetic. Contradictory directions qualify byte arithmetic, not a physical D-pad promise.

Real host controller events, native event counters, all 32 KiB of immutable PRG ROM, original-font screenshot pixels, source/tool/cartridge hashes and rejected corruptions are retained under verification/edges. Run scripts/verify-nes-input-edges.py, check-nes-input-edges.py and the unittest controls. Qualification names this NTSC NROM/DMC-off configuration, not every mapper/region, original hardware or the entire emulator.

The hardware interface and limits remain those of the maintained serial reader and this named caller. Its DMC exclusion remains binding; these emulation observations do not establish a new hardware exception. Set difference, sampling and event mapping connect to Maths for Games; action timing remains a separate game-loop decision.

Maintained helper and complete callers · Native evidence/configuration. Maths for Games covers bit-set differences; clock and loop choices keeps sampling separate from game progression.