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.
; 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
rtsread_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
; 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
; 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
rtsProvide 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
.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,0One sample shared by input, actions and display
.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:
rtiRun 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.

Make it your own
Let Left rather than A spend an action
.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,0edges-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
.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,0The 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.

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.