Chapter 13: Input/Output Handling

The I/O hardware and memory map are described in Chapter 6 and Appendix A. This chapter is the programming-side counterpart — concrete assembly idioms for polling HID devices, driving LEDs and the buzzer, using the UART, and reaching the networking stack.

Polling USB HID Gamepads

The HID window at $FFB0–$FFBF is device-switched: writing to $FFB0 selects which device appears in the next fifteen bytes. For gamepads, the selector’s low nibble is 2; the high nibble picks the pad index (0 = merged, 1..4 = physical pads 1..4).

Merged pad 0 (single-player)

The simplest and the idiomatic starting point — read one virtual pad that is the bitwise OR of every connected controller:

    ; Select merged gamepad
    lda #$02                ; low nibble 2 = gamepad, high nibble 0 = merged
    sta $FFB0

    ; Direction buttons
    lda $FFB1               ; D-pad + features
    and #%00001000          ; bit 3 = right
    bne right_held

    lda $FFB1
    and #%00000100          ; bit 2 = left
    bne left_held

    ; Primary action button (bit 0 of buttons byte 0)
    lda $FFB3
    and #%00000001
    bne button_a_held

Bit 7 of $FFB1 is the valid flag — 0 means no pad is connected. Single-player code should either treat an invalid pad as “no input” or use it to show a “connect a controller” screen.

Multi-player

For multiplayer games, walk pads 1 through 4:

    ldx #1                  ; start at player 1
next_player:
    txa
    asl                     ; *= 2 (shift into high nibble)
    asl
    asl
    asl
    ora #$02                ; combine with gamepad type
    sta $FFB0               ; select that pad

    ; Read this pad's state...
    lda $FFB1
    and #%10000000
    beq skip_player         ; high bit clear = not connected
    ; ... process input for player X ...

skip_player:
    inx
    cpx #5
    bne next_player

Each pad’s ten-byte report covers D-pad, digital-stick approximations, two button bytes, analog stick coordinates, and triggers — see Appendix A for the exact offsets.

Analog sticks and deadzones

Analog stick bytes are signed 8-bit values. A reasonable deadzone filter:

    lda $FFB5               ; left stick X, signed
    sec
    sbc #-16                ; shift negative range up
    cmp #32                 ; below 16 in either direction = deadzone
    bcc inside_deadzone
    ; ... use stick value ...
inside_deadzone:

Tune the deadzone for the game; ±16 is a reasonable starting point for sticks that have seen some use.

Polling USB HID Keyboards

Keyboard state is 256 bits (one per HID keycode), exposed through the same window in two 16-byte pages. The low nibble of the selector is 0 (keyboard); the high nibble is 0 for bytes 0–15, 1 for bytes 16–31.

“Any key pressed?”

Page 0, offset 0 is the device status byte. Bit 0 is connected, and the rest of the byte plus the next fifteen bytes reflect which keycodes are currently down. Fast existence check:

    lda #$00                ; keyboard, page 0
    sta $FFB0

    lda $FFB1
    ora $FFB2
    ora $FFB3
    ; ... through $FFBF ...
    beq no_key_pressed

For most games, a dedicated “any key” check is enough to dismiss a title screen.

“Is this specific key down?”

Each state bit lives at bit (keycode & 7) of byte (keycode >> 3) + 1 (offset 1, because byte 0 is the status byte). For HID keycode $04 (letter A):

    lda #$00                ; page 0
    sta $FFB0
    lda $FFB1               ; byte 0 of state = status byte is there, bit 4 is "A"
    and #%00010000          ; bit 4
    bne a_key_down

For a keycode in page 1 (≥ 128), select page 1 first:

    lda #$10                ; keyboard, page 1
    sta $FFB0
    lda $FFB7               ; appropriate byte on page 1
    and #%00000100          ; the bit for the target key
    bne key_down

A small lookup table keyed by keycode that returns (page, byte_offset, bit_mask) is the usual way to make this readable from higher-level code.

Modifier LEDs

Bits 1, 2, and 3 of the status byte carry the NUMLOCK, CAPSLOCK, and SCROLLLOCK LED states respectively. These are host-computed — the X65 firmware tracks them as the keyboard’s HID output report. A program can use them to detect the modifier states without decoding the keycode-bitmap.

DE-9 Joystick Ports

The on-board DE-9 joystick window at $FF80–$FF97 is currently stubbed in firmware — reads return $FF and writes are ignored. Until the path from the PCAL6416A GPIO expander through the RIA interrupt controller is brought up, use USB HID gamepads via $FFB0–$FFBF for controller input.

Once the region goes live, the expected polling pattern is a small lookup against the expander’s port registers, and the IRQ path (via the PCAL6416A’s interrupt-mask registers) supports edge-driven input handling without continuous polling.

RGB LED Programming

Two interfaces share $FFA0–$FFA7. Use whichever matches the application.

Direct RGB332 — on-board LEDs 0..3

The simplest way to light one of the four direct-addressable LEDs. Format: [7:5]R · [4:2]G · [1:0]B. A single STA commits.

    lda #%11100000          ; pure red
    sta $FFA0               ; LED 0 → red

    lda #%00011100          ; pure green
    sta $FFA1               ; LED 1 → green

    lda #0                  ; off
    sta $FFA2               ; LED 2 → off

No setup, no sequencing, no need to touch any other register. Ideal for status indicators.

Chain protocol — any LED 0..255, full 24-bit colour

The four bytes at $FFA4–$FFA7 address an arbitrary LED in the WS2812 chain (including the on-board LEDs, which sit at the chain’s low indices). The write to $FFA4 is the commit — the other three bytes are just latched, so the order is: index, green, blue, then red.

    ; Light LED 5 (on the chain) bright red
    lda #5
    sta $FFA7               ; index — latched
    lda #$00
    sta $FFA5               ; G — latched
    lda #$00
    sta $FFA6               ; B — latched
    lda #$FF
    sta $FFA4               ; R — commits the update

To animate the chain, build a small table of (index, R, G, B) quads and loop over it — each quad is four stores.

System Buzzer

Beeping the buzzer is the simplest I/O operation on the board: two writes to set frequency, one to set duty. Freq is encoded logarithmically (see Chapter 6):

    ; ~1 kHz at 50% duty
    lda #$FF
    sta $FFA8               ; FREQ_L
    lda #$8C
    sta $FFA9               ; FREQ_H
    lda #$80
    sta $FFAA               ; DUTY
    ; ... wait ...
    lda #0
    sta $FFAA               ; silence

Practical pattern: keep a small lookup table from note index → 16-bit FREQ code, then ldx note; lda table_lo,x; sta $FFA8; lda table_hi,x; sta $FFA9 to start a tone; set DUTY to 0 to stop.

UART / Monitor Console from a Program

The UART pair at $FFE0–$FFE1 gives a program direct access to the same serial path that the monitor uses. With nothing special to configure, a program can print to the host terminal over USB-CDC or talk to another serial device.

Transmit

tx_byte:                    ; A = byte to send
    pha
tx_wait:
    lda $FFE0               ; status
    and #%10000000          ; bit 7 = TX writable
    beq tx_wait
    pla
    sta $FFE1               ; write the byte
    rts

Sending a zero-terminated string:

puts:                       ; X:Y = string pointer (cc65 convention)
    ; ... loop: lda (str),y; beq done; jsr tx_byte; iny; bne loop; done: rts ...

Receive

rx_byte:                    ; return byte in A, or carry set if none pending
    lda $FFE0               ; status
    and #%01000000          ; bit 6 = RX ready
    beq no_rx
    lda $FFE1               ; read the byte (also clears the ready flag)
    clc
    rts
no_rx:
    sec
    rts

A polling program reads one byte per VBI or main-loop iteration.

Summary

The X65’s I/O surface is uniformly memory-mapped and uniformly poll-or-IRQ-driven. HID gamepad and keyboard support is a single selector-plus-read pattern per device; LEDs and the buzzer are one-or-a-few stores; the UART is a classic two-register status-plus-data flow. Register offsets and bit layouts are in Appendix A; the hardware context is in Chapter 6.