Sticky — Joypad Controller

Built on the AVR128DB64 — the same controller as Clicky — Sticky owns the four DB9 joypad ports. It scans them, decodes Sega 3- and 6-button pads, Atari sticks, paddles, keypads and two-button 7800 controllers, Sinclair and Amstrad joysticks, and Amiga/ST mice, and reports normalised controller state to Pulse over SPI with an interrupt line so Pulse never has to poll. Analog axes are read on-chip. Every DB9 line except pin 8 is a software-configurable GPIO, and each port's supply comes from a monitored high-side switch — so a port reconfigures between controller families in firmware, with no adapter and no jumper. The four ports are wired identically on Ant64 and eZX Spectrum, so one Sticky firmware binary covers both. Fully reprogrammable by Pulse over UPDI at any time.


Overview

Feature Detail
Chip AVR128DB64 @ 24 MHz (same part as Clicky)
Supply 5 V throughout — DB9 signalling is 5 V TTL, so the ports connect directly with no buffering. Level shifters are used only on the links to 3.3 V peers (see Interface Levels)
Ports 4 × DB9, structurally identical, each independently configurable by a mode enumerator
Port power Per-port high-side load switch on DB9 pin 5, with ADC rail sensing for short detection
Analog 8 channels — two per port (DB9 pins 5 and 9), on ADC0 AIN0–AIN7
Comms SPI client ↔ Pulse with a dedicated interrupt line; I²C client ↔ DeMon with an interrupt line; UART → FireStorm (TX only)
Programming UPDI direct from Pulse — update sticky

Sticky sits next to Pulse on the board, which is why Pulse rather than DeMon owns both the SPI link and the UPDI path.


Sticky routes four configurable DB9 ports to Pulse, DeMon and FireStorm

What Sticky Does

  • Scans four DB9 ports at 1 kHz, decoding whatever family of controller each port is configured for, and normalising everything to one report format
  • Decodes Sega 6-button pads — the TH pulse-train protocol with its inter-frame timeout, timed in hardware per port
  • Runs the Sega three-wire handshake for the Team Player multitap, the Mega Mouse and the XE-1 AP analog controller
  • Reads analog axes on-chip — Atari and Commodore paddles, two channels per port
  • Counts quadrature from Amiga and Atari ST mice, Atari driving controllers, trak-balls and spinners — one pin-change interrupt per port, reading all four phases in a single register access
  • Clocks the Amiga CD32 pad's shift register for its seven buttons, falling back to a plain two-button stick if the pad turns out not to be a CD32
  • Scans key matrices — the Atari CX50 keyboard controller and CX21/CX23 touch pads need pins 1–4 driven as outputs, which every port can do
  • Switches and monitors each port's supply — DB9 pin 5 comes from a load switch whose output Sticky reads back on the ADC, so a shorted cable is detected in under a millisecond and the port is shut down and retried with backoff
  • Interrupts Pulse on change rather than being polled, so an idle set of controllers costs the system bus nothing
  • Comes up before the host does, so Pulse can read controller state during early boot and in recovery menus

Joypad Ports

Ant64 concept render — low front view of four DB9 joypad ports and the central power button

Concept render of the Ant64 enclosure.

The one fixed pin

Pin 8 is hard-wired to ground. Every other DB9 pin on every port is a GPIO. That single decision is what makes the mode list open-ended: pin 5 can be a switched 5 V rail, an ADC input, a digital input or a shift-register latch; pin 7 can be a pulsed SELECT, a current-limited 5 V supply or a pulled-up input; pin 9 can be an input, an ADC channel or a driven clock; and pins 1–4 can be row drives instead of direction reads.

It also defines the compatibility boundary precisely. A standard is supportable if its common return is pin 8 — or if its common is some other pin that Sticky can simply drive low. It is only unsupportable when the device needs pin 8 to be something other than ground.

Standard pin mapping

DB9 pin Sega Atari digital Atari paddle Amiga mouse MCU role
1 D0 / Up Up Y-A Digital in/out, pull-up
2 D1 / Down Down X-A Digital in/out, pull-up
3 D2 / Left Left Paddle B fire Y-B Digital in/out, pull-up
4 D3 / Right Right Paddle A fire X-B Digital in/out, pull-up
5 +5 V POT Y Button 3 Load-switch rail, or ADC in, or digital in
6 TL / Button A Fire Button 1 Digital in/out, pull-up
7 SELECT +5 V +5 V Output, or current-limited +5 V, or pulled-up input
8 GND GND GND GND Hard ground
9 TR / Button B POT X Button 2 Digital in/out, or ADC in

Pin group structure

The four ports are laid out identically, which is what lets one code path with a lookup table serve all four:

Port Six digital lines POT X (pin 9) POT Y (pin 5) Supply enable
J1 PORTB bits 0–5 PD0 / AIN0 PD1 / AIN1 PB7
J2 PORTC bits 0–5 PD2 / AIN2 PD3 / AIN3 PC6
J3 PORTE bits 0–5 PD4 / AIN4 PD5 / AIN5 PE6
J4 PORTG bits 0–5 PD6 / AIN6 PD7 / AIN7 PG6

Within each digital group the bit order is fixed:

Bit 0 1 2 3 4 5
DB9 pin 1 2 3 4 6 7

So reading a pad is one masked port read plus one bit from PORTD. Two adjacent reads are about 80 ns apart at 24 MHz, so the pair is effectively simultaneous against Sega's microsecond-scale timing.

Why the analog lines are all on PORTD. Only PORTD, PORTE and PORTF have ADC inputs on this part — PORTA, PORTB, PORTC and PORTG have none. PORTF is mostly spoken for by the crystals, RESET and UPDI, which leaves PORTD as the only group with eight free ADC-capable pins in one place. The alternative — two ports fully on PORTD and PORTE, two ports split — would have made J1/J2 and J3/J4 structurally different and forked the scan code.

Two PORTD pins carry a secondary function worth noting: PD6 is DAC0's output, so DAC0 is unavailable, and PD7 is VREFA, so the ADC must use an internal reference rather than an external one. Neither costs anything here.


Port Power and Short Protection

Each port's DB9 pin 5 is fed by a current-limited high-side load switch, gated by one GPIO. The MCU pin on that net sits on the load side and reads it back on the ADC. This is the arrangement that makes several of the modes above safe rather than merely possible — and it is what lets a shorted cable be detected and shut down in software.

The load switch

                            +5V_PORTS
                                │
                                ├── C1 10µF ── GND      (shared bulk)
                                │
                    ┌───────────┴───────────┐
                    │ IN                 OUT│───┬──────────► DB9 pin 5
      Jn_PWR ───────┤ EN                    │   │
                    │           U1          │   │
        ┌───────────┤ ILIM            /FAULT├───┼──► SFAULT (wired-OR)
        │           │          GND          │   │
       R1           └───────────┬───────────┘   │
      (see note)                │               │
        │                      GND              ├── C2 1µF ── GND
       GND                                      │
                                                ├── R2 1M ── GND
                                                │
                                                └── R3 100R ──► PDn  (MCU)
Ref Value Purpose
U1 TPS2553, AP22653, MIC2005 or similar Current-limited load switch. Requirements: programmable limit, soft-start, thermal shutdown, ≥ 5.5 V rating. Active-high enable — the /EN variants of these parts exist and would need the polarity inverted in port_set_mode
R1 set for ~300 mA limit Well above a Team Player with four pads attached, well below anything that endangers the connector or the 5 V rail. Compute from the chosen part's datasheet — the formula differs between families
C1 10 µF Bulk on the shared port rail, one per board rather than per port
C2 1 µF Output decoupling. Keep it modest: a large output cap fights the soft-start and looks like an inrush fault
R2 1 MΩ to GND Paddle divider and the sense return. See below
R3 100 Ω Isolates the MCU pin from the DB9 net, so a fault on the connector cannot reach the pin directly

Discrete fallback, if a load-switch IC is not acceptable on cost:

                            +5V_PORTS
                                │
                       R4 100k  │
                    ┌───────────┤
                    │           │ S
                    │      ┌────┴────┐
                    │      │   Q1    │   P-channel, Vgs(th) < 2 V
                    │      │  P-FET  │   e.g. DMP2160UW
                    │      └────┬────┘
                    │        G  │  D
                    ├───────────┘  └──── F1 ──────► DB9 pin 5
                    │  │                (PTC, 250 mA hold)
                   C3  │
                 100nF │
                    │  │
                    └──┴─ R5 10k ──┐
                                   │ B
                               ┌───┴───┐
              Jn_PWR ── R6 ────┤  Q2   │   MMBT3904
                       10k     └───┬───┘
                                   │ E
                                  GND

C3 across gate-to-source gives roughly a 10 ms soft-start against R4, which is what keeps inrush into a pad's decoupling from looking like a short. F1 is the only current limiting, and a PTC takes seconds to trip — which is exactly why the ADC rail monitor below matters more in this variant, not less.

Sensing sequence

The pin-5 net carries no external pull-up. Instead the sense uses the MCU's own internal pull-up (roughly 35 kΩ) against R2's 1 MΩ pull-down, enabled only for the duration of the probe. An open port then reads about 97 % of VDD, a grounded one reads zero, and — critically — the pull-up is off during paddle reads, so it does not compress the pot's range. An external pull-up strong enough to sense with would have flattened a 1 MΩ paddle into the top sixth of the ADC's scale.

Switch OFF, pin 5 = ADC input, internal pull-up ON
        │
        ├── reads ~VDD ........... nothing loading it → safe to energise
        ├── reads ~0 V ........... held at the common → DO NOT ENERGISE
        │                          (Sinclair SJS 'up', Amstrad Fire 3,
        │                           or a genuine short)
        └── reads mid-scale ...... resistive load → a paddle, or a
                                   part-inserted connector

Switch ON, wait 2 ms for soft-start
        │
        ├── rail ≥ 90 % VDD ...... healthy
        └── rail < 90 % VDD ...... switch is in current limit → OFF,
                                   mark faulted, retry with backoff

Then every 1 ms scan tick, while enabled:
        rail sags below threshold for 3 consecutive ticks
                → OFF within 3 ms, faulted, exponential backoff

That is orders of magnitude faster than a polyfuse, and it costs nothing beyond the ADC channel the port already has for paddles.

Optional: a wired-OR fault line. The /FAULT outputs of all four load switches are open-drain, so they can be tied together with a single pull-up into PF3 — a free pin on a port group with no joypad lines, so it gets a clean interrupt vector to itself. It does not say which port faulted; the ADC sense does that. What it buys is notification in microseconds rather than at the next scan tick, which is the difference between shutting a port down before and after the connector arcs.

Pin 7 is the more dangerous pin

Pin 5 is protected by a switch Sticky controls. Pin 7 is not, and pin 7 is where the real hazards live: both the Sinclair SJS (Left) and the Amstrad CPC (Fire 1) put a switch on pin 7 that returns to the port's common. If pin 7 were a hard push-pull 5 V rail, a single button press would be a dead short through the AVR pin.

Pin 7 has no ADC channel available — it sits in the digital group, and PORTB/C/E/G have no analog inputs. So the protection is passive and permanent: a 220 Ω series resistor on every port's pin 7. The loads that legitimately need +5 V there are tiny — an Atari paddle is a 1 MΩ pot drawing microamps, a CX50 keypad's three 4.7 kΩ column pull-ups draw about a milliamp — so the drop is negligible, while a dead short is limited to roughly 23 mA. Well inside the pin's rating, indefinitely.

The firmware rule that goes with it: pin 7 is only ever driven high in modes that need it (paddle, keypad, plain Atari), and is a pulled-up input everywhere else.


Supported Controllers

Every entry below works with pin 8 grounded. The last two rows are the boundary cases.

Controller Common Host must drive Notes
Atari CX40 / C64 / VIC-20 joystick 8 The baseline. Kempston, Cursor/Protek/AGF and Fuller Spectrum interfaces all use this wiring
Atari CX30 paddle pair 8 pin 7 = +5 V Pots on 5 and 9, fire buttons on 3 and 4
Atari CX50 keyboard controller, CX21/CX23 touch pad 8 pins 1–4 as row drives, pin 7 = +5 V 4×3 matrix, columns return on 5, 6 and 9. Needs ~400 µs settling after a row change
Atari CX24 Pro-Line / CX78 joypad 8 pin 6 driven high Two buttons bridge pin 6 to pin 5 (right) and pin 9 (left) through ~680 Ω. ⚠ See the hazard below
Atari CX20 driving controller 8 2-bit Gray code on pins 1 and 2 as it spins. Same decoder as a mouse axis
Atari CX22 / CX80 Trak-Ball 8 Rear switch picks joystick emulation or raw mode; raw is direction+clock pairs on pins 1–4
Commodore 1351 mouse 8 Proportional on pins 5 and 9. Holding the right button at power-on selects 1350 joystick emulation
Amiga mouse 8 Y on pins 1 & 3, X on pins 2 & 4. Middle button on pin 5
Atari ST mouse 8 X on pins 1 & 2, Y on pins 3 & 4 — a different pairing from Amiga. No middle button
Amiga CD32 gamepad 8 pin 5 = latch, pin 6 = clock 74LS165-style shift register returning 7 buttons on pin 9. Degrades to a 2-button stick if the latch is left alone
Sega 3-button / Master System pad 8 pin 7 = TH Pin 5 needs the supply on
Sega 6-button pad 8 pin 7 = TH pulses Extra buttons on the third and fourth frames
Sega Mega Mouse 8 pin 7 = TH low, pin 9 = clock Three-wire handshake, not quadrature. Returns 8-bit signed deltas
Sega Team Player (MK-1654) 8 pin 7 = TH low, pin 9 = clock Up to 18 nibbles: header, four ID nibbles, then per-pad data. Needs the supply on for four pads' worth of logic
Sega XE-1 AP 8 pin 7 = REQ only Four 8-bit analog channels over 12 nibbles. Pins 6 and 9 are inputs here, the opposite of the multitap
SMS Sports Pad, Paddle Control 8 pin 7 = TH (Sports Pad only) Region-dependent; the Japanese paddle drives nothing and free-runs its own nibble clock
Sinclair SJS-1 / SJS-2 2 and 8 pin 2 driven low See below — supportable without an adapter
Amstrad CPC joystick 8 (COM1) Works as-is for joystick 1, giving three fire buttons on pins 5, 6 and 7. ⚠ Pin 7 must never be driven high
MSX joystick 9 pin 9 driven low, pin 5 = +5 V Plain joysticks work. Pin 8 is a strobe the MSX drives, and Sticky grounds it — so MSX mice, trackballs and paddles will not work
EA 4-Way Play 8 Occupies two ports: one for data, one as a 3-bit selector on pins 6, 7 and 9

The Pro-Line hazard

The Atari 7800 Software Guide is explicit: "Reading in two button mode when the console has a one button joystick plugged-in isn't recommended, and may be harmful to the console if done for an extended period." A CX40 shorts pin 6 straight to ground; asserting the strong pull-up needed for two-button mode is then a sustained short. Autodetect must confirm pin 6 is not grounded before entering two-button mode, which is step 6 of the sequence below.

(One unresolved detail: sources give the Pro-Line's internal button resistors as 620 Ω or 680 Ω. Measure a real unit before finalising the sense threshold.)

Sinclair SJS — supportable, and the common is not on pin 5

The Sinclair joystick used on the +2/+2A/+3 is not Atari-wired, but its ground is not where it is often assumed to be either. From the official ZX Spectrum +3 manual:

DB9 pin 1 2 3 4 5 6 7 8 9
SJS n/c common n/c Fire Up Right Left common Down

The common is pins 2 and 8 — pin 5 is Up. That matters twice over. Pin 8 is already ground on Sticky, so half the common is free. And pin 5, which many designs would drive as a 5 V rail, is a switch — pressing Up on an SJS while pin 5 is energised would be a short straight through the port supply.

Supporting it needs no adapter and no extra hardware:

  • Drive pin 2 low as an output. That synthesises the second common
  • Leave the load switch off. Pin 5 becomes a pulled-up input reading Up
  • Leave pin 7 as a pulled-up input. It is Left, and driving it high would short through the joystick on every left press. The 220 Ω series resistor makes even a firmware mistake survivable
  • Read Fire on pin 4, Right on pin 6, Left on pin 7, Up on pin 5, Down on pin 9

The original ZX Interface 2 is a different case: its sockets are Atari-wired and only the protocol is Sinclair, so an Interface 2 joystick is just MODE_ATARI.

Amstrad CPC and MSX

Amstrad CPC works better than its reputation suggests. Its COM1 is pin 8, which Sticky already grounds, so a CPC joystick reads correctly as-is and gives three fire buttons — pin 7 (Fire 1), pin 6 (Fire 2) and pin 5 (Fire 3). The only rule is that pin 7 must never be driven, which the 220 Ω resistor and the mode table both enforce. A CPC Y-cable's second joystick returns to COM2 on pin 9, which Sticky can drive low — so it can be read too, though not alternately with the first, since pin 8 is not strobeable.

MSX is the one genuine gap. Ground is on pin 9 and +5 V on pin 5 — both handled, since pin 9 is a GPIO Sticky can drive low and pin 5 is the load switch. But pin 8 is a strobe the MSX host drives, and Sticky grounds it. Plain joysticks do not care. MSX mice, trackballs and paddles all multiplex on that strobe, and they will not work. Making pin 8 a GPIO on all four ports would cost four pins Sticky does not have spare, and would route every port's return current through an AVR pin — which a joystick tolerates and a Team Player does not.


Port Modes

Mode is a per-port enumerator. Mode 0 is autodetect, which probes and then latches a concrete mode.

typedef enum {
    MODE_AUTO        =  0,  /* probe, then latch a concrete mode        */
    MODE_OFF         =  1,  /* all lines Hi-Z, supply off               */
    MODE_ATARI       =  2,  /* CX40, C64, Kempston, Cursor, Fuller      */
    MODE_ATARI_2BTN  =  3,  /* CX24 Pro-Line, CX78                      */
    MODE_PADDLE      =  4,  /* CX30 pair, C64 paddles                   */
    MODE_KEYPAD      =  5,  /* CX50, CX21, CX23                         */
    MODE_DRIVING     =  6,  /* CX20 driving controller                  */
    MODE_TRAKBALL    =  7,  /* CX22/CX80 raw mode                       */
    MODE_AMIGA_MOUSE =  8,  /* quadrature, Amiga pin pairing            */
    MODE_ST_MOUSE    =  9,  /* quadrature, ST pin pairing               */
    MODE_CD32        = 10,  /* shift register on pins 5/6/9             */
    MODE_SEGA        = 11,  /* 3- and 6-button, resolved within the mode*/
    MODE_SEGA_MOUSE  = 12,  /* Mega Mouse, three-wire handshake         */
    MODE_SEGA_TAP    = 13,  /* Team Player multitap                     */
    MODE_SEGA_ANALOG = 14,  /* XE-1 AP                                  */
    MODE_SMS_SPORTS  = 15,  /* Sports Pad trackball                     */
    MODE_SMS_PADDLE  = 16,  /* HPD-200                                  */
    MODE_SJS         = 17,  /* Sinclair +2/+3 joystick                  */
    MODE_CPC         = 18,  /* Amstrad, three fire buttons              */
    MODE_MSX         = 19,  /* MSX joystick, pin 9 driven as ground     */
    MODE_EA4WAY_DATA = 20,  /* 4-Way Play, data half  (paired port)     */
    MODE_EA4WAY_SEL  = 21,  /* 4-Way Play, select half (paired port)    */
} port_mode_t;

Every port powers up in MODE_AUTO and falls back to MODE_ATARI if nothing more specific is identified — a plain digital stick is the safe default, because it drives nothing any other family objects to.


Firmware

Port descriptor

#include <avr/io.h>
#include <stdbool.h>
#include <stdint.h>

/* Bit positions inside a port's digital group. */
#define P1_bm    PIN0_bm        /* DB9 1 - Up    / D0 / Y-A          */
#define P2_bm    PIN1_bm        /* DB9 2 - Down  / D1 / X-A          */
#define P3_bm    PIN2_bm        /* DB9 3 - Left  / D2 / Y-B          */
#define P4_bm    PIN3_bm        /* DB9 4 - Right / D3 / X-B          */
#define P6_bm    PIN4_bm        /* DB9 6 - TL / Fire / clock         */
#define P7_bm    PIN5_bm        /* DB9 7 - TH / SELECT / +5V         */
#define DIRS_bm  (P1_bm | P2_bm | P3_bm | P4_bm)
#define ALL_bm   (DIRS_bm | P6_bm | P7_bm)

typedef struct {
    PORT_t  *dig;               /* digital group                     */
    uint8_t  potx_bm;           /* PORTD mask, DB9 pin 9             */
    uint8_t  poty_bm;           /* PORTD mask, DB9 pin 5             */
    uint8_t  potx_ain;          /* ADC0.MUXPOS for pin 9             */
    uint8_t  poty_ain;          /* ADC0.MUXPOS for pin 5             */
    PORT_t  *pwr;               /* load-switch enable                */
    uint8_t  pwr_bm;
    TCB_t   *tcb;               /* handshake timing and timeouts     */
} port_hw_t;

static const port_hw_t HW[4] = {
    { &PORTB, PIN0_bm, PIN1_bm, ADC_MUXPOS_AIN0_gc, ADC_MUXPOS_AIN1_gc,
      &PORTB, PIN7_bm, &TCB0 },
    { &PORTC, PIN2_bm, PIN3_bm, ADC_MUXPOS_AIN2_gc, ADC_MUXPOS_AIN3_gc,
      &PORTC, PIN6_bm, &TCB1 },
    { &PORTE, PIN4_bm, PIN5_bm, ADC_MUXPOS_AIN4_gc, ADC_MUXPOS_AIN5_gc,
      &PORTE, PIN6_bm, &TCB2 },
    { &PORTG, PIN6_bm, PIN7_bm, ADC_MUXPOS_AIN6_gc, ADC_MUXPOS_AIN7_gc,
      &PORTG, PIN6_bm, &TCB3 },
};

typedef struct {
    port_mode_t mode;
    uint16_t    buttons;        /* normalised, family-independent    */
    uint8_t     axis[2];        /* POT X, POT Y                      */
    int16_t     delta[2];       /* quadrature, cleared on read       */
    bool        present;
    bool        faulted;
    uint8_t     fault_ticks;
    uint16_t    backoff_ms;
} port_state_t;

static port_state_t ST[4];

/* Write a PINnCTRL register by bit index - they are contiguous. */
static inline void pinctrl(PORT_t *p, uint8_t idx, uint8_t val)
{
    (&p->PIN0CTRL)[idx] = val;
}

ADC and rail sensing

#define ADC_FULL   4095u
#define RAIL_OK    ((ADC_FULL * 90u) / 100u)   /* healthy rail        */
#define RAIL_LOW   ((ADC_FULL * 10u) / 100u)   /* held at the common  */

void adc_init(void)
{
    /* INITDLY stays at zero - erratum 2.3.2 makes a non-zero value
       delay MUXPOS updates by one conversion on every silicon rev,
       and we rotate MUXPOS across eight channels. Buy acquisition
       time with SAMPLEN instead, which is what a 1 M-ohm pot needs
       anyway.                                                       */
    ADC0.CTRLC     = ADC_PRESC_DIV64_gc;       /* ~375 kHz CLK_ADC    */
    ADC0.SAMPCTRL  = 64;                       /* (2+64) x T_CLK_ADC  */
    ADC0.CTRLD     = 0;
    ADC0.CTRLA     = ADC_RESSEL_12BIT_gc | ADC_ENABLE_bm;
}

static uint16_t adc_read(uint8_t muxpos)
{
    ADC0.MUXPOS  = muxpos;
    ADC0.COMMAND = ADC_STCONV_bm;
    while (!(ADC0.INTFLAGS & ADC_RESRDY_bm))
        ;
    return ADC0.RES;
}

Safe idle, and bringing a port up

/* Every line an input, supply off. Nothing is driven. This is the
   state every mode change passes through, so a transition never
   leaves two drivers fighting on the same DB9 pin.                 */
static void port_idle(uint8_t n)
{
    const port_hw_t *h = &HW[n];

    h->pwr->OUTCLR = h->pwr_bm;          /* supply off FIRST, always */
    h->pwr->DIRSET = h->pwr_bm;

    h->dig->DIRCLR = ALL_bm;
    for (uint8_t b = 0; b < 6; b++)
        pinctrl(h->dig, b, 0);           /* externals do the pulling */

    PORTD.DIRCLR = h->potx_bm | h->poty_bm;
    /* Pot lines keep their input buffers enabled so they can double
       as buttons. Paddle mode turns the buffers off to cut leakage. */
}

typedef enum { PWR_OK, PWR_HELD_LOW, PWR_OVERLOAD } pwr_result_t;

static pwr_result_t port_power_on(uint8_t n)
{
    const port_hw_t *h = &HW[n];

    /* 1. Probe with the switch off. The internal pull-up against the
          board's 1M pull-down takes an open port to ~97 % of VDD.
          It is enabled only here - during paddle reads it would
          flatten a 1M pot into the top of the ADC's range.          */
    h->pwr->OUTCLR = h->pwr_bm;
    PORTD.DIRCLR   = h->poty_bm;
    pinctrl(&PORTD, __builtin_ctz(h->poty_bm), PORT_PULLUPEN_bm);
    _delay_us(500);

    uint16_t probe = adc_read(h->poty_ain);
    pinctrl(&PORTD, __builtin_ctz(h->poty_bm), 0);

    if (probe < RAIL_LOW)
        return PWR_HELD_LOW;   /* SJS 'up', a CPC common, or a short */

    /* 2. Energise and let soft-start finish. */
    h->pwr->OUTSET = h->pwr_bm;
    _delay_ms(2);

    /* 3. Did the rail actually come up, or is the switch limiting? */
    if (adc_read(h->poty_ain) < RAIL_OK) {
        h->pwr->OUTCLR = h->pwr_bm;
        return PWR_OVERLOAD;
    }
    return PWR_OK;
}

/* Called from the 1 kHz scan tick for every energised port. */
void port_rail_monitor(uint8_t n, uint32_t now_ms)
{
    const port_hw_t *h = &HW[n];

    if (!(h->pwr->OUT & h->pwr_bm))
        return;

    if (adc_read(h->poty_ain) >= RAIL_OK) {
        ST[n].fault_ticks = 0;
        return;
    }
    if (++ST[n].fault_ticks < 3)        /* ride out a hot-plug bounce */
        return;

    h->pwr->OUTCLR   = h->pwr_bm;
    ST[n].faulted    = true;
    ST[n].mode       = MODE_OFF;
    ST[n].backoff_ms = ST[n].backoff_ms ? ST[n].backoff_ms * 2 : 100;
    if (ST[n].backoff_ms > 8000)
        ST[n].backoff_ms = 8000;
    (void)now_ms;                       /* schedule the retry */
}

Switching mode

bool port_set_mode(uint8_t n, port_mode_t m)
{
    const port_hw_t *h = &HW[n];

    port_idle(n);                        /* always via a safe state  */
    ST[n].mode = m;

    switch (m) {

    case MODE_OFF:
        return true;

    case MODE_ATARI:
    case MODE_CPC:                       /* pin 7 is Fire 1 - INPUT  */
        /* Nothing to drive. CPC in particular must leave pin 7 as a
           pulled-up input: it is a switch to the common there.      */
        return true;

    case MODE_PADDLE:
        /* Pots need +5 V on pin 7, through the board's 220 ohm. */
        h->dig->OUTSET = P7_bm;
        h->dig->DIRSET = P7_bm;
        /* Analog lines: buffers off, no pull-ups. */
        pinctrl(&PORTD, __builtin_ctz(h->potx_bm), PORT_ISC_INPUT_DISABLE_gc);
        pinctrl(&PORTD, __builtin_ctz(h->poty_bm), PORT_ISC_INPUT_DISABLE_gc);
        return true;

    case MODE_KEYPAD:
        /* Rows are outputs, idle high; columns return on 5, 6, 9. */
        h->dig->OUTSET = DIRS_bm | P7_bm;
        h->dig->DIRSET = DIRS_bm | P7_bm;
        return true;

    case MODE_ATARI_2BTN:
        /* Never enter this blind - a one-button stick grounds pin 6
           and the strong pull-up would be a sustained short.        */
        if (!(h->dig->IN & P6_bm))
            { ST[n].mode = MODE_ATARI; return false; }
        h->dig->OUTSET = P6_bm;
        h->dig->DIRSET = P6_bm;
        return true;

    case MODE_SJS:
        /* Supply stays OFF: pin 5 is 'Up', a switch to the common.
           Pin 7 stays a pulled-up input: it is 'Left'.
           Pin 2 becomes the second common.                          */
        h->dig->OUTCLR = P2_bm;
        h->dig->DIRSET = P2_bm;
        return true;

    case MODE_MSX:
        /* Ground moves to pin 9, +5 V to pin 5. Pin 8 stays grounded,
           so the MSX strobe is stuck low and only plain joysticks
           work - mice and trackballs need that strobe.              */
        PORTD.OUTCLR = h->potx_bm;
        PORTD.DIRSET = h->potx_bm;
        return port_power_on(n) == PWR_OK;

    case MODE_CD32:
        /* Pin 5 is the latch and pin 6 the clock, both host-driven;
           pin 9 returns the shifted bits.                           */
        if (port_power_on(n) != PWR_OK) return false;
        h->dig->OUTSET = P6_bm;
        h->dig->DIRSET = P6_bm;
        return true;

    case MODE_SEGA:
    case MODE_SMS_SPORTS:
    case MODE_SEGA_ANALOG:
        /* Pad logic runs off pin 5; pin 7 is TH / REQ. Pin 9 stays
           an input in all three - only the multitap and the mouse
           turn it into a clock.                                     */
        if (port_power_on(n) != PWR_OK) return false;
        h->dig->OUTSET = P7_bm;
        h->dig->DIRSET = P7_bm;
        return true;

    case MODE_SEGA_TAP:
    case MODE_SEGA_MOUSE:
        /* Three-wire handshake: TH low enables, pin 9 becomes the
           clock, TL (pin 6) is the device's ready line.             */
        if (port_power_on(n) != PWR_OK) return false;
        h->dig->OUTSET = P7_bm;
        h->dig->DIRSET = P7_bm;
        PORTD.OUTSET   = h->potx_bm;
        PORTD.DIRSET   = h->potx_bm;
        return true;

    case MODE_AMIGA_MOUSE:
    case MODE_ST_MOUSE:
        if (port_power_on(n) != PWR_OK) return false;
        quad_enable(n);
        return true;

    case MODE_DRIVING:
    case MODE_TRAKBALL:
        /* Both are unpowered switch-and-encoder devices on pins 1-4;
           the trak-ball's active modes still take +5 V on pin 7.    */
        h->dig->OUTSET = P7_bm;
        h->dig->DIRSET = P7_bm;
        quad_enable(n);
        return true;

    default:
        ST[n].mode = MODE_OFF;
        return false;
    }
}

Reading — digital families

/* Normalised button bits, family-independent. */
#define BTN_UP     0x0001
#define BTN_DOWN   0x0002
#define BTN_LEFT   0x0004
#define BTN_RIGHT  0x0008
#define BTN_A      0x0010
#define BTN_B      0x0020
#define BTN_C      0x0040
#define BTN_START  0x0080
#define BTN_X      0x0100
#define BTN_Y      0x0200
#define BTN_Z      0x0400
#define BTN_MODE   0x0800

/* CX40, C64, Kempston, Cursor, Fuller, ZX Interface 2. */
static uint16_t read_atari(uint8_t n)
{
    uint8_t d = (uint8_t)~HW[n].dig->IN;      /* switches close to GND */
    return ((d & P1_bm) ? BTN_UP    : 0)
         | ((d & P2_bm) ? BTN_DOWN  : 0)
         | ((d & P3_bm) ? BTN_LEFT  : 0)
         | ((d & P4_bm) ? BTN_RIGHT : 0)
         | ((d & P6_bm) ? BTN_A     : 0);
}

/* Amstrad CPC - identical directions, three fire buttons.
   Fire 1 is pin 7, Fire 2 pin 6, Fire 3 pin 5.                     */
static uint16_t read_cpc(uint8_t n)
{
    const port_hw_t *h  = &HW[n];
    uint8_t          d  = (uint8_t)~h->dig->IN;
    uint8_t          pd = (uint8_t)~PORTD.IN;

    return ((d & P1_bm)     ? BTN_UP    : 0)
         | ((d & P2_bm)     ? BTN_DOWN  : 0)
         | ((d & P3_bm)     ? BTN_LEFT  : 0)
         | ((d & P4_bm)     ? BTN_RIGHT : 0)
         | ((d & P7_bm)     ? BTN_A     : 0)   /* Fire 1 */
         | ((d & P6_bm)     ? BTN_B     : 0)   /* Fire 2 */
         | ((pd & h->poty_bm) ? BTN_C   : 0);  /* Fire 3 */
}

/* Sinclair SJS. Pin 2 is driven low as the second common; the
   direction and fire lines sit in Sinclair's own order.            */
static uint16_t read_sjs(uint8_t n)
{
    const port_hw_t *h  = &HW[n];
    uint8_t          d  = (uint8_t)~h->dig->IN;
    uint8_t          pd = (uint8_t)~PORTD.IN;

    return ((pd & h->poty_bm) ? BTN_UP    : 0)   /* pin 5 */
         | ((pd & h->potx_bm) ? BTN_DOWN  : 0)   /* pin 9 */
         | ((d  & P7_bm)      ? BTN_LEFT  : 0)   /* pin 7 */
         | ((d  & P6_bm)      ? BTN_RIGHT : 0)   /* pin 6 */
         | ((d  & P4_bm)      ? BTN_A     : 0);  /* pin 4 */
}

/* CX24 Pro-Line / CX78. Pin 6 is driven high; a pressed button lifts
   its pot line toward VDD through ~680 ohm, so these read HIGH.    */
static uint16_t read_proline(uint8_t n)
{
    const port_hw_t *h  = &HW[n];
    uint8_t          d  = (uint8_t)~h->dig->IN;
    uint8_t          pd = PORTD.IN;             /* active HIGH here  */

    return ((d & P1_bm)      ? BTN_UP    : 0)
         | ((d & P2_bm)      ? BTN_DOWN  : 0)
         | ((d & P3_bm)      ? BTN_LEFT  : 0)
         | ((d & P4_bm)      ? BTN_RIGHT : 0)
         | ((pd & h->poty_bm) ? BTN_A    : 0)   /* pin 5 = right */
         | ((pd & h->potx_bm) ? BTN_B    : 0);  /* pin 9 = left  */
}

Reading — paddles

/* Two 8-bit axes per port. The pots are wired as rheostats from
   pin 7 to the POT pin, with the board's 1M pull-down forming the
   divider. SAMPLEN carries the acquisition; see adc_init(). The
   internal pull-up must be off here or it flattens the range.      */
static void read_paddles(uint8_t n)
{
    const port_hw_t *h = &HW[n];

    ST[n].axis[0] = (uint8_t)(adc_read(h->potx_ain) >> 4);
    ST[n].axis[1] = (uint8_t)(adc_read(h->poty_ain) >> 4);

    /* Paddle fire buttons are on pins 3 and 4, not pin 6. */
    uint8_t d = (uint8_t)~h->dig->IN;
    ST[n].buttons = ((d & P4_bm) ? BTN_A : 0)     /* paddle A fire */
                  | ((d & P3_bm) ? BTN_B : 0);    /* paddle B fire */
}

Reading — Sega 3/6-button

/* One TH phase: set TH, let the pad's mux settle, sample all six
   data lines. D0-D3 and TL come from the digital group; TR is the
   one bit that lives on PORTD.                                     */
static uint8_t sega_phase(const port_hw_t *h, bool th)
{
    if (th) h->dig->OUTSET = P7_bm;
    else    h->dig->OUTCLR = P7_bm;
    _delay_us(2);

    uint8_t d  = (uint8_t)~h->dig->IN;
    uint8_t tr = (PORTD.IN & h->potx_bm) ? 0u : 1u;
    return (uint8_t)((d & 0x1F) | (tr << 5));      /* D0-3, TL, TR  */
}

/* Six phases resolves 3-button vs 6-button. A 6-button pad reports
   D0-D3 all pressed on the third TH-low phase - a state no real
   3-button pad can produce - and reveals X/Y/Z/Mode on the next
   TH-high phase. Verify the frame indices against a real pad.      */
static void read_sega(uint8_t n)
{
    const port_hw_t *h = &HW[n];
    uint8_t f[6];

    for (uint8_t i = 0; i < 6; i++)
        f[i] = sega_phase(h, (i & 1) != 0);
    h->dig->OUTSET = P7_bm;                 /* leave TH idle high    */

    uint16_t b = 0;
    if (f[1] & 0x01) b |= BTN_UP;
    if (f[1] & 0x02) b |= BTN_DOWN;
    if (f[1] & 0x04) b |= BTN_LEFT;
    if (f[1] & 0x08) b |= BTN_RIGHT;
    if (f[1] & 0x10) b |= BTN_B;
    if (f[1] & 0x20) b |= BTN_C;
    if (f[0] & 0x10) b |= BTN_A;
    if (f[0] & 0x20) b |= BTN_START;

    if ((f[4] & 0x0F) == 0x0F) {            /* 6-button signature    */
        if (f[5] & 0x01) b |= BTN_Z;
        if (f[5] & 0x02) b |= BTN_Y;
        if (f[5] & 0x04) b |= BTN_X;
        if (f[5] & 0x08) b |= BTN_MODE;
    }
    ST[n].buttons = b;

    /* The pad's internal counter resets if TH is left high for
       ~1.5 ms, which the 1 kHz scan interval guarantees.            */
}

Reading — the three-wire handshake

Shared by the Team Player, the Mega Mouse and anything else in that family. TH (pin 7) is the active-low enable, pin 9 becomes the clock, and TL (pin 6) is the device's ready line. The timeout is not optional: an unplugged tap mid-read would otherwise hang the scan.

#define TWH_TIMEOUT  4800u        /* 200 us at 24 MHz               */

/* Clock one nibble. Returns -1 on timeout. */
static int8_t twh_nibble(const port_hw_t *h, bool tr, uint8_t *out)
{
    if (tr) PORTD.OUTSET = h->potx_bm;
    else    PORTD.OUTCLR = h->potx_bm;

    h->tcb->CNT = 0;
    while ((((h->dig->IN & P6_bm) != 0)) != tr)     /* TL follows TR */
        if (h->tcb->CNT > TWH_TIMEOUT)
            return -1;

    *out = (uint8_t)(~h->dig->IN & 0x0F);
    return 0;
}

/* Team Player: 2 header nibbles, 4 peripheral IDs, then per-pad data.
   IDs: 0x0 = 3-button, 0x1 = 6-button, 0x2 = Mega Mouse, 0xF = empty.
   Payloads: 2, 3 and 6 nibbles respectively.                        */
static int8_t read_teamplayer(uint8_t n, uint8_t *nyb, uint8_t max)
{
    const port_hw_t *h = &HW[n];
    uint8_t          i = 0;
    bool             tr = true;

    h->dig->OUTCLR = P7_bm;                 /* TH low = enabled      */
    PORTD.OUTSET   = h->potx_bm;

    while (i < max) {
        tr = !tr;
        if (twh_nibble(h, tr, &nyb[i]) < 0) {
            h->dig->OUTSET = P7_bm;         /* release the tap       */
            return -1;
        }
        if (i == 5) {                       /* IDs are in, size it   */
            max = 6;
            for (uint8_t p = 2; p < 6; p++)
                max += (nyb[p] == 0x0) ? 2 : (nyb[p] == 0x1) ? 3
                     : (nyb[p] == 0x2) ? 6 : 0;
            if (max > 18) max = 18;
        }
        i++;
    }
    h->dig->OUTSET = P7_bm;
    return (int8_t)i;
}

Reading — the Atari keypad

/* 4x3 matrix. Rows on pins 1-4, driven low one at a time; columns
   return on pins 5, 6 and 9 through 4.7k pull-ups to pin 7. The pot
   lines need ~400 us to settle after a row change - that is the
   original TIA timing and real hardware depends on it.             */
static uint16_t read_keypad(uint8_t n)
{
    const port_hw_t *h   = &HW[n];
    uint16_t         keys = 0;
    static const uint8_t row_bm[4] = { P1_bm, P2_bm, P3_bm, P4_bm };

    for (uint8_t r = 0; r < 4; r++) {
        h->dig->OUTSET = DIRS_bm;
        h->dig->OUTCLR = row_bm[r];
        _delay_us(400);

        uint8_t pd = (uint8_t)~PORTD.IN;
        if (pd & h->poty_bm)          keys |= (uint16_t)1 << (r * 3 + 0);
        if (!(h->dig->IN & P6_bm))    keys |= (uint16_t)1 << (r * 3 + 2);
        if (pd & h->potx_bm)          keys |= (uint16_t)1 << (r * 3 + 1);
    }
    h->dig->OUTSET = DIRS_bm;
    return keys;
}

Autodetect — mode zero

port_mode_t port_autodetect(uint8_t n)
{
    const port_hw_t *h = &HW[n];

    /* 0. Nothing driven, supply off. */
    port_idle(n);
    _delay_ms(1);

    /* 1. Is pin 5 held at the common? Never energise if so. An SJS
          with 'up' pressed, an Amstrad Fire 3, or a fault all look
          the same at DC - so probe behaviourally instead.          */
    PORTD.DIRCLR = h->poty_bm;
    pinctrl(&PORTD, __builtin_ctz(h->poty_bm), PORT_PULLUPEN_bm);
    _delay_us(500);
    uint16_t pin5 = adc_read(h->poty_ain);
    pinctrl(&PORTD, __builtin_ctz(h->poty_bm), 0);

    if (pin5 < RAIL_LOW) {
        h->dig->OUTCLR = P2_bm;             /* synthesise SJS common */
        h->dig->DIRSET = P2_bm;
        _delay_us(100);
        if ((h->dig->IN & (P4_bm | P6_bm | P7_bm))
                       == (P4_bm | P6_bm | P7_bm))
            return MODE_SJS;                /* switches idle: a stick */
        return MODE_OFF;                    /* everything down: short */
    }

    /* 2. Bring the supply up under supervision. */
    if (port_power_on(n) != PWR_OK)
        return MODE_OFF;

    /* 3. Sega signature: with TH low a Sega pad grounds D2 and D3.
          No Atari-family stick does that.                          */
    h->dig->OUTSET = P7_bm;
    h->dig->DIRSET = P7_bm;
    _delay_us(20);
    h->dig->OUTCLR = P7_bm;
    _delay_us(20);
    bool sega = ((h->dig->IN & (P3_bm | P4_bm)) == 0);
    h->dig->OUTSET = P7_bm;

    if (sega) {
        /* Distinguish plain pad from mouse and multitap by trying
           the three-wire handshake: only those two answer on TL.   */
        uint8_t nyb[4];
        PORTD.OUTSET = h->potx_bm;
        PORTD.DIRSET = h->potx_bm;
        h->dig->OUTCLR = P7_bm;
        int8_t got = twh_nibble(h, false, &nyb[0]);
        h->dig->OUTSET = P7_bm;
        PORTD.DIRCLR   = h->potx_bm;

        if (got == 0)
            return twh_identify(n);         /* MODE_SEGA_TAP or _MOUSE */
        return MODE_SEGA;                   /* resolves 3 vs 6 itself  */
    }

    /* 4. Quadrature? Direction lines changing in Gray code rather
          than as independent switch closures means a mouse, a
          driving controller or a spinner.                          */
    switch (quadrature_probe(n, 50 /* ms */)) {
    case QUAD_TWO_AXIS_AMIGA: return MODE_AMIGA_MOUSE;
    case QUAD_TWO_AXIS_ST:    return MODE_ST_MOUSE;
    case QUAD_ONE_AXIS:       return MODE_DRIVING;
    default:                  break;
    }

    /* 5. Keypad: drive one row low and see whether a column follows.
          A joystick's direction lines are switches to ground and
          will not respond to a row drive at all.                   */
    if (keypad_probe(n))
        return MODE_KEYPAD;

    /* 6. Two-button 7800 stick. Pin 6 MUST be high first - a
          one-button stick grounds it, and driving it high then is a
          sustained short the 7800 manual warns can cause damage.   */
    if ((h->dig->IN & P6_bm) && proline_probe(n))
        return MODE_ATARI_2BTN;

    /* 7. Default. Paddles are deliberately never autodetected: an
          idle paddle and an empty socket are indistinguishable, and
          guessing wrong energises pin 7 into something that may not
          want it.                                                  */
    return MODE_ATARI;
}

Autodetect runs at power-on, on a hot-plug edge, and on host request. It never runs on a port the host has explicitly configured.


Quadrature and Delta Devices

Every quadrature device — Amiga mouse, Atari ST mouse, CX20 driving controller, CX22/CX80 trak-ball, spinners — is decoded the same way on all four ports: one pin-change interrupt per MCU port group, reading all four phases in a single PORTx.IN.

That uniformity is worth more than it costs. Each port's direction lines sit in bits 0–3 of one group, so the ISR is a register read, a table lookup per axis and an add. Even a mouse thrown across a desk generates only a few tens of thousands of edges per second, which is a low single-digit percentage of one core at 24 MHz. Hardware decoding through the CCL was considered and rejected: it would have decoded only one port, only for one mouse pinout, and only by relocating a pin — for a saving the CPU does not need.

The pinouts differ, and the difference matters. The Amiga interleaves its axes and the Atari ST blocks them:

DB9 pin Amiga mouse Atari ST mouse Driving controller Trak-ball (raw)
1 Y-A X-B Gray bit 0 X direction
2 X-A X-A Gray bit 1 X motion clock
3 Y-B Y-A Y direction
4 X-B Y-B Y motion clock

So a decoder wired for one does not serve the other. Rather than branch, the phase extraction is a small table indexed by mode.

The trak-ball in raw mode is not quadrature at all — it emits a direction level and a motion clock per axis, so each clock edge is a single count in whichever direction the level says. Its rear switch also offers a joystick-emulation mode, in which it reads as a plain CX40. The Sega Mega Mouse is a third thing again: it counts internally and reports 8-bit signed deltas over the three-wire handshake, so it never touches this path.

typedef struct {
    uint8_t last[2];            /* previous phase pair, per axis     */
    int16_t count[2];           /* accumulated, cleared on read      */
} quad_t;

static volatile quad_t QUAD[4];

/* Standard 4x transition table: index = (prev << 2) | current,
   where each phase pair is (A << 1) | B. Zero entries are the
   illegal double transitions - a glitch or an overspeed miss.     */
static const int8_t QUAD_LUT[16] = {
     0, +1, -1,  0,
    -1,  0,  0, +1,
    +1,  0,  0, -1,
     0, -1, +1,  0
};

typedef struct { uint8_t a, b; } phase_t;   /* bit indices in DIRS   */

static const phase_t PHASE[][2] = {
    /*                        X axis      Y axis   */
    [MODE_AMIGA_MOUSE] = { {1, 3},     {0, 2} },
    [MODE_ST_MOUSE]    = { {1, 0},     {2, 3} },
    [MODE_DRIVING]     = { {0, 1},     {0, 0} },   /* axis 1 unused  */
};

static inline uint8_t phase_of(uint8_t raw, phase_t p)
{
    return (uint8_t)((((raw >> p.a) & 1u) << 1) | ((raw >> p.b) & 1u));
}

static void quad_update(uint8_t n)
{
    const port_hw_t *h   = &HW[n];
    volatile quad_t *q   = &QUAD[n];
    uint8_t          raw = h->dig->IN;

    if (ST[n].mode == MODE_TRAKBALL) {
        /* Direction level + motion clock, one pair per axis. */
        uint8_t xc = (raw >> 1) & 1u, yc = (raw >> 3) & 1u;
        if (xc != q->last[0]) {
            q->count[0] += (raw & P1_bm) ? 1 : -1;
            q->last[0]   = xc;
        }
        if (yc != q->last[1]) {
            q->count[1] += (raw & P3_bm) ? 1 : -1;
            q->last[1]   = yc;
        }
        return;
    }

    uint8_t axes = (ST[n].mode == MODE_DRIVING) ? 1u : 2u;
    for (uint8_t a = 0; a < axes; a++) {
        uint8_t cur = phase_of(raw, PHASE[ST[n].mode][a]);
        q->count[a] += QUAD_LUT[(q->last[a] << 2) | cur];
        q->last[a]   = cur;
    }
}

static void quad_enable(uint8_t n)
{
    const port_hw_t *h = &HW[n];

    QUAD[n].count[0] = QUAD[n].count[1] = 0;
    for (uint8_t b = 0; b < 4; b++)
        pinctrl(h->dig, b, PORT_ISC_BOTHEDGES_gc);   /* 4k7 externals */
    quad_update(n);                 /* seed last[] from the current state */
}

/* One vector per port group. Nothing else on these groups uses
   pin-change interrupts, so the flag clear can be unconditional
   over the direction bits.                                        */
ISR(PORTB_PORT_vect) { quad_update(0); PORTB.INTFLAGS = DIRS_bm; }
ISR(PORTC_PORT_vect) { quad_update(1); PORTC.INTFLAGS = DIRS_bm; }
ISR(PORTE_PORT_vect) { quad_update(2); PORTE.INTFLAGS = DIRS_bm; }
ISR(PORTG_PORT_vect) { quad_update(3); PORTG.INTFLAGS = DIRS_bm; }

/* Called from the scan tick. Deltas are consumed, not levelled. */
static void read_quadrature(uint8_t n)
{
    uint8_t s = SREG;
    cli();
    ST[n].delta[0] = QUAD[n].count[0];  QUAD[n].count[0] = 0;
    ST[n].delta[1] = QUAD[n].count[1];  QUAD[n].count[1] = 0;
    SREG = s;

    /* Buttons still read normally from the same port. */
    const port_hw_t *h  = &HW[n];
    uint8_t          d  = (uint8_t)~h->dig->IN;
    uint8_t          pd = (uint8_t)~PORTD.IN;

    ST[n].buttons = ((d  & P6_bm)      ? BTN_A : 0)    /* left       */
                  | ((pd & h->potx_bm) ? BTN_B : 0)    /* right      */
                  | ((pd & h->poty_bm) ? BTN_C : 0);   /* middle     */
}

The driving controller deserves a note of its own. It is often mistaken for a paddle because it looks like one, but it spins freely and emits a 2-bit Gray code on pins 1 and 2 as it turns — no pot, no pin 7 supply needed, and no absolute position. It falls out of the mouse machinery for free as a single-axis device, and the host sees it as a delta rather than an axis.


The Amiga CD32 Pad

The CD32 controller is an ordinary two-button Amiga stick until you talk to it. Pull its latch line low and it loads seven buttons into a shift register; clock them out and you get Play/Pause, Reverse, Forward, Green, Yellow, Red and Blue. Leave the latch alone and it behaves exactly like a CX40 with Red on pin 6 and Blue on pin 9 — which is why it is safe to probe for.

DB9 pin Normal Amiga role CD32 role Sticky direction
1–4 Directions Directions, unchanged Input
5 POT X Latch / shift load Output
6 Button 1 (Red) Clock Output
7 +5 V +5 V — powers the shift register Output high
9 Button 2 (Blue) Serial data out Input

This is the one mode where Sticky drives DB9 pin 5 directly, so the load switch is interlocked off and the pad takes its power from pin 7 instead. The 220 Ω series resistor on pin 7 is fine here: the pad's shift register is CMOS and draws well under a milliamp, so the drop is a fraction of a volt. Anything drawing more than a couple of milliamps from pin 7 would sag noticeably — worth remembering if a third-party pad ever misbehaves.

/* Seven buttons, MSB first, then an eighth clock that exposes a
   permanently-low marker bit. A plain two-button stick has no shift
   register and leaves that bit high, which is how the probe tells
   them apart without ever putting the pad in a bad state.          */
static bool read_cd32(uint8_t n, uint16_t *out)
{
    const port_hw_t *h = &HW[n];
    uint8_t          sr = 0;

    /* Directions read normally, before and after the shift. */
    uint8_t  d = (uint8_t)~h->dig->IN;
    uint16_t b = ((d & P1_bm) ? BTN_UP    : 0)
               | ((d & P2_bm) ? BTN_DOWN  : 0)
               | ((d & P3_bm) ? BTN_LEFT  : 0)
               | ((d & P4_bm) ? BTN_RIGHT : 0);

    PORTD.OUTCLR   = h->poty_bm;        /* latch low: capture buttons */
    _delay_us(10);
    h->dig->OUTSET = P6_bm;             /* clock idles high           */
    PORTD.OUTSET   = h->poty_bm;        /* latch high: begin shifting */
    _delay_us(5);

    for (uint8_t i = 0; i < 8; i++) {
        h->dig->OUTCLR = P6_bm;
        _delay_us(5);
        sr = (uint8_t)(sr << 1);
        if (!(PORTD.IN & h->potx_bm))   /* data is active low         */
            sr |= 1u;
        h->dig->OUTSET = P6_bm;
        _delay_us(5);
    }

    if (sr & 0x01)                      /* marker should be clear     */
        return false;                   /* not a CD32 pad             */

    if (sr & 0x80) b |= BTN_START;      /* Play / Pause               */
    if (sr & 0x40) b |= BTN_Y;          /* Reverse                    */
    if (sr & 0x20) b |= BTN_Z;          /* Forward                    */
    if (sr & 0x10) b |= BTN_C;          /* Green                      */
    if (sr & 0x08) b |= BTN_X;          /* Yellow                     */
    if (sr & 0x04) b |= BTN_A;          /* Red                        */
    if (sr & 0x02) b |= BTN_B;          /* Blue                       */

    *out = b;
    return true;
}

If read_cd32 returns false the port silently reverts to MODE_AMIGA_MOUSE or MODE_ATARI for that scan, so plugging a plain stick into a port configured for CD32 degrades rather than producing nonsense.


The Sega Mega Mouse

Not quadrature — the mouse accumulates its own counts and reports 8-bit signed deltas over the same three-wire handshake the Team Player uses. Nine nibbles standalone, six through a multitap where the tap's ID nibble replaces the signature.

static bool read_megamouse(uint8_t n)
{
    const port_hw_t *h = &HW[n];
    uint8_t          y[9];
    bool             tr = true;

    h->dig->OUTCLR = P7_bm;                     /* TH low = enabled  */
    for (uint8_t i = 0; i < 9; i++) {
        tr = !tr;
        if (twh_nibble(h, tr, &y[i]) < 0) {
            h->dig->OUTSET = P7_bm;
            return false;
        }
    }
    h->dig->OUTSET = P7_bm;

    /* y[3] = Yovf Xovf Ysign Xsign, y[4] = C M R L (active HIGH -
       the Mega Mouse is the odd one out here), y[5..8] = X then Y. */
    int16_t x = (int16_t)((y[5] << 4) | y[6]);
    int16_t v = (int16_t)((y[7] << 4) | y[8]);
    if (y[3] & 0x01) x -= 256;                  /* X sign            */
    if (y[3] & 0x02) v -= 256;                  /* Y sign            */

    ST[n].delta[0] = x;
    ST[n].delta[1] = v;
    ST[n].buttons  = ((y[4] & 0x01) ? BTN_A     : 0)   /* left       */
                   | ((y[4] & 0x02) ? BTN_B     : 0)   /* right      */
                   | ((y[4] & 0x04) ? BTN_C     : 0)   /* middle     */
                   | ((y[4] & 0x08) ? BTN_START : 0);
    return true;
}

Two things to check on real hardware. The signature nibbles are documented inconsistently — Plutiedev describes nibbles 1–3 as a status word beginning %1011, BlueRetro reports the first two as 0xF 0xF. And the Mega Mouse's buttons are active high, which is unique among the DB9 devices here; every other decoder in this document inverts.


Sega Serial Peripherals

Three more devices share the TH-pulsing machinery and differ only in their frame layout and timing. All three are firmware additions on top of what is already here — no pin behaves differently from a mode already described.

Device Host drives Frames Notes
XE-1 AP (Sega analog) pin 7 as REQ only 12 nibbles Four 8-bit analog channels — X, Y, throttle, RZ — plus buttons. Pins 6 and 9 are inputs here, the reverse of the multitap, and the pad clocks itself: a falling edge on REQ triggers the report and the host follows the pad's own strobe and ACK. Has hardware Digital/Analog and PC/MD switches, so it falls back to a 3-button pad
SMS Sports Pad (trackball) pin 7 as TH 4 nibbles TH falling latches the axis, TH rising presents the low nibble; relative 8-bit delta per axis. The phase resets if no TH transition occurs within roughly 143 µs, so the four-step sequence must not be interrupted
SMS Paddle Control (HPD-200) nothing free-running The Japanese unit leaves pin 7 unconnected and toggles its own nibble clock at roughly 36 kHz, signalling which half is present on pin 9. Absolute 8-bit position. Detection is statistical: sample pin 9 a few hundred times and confirm the duty is near 50 %

Timing caveat worth recording. The XE-1 AP and Sports Pad timings above come from MAME, whose own source comments flag them as fudged rather than measured. Budget bench time against real hardware before treating any of these three as done.



Host Interfaces

Line Direction Carries
SPI client (to Pulse) bidirectional Controller reports out, configuration and mode commands in
SIRQ Sticky → Pulse Asserted when any port's state changes; cleared when Pulse reads the pending report
I²C client (to DeMon) bidirectional Direct DeMon path — controller-state reads, mode configuration, status queries, identity
SDIRQ Sticky → DeMon Asserted when any port's state changes; lets DeMon read on change instead of polling
UART (to FireStorm) Sticky → FireStorm only Direct controller stream to FireStorm without going through Pulse
UPDI (from Pulse) Pulse → Sticky Firmware programming — update sticky

SPI with an interrupt line, not polling. Sticky raises SIRQ when a scan produces a report that differs from the last one Pulse collected, and Pulse clocks the report out when it is ready. An idle set of controllers produces no bus traffic at all — which matters, because a 1 kHz poll of four ports across a shared SPI bus would otherwise be constant background load for no information. SIRQ is a plain push-pull GPIO; if Pulse ever needs to share the line with other peers it becomes open-drain with a pull-up, which costs nothing in firmware.

Three readers, one authority. SPI (to Pulse) is the primary data + control path. The I²C client is now Sticky's direct link to DeMon, so AntOS can read controller state and configure ports without going through Pulse — SDIRQ is its change doorbell, so DeMon reads on change rather than polling. The UART feeds the same normalised state to FireStorm for the soft cores. All three see one report; where configuration commands disagree the most recent wins, since port config is not a real-time operation.

Report format

Field Size Description
Sequence 1 byte Increments per report; lets the host detect a missed one
Port status 4 × 1 byte Resolved mode enumerator, plus present / faulted flags
Digital state 4 × 2 bytes Normalised button bitmap per port, family-independent
Analog axes 4 × 2 bytes Two 8-bit axes per port; zero outside analog modes
Delta counters 4 × 2 bytes Signed quadrature delta per axis, cleared on read
Extended variable Keypad bitmap, multitap sub-reports, XE-1 AP's four axes

Normalising in firmware is the point: a host reading the digital bitmap does not need to know whether the pad underneath was Sega, Atari or Sinclair, and adding a controller family later changes nothing above Sticky.


Peripheral Allocation

Subsystem Timer ADC Pins
Scan tick TCA0 (1 kHz)
Handshake timing / timeouts TCB0–TCB3, one per port
Paddle and rail sensing ADC0, AIN0–AIN7 PD0–PD7
Port digital PB0–5, PC0–5, PE0–5, PG0–5
Port supplies PB7, PC6, PE6, PG6
SPI client + IRQ PA4–PA7, PB6
I²C client + IRQ (to DeMon) PA2, PA3, PC7
UART TX to FireStorm PF4; PF5 free

Remaining after allocation:

Resource Free
Pins PE7, PF2, PF3, PG7 — 4 in total, three ADC-capable
CCL All 6 LUTs, all 3 sequencers
Timers TCA1, TCB4, TCD0
Event channels All 10
Serial USART0, 1, 3, 4, 5, SPI1, TWI1

Quadrature is decoded in software on every port, so the CCL stays entirely unallocated. That is the resource with the fewest substitutes, and keeping it whole is worth more than the few percent of CPU the interrupt path costs.

A note on the UART's position. USART2 at ALT1 uses PF4 for TX only; PF5 is released. PF4/PF5 was the only free UART pin pair once the four joypad ports have taken bits 0–5 of PORTB, PORTC, PORTE and PORTG. Every other USART's positions land on one of those groups. If the joypad bit assignment ever shifts, re-check this before anything else.


Interface Levels

Sticky runs at 5 V, which is what lets the DB9 ports connect directly. Sega pads pull data lines to ground through switches and expect a 5 V pull-up; Atari sticks are bare switches; paddle range is referenced to 5 V, so the ADC's full scale matches the pot's range without scaling.

Level shifters are required on every link to a 3.3 V peer:

Link Pins Peer
SPI client PA4–PA7 Pulse
SIRQ PB6 Pulse
UART TX PF4 To FireStorm; PF5 free
UPDI PF7 Pulse
I²C client (to DeMon) PA2 / PA3 DeMon (3.3 V). Open-drain — a bidirectional level translator or a correctly-referenced pull-up arrangement, not a unidirectional buffer
SDIRQ PC7 DeMon (3.3 V) — 5 V→3.3 V

Port Protection

A DB9 retro port is hot-plugged, constantly, with cables of unknown provenance, by people who are not being careful — and half the DB9 standards in existence disagree about which pin is ground. Protection here is not defensive over-engineering; it is the price of the mode list.

Every port carries the same network. Order matters: the TVS sits at the connector, ahead of the series resistors, so a strike is clamped before it reaches anything.

   DB9                TVS            series          MCU / switch
                       │                │
   pin 1 ──────────────┼── D1 ──┬── R 100R ──────────► PxN.0
   pin 2 ──────────────┼── D1 ──┤   R 100R ──────────► PxN.1
   pin 3 ──────────────┼── D1 ──┤   R 100R ──────────► PxN.2      + 4k7 pull-ups
   pin 4 ──────────────┼── D1 ──┤   R 100R ──────────► PxN.3        to +5V on
   pin 6 ──────────────┼── D1 ──┤   R 100R ──────────► PxN.4        pins 1,2,3,4,6,9
   pin 9 ──────────────┼── D1 ──┘   R 100R ──────────► PDn (ADC)
                       │
   pin 7 ──────────────┼── D2 ────── R 220R ─────────► PxN.5      + 10k to +5V
                       │
   pin 5 ──────────────┼── D3 ──┬──────────────────── load switch OUT
                       │        ├── R 1M ── GND
                       │        └── R 100R ────────── PDn (ADC)
                       │
   pin 8 ──────────────┴──────────────────────────────  GND
   shell ─────────────────── 1nF / 1MΩ in parallel ───  chassis
Element Value Why
D1 — six fast lines Low-capacitance TVS array, ≤ 1 pF/channel (SP3012-06 class) Capacitance matters here. A fat clamp diode rounds off mouse quadrature edges and eats into the Sega 6-button settling budget
D2, D3 — the two supply-capable lines Ordinary 5 V TVS Capacitance is irrelevant on pins 5 and 7; clamping energy is not
Series, pins 1–4, 6, 9 100 Ω Limits fault current into the AVR, damps ringing on long cables. Negligible against a 4.7 kΩ pull-up, and negligible against a 1 MΩ paddle on pin 9
Series, pin 7 220 Ω The single most important resistor on the board. Both the Sinclair SJS (Left) and the Amstrad CPC (Fire 1) put a switch on pin 7 returning to the common — this clamps that short to about 23 mA, indefinitely survivable, while costing nothing against the microamp loads that legitimately want +5 V there
Series, pin 5 MCU branch 100 Ω Keeps a connector fault off the MCU pin without dropping the switched rail, which comes straight off the switch
Pull-ups, pins 1–4, 6, 9 4.7 kΩ to +5 V External, not internal. The AVR's internal pull-ups are around 35 kΩ; against a couple of hundred picofarads of cable that is a 7 µs rise, and the Sega 6-button protocol needs the data lines settled inside about 2 µs. 4.7 kΩ brings that under a microsecond. Disable the internal pull-ups on these lines
Pull-up, pin 7 10 kΩ to +5 V Only needed in the modes where pin 7 is an input (SJS Left, CPC Fire 1). No speed requirement, and harmless when the pin is driven
Pull-down, pins 5 and 9 1 MΩ to GND The paddle divider, and the return the internal pull-up works against during the pin-5 probe
Shell 1 nF ∥ 1 MΩ to chassis Gives ESD a path to chassis rather than through signal ground, without creating a ground loop

Two things that are easy to get wrong:

  • No external pull-up on pin 5. It would flatten a 1 MΩ paddle into the top of the ADC's range. The sense pull-up is the MCU's internal one, switched on for the probe and off again — see Port Power
  • 10 nF at each pot pin is tempting, and wrong here. The ADC's long SAMPLEN already handles the source impedance, and a capacitor that size on a 1 MΩ divider gives a 10 ms settling time the paddle scan cannot afford. If noise turns out to need filtering, do it in firmware with a median-of-three

Pin Naming

Signal Direction Count Notes
Jn_P1Jn_P4 bidirectional 4 per port DB9 pins 1–4. Direction reads, quadrature phases, or keypad row drives
Jn_P6 bidirectional 1 per port DB9 pin 6. Sega TL / fire / mouse left button / CD32 clock / Pro-Line button common
Jn_P7 bidirectional 1 per port DB9 pin 7. Pulsed as Sega TH, held high as the Atari +5 V supply through 220 Ω, or a pulled-up input for SJS Left and CPC Fire 1
Jn_P9 bidirectional 1 per port DB9 pin 9. Sega TR / mouse right button / paddle POT X / three-wire clock / MSX ground return. Must be an ADC-capable pin
Jn_P5 input, output in CD32 mode only 1 per port DB9 pin 5. Reads the switched rail, paddle POT Y, mouse middle button, or SJS Up. Driven only as the CD32 shift-register latch, and only with the load switch interlocked off — the switch owns this net in every other mode
Jn_PWR Sticky output 1 per port High-side load-switch enable for DB9 pin 5
SSPI_MOSI, SSPI_MISO, SSPI_SCK, SSPI_SS SPI client from Pulse 4 Sticky is always the client; it never drives the clock
SIRQ Sticky output 1 Asserted on state change, cleared when the pending report is read
SI2C_SDA, SI2C_SCL I²C client (to DeMon) 2 Open-drain, external pull-ups
SDIRQ Sticky output 1 Doorbell to DeMon on state change; cleared when DeMon reads the report
SUART_TX UART output to FireStorm 1 One-way controller stream; no return channel
SUPDI UPDI from Pulse 1 Firmware update only

Pinouts

Pin Function Description
PA0 XTALHF1 24 MHz crystal
PA1 XTALHF2
PA2 SI2C_SDA I²C client — TWI0 default position
PA3 SI2C_SCL
PA4 SSPI_MOSI SPI client to Pulse — SPI0 default position
PA5 SSPI_MISO
PA6 SSPI_SCK
PA7 SSPI_SS
PB0 J1_P1 Joypad 1, DB9 pin 1. Amiga mouse Y-A
PB1 J1_P2 DB9 pin 2. Amiga mouse X-A
PB2 J1_P3 DB9 pin 3. Amiga mouse Y-B
PB3 J1_P4 DB9 pin 4. Amiga mouse X-B
PB4 J1_P6 DB9 pin 6
PB5 J1_P7 DB9 pin 7 — 220 Ω series
PB6 SIRQ Interrupt to Pulse
PB7 J1_PWR Joypad 1 load-switch enable
PC0 J2_P1 Joypad 2, DB9 pin 1
PC1 J2_P2 DB9 pin 2
PC2 J2_P3 DB9 pin 3
PC3 J2_P4 DB9 pin 4
PC4 J2_P6 DB9 pin 6
PC5 J2_P7 DB9 pin 7 — 220 Ω series
PC6 J2_PWR Joypad 2 load-switch enable
PC7 SDIRQ Interrupt to DeMon (I²C-bus doorbell)
PD0 J1_P9 — AIN0 Joypad 1, DB9 pin 9 / POT X
PD1 J1_P5 — AIN1 Joypad 1, DB9 pin 5 / POT Y / rail sense
PD2 J2_P9 — AIN2 Joypad 2, DB9 pin 9 / POT X
PD3 J2_P5 — AIN3 Joypad 2, DB9 pin 5 / POT Y / rail sense
PD4 J3_P9 — AIN4 Joypad 3, DB9 pin 9 / POT X
PD5 J3_P5 — AIN5 Joypad 3, DB9 pin 5 / POT Y / rail sense
PD6 J4_P9 — AIN6 Joypad 4, DB9 pin 9 / POT X. Also DAC0 OUT — DAC0 unavailable
PD7 J4_P5 — AIN7 Joypad 4, DB9 pin 5 / POT Y / rail sense. Also VREFA — use an internal ADC reference
PE0 J3_P1 Joypad 3, DB9 pin 1
PE1 J3_P2 DB9 pin 2
PE2 J3_P3 DB9 pin 3
PE3 J3_P4 DB9 pin 4
PE4 J3_P6 DB9 pin 6
PE5 J3_P7 DB9 pin 7 — 220 Ω series
PE6 J3_PWR Joypad 3 load-switch enable
PE7 free AIN15
PF0 XTAL32K1 32.768 kHz crystal
PF1 XTAL32K2
PF2 free AIN18
PF3 free AIN19
PF4 SUART_TX — USART2 ALT1 To FireStorm
PF5 Free GPIO FPGA UART return removed; USART2 RX disabled
PF6 RESET
PF7 SUPDI UPDI from Pulse
PG0 J4_P1 Joypad 4, DB9 pin 1
PG1 J4_P2 DB9 pin 2
PG2 J4_P3 DB9 pin 3
PG3 J4_P4 DB9 pin 4
PG4 J4_P6 DB9 pin 6
PG5 J4_P7 DB9 pin 7 — 220 Ω series
PG6 J4_PWR Joypad 4 load-switch enable
PG7 free

DB9 pin 8 is hard ground on every port and does not reach the MCU.


Silicon Revision Notes

Errata references are DS80000915F rev. F, covering AVR128DB28/32/48/64.

Erratum Effect on Sticky Revisions
2.3.2 ADC MUX and accumulation update is delayed when initialization delay is used Directly relevant. Sticky rotates MUXPOS across eight channels for paddles and rail sensing; with a non-zero INITDLY the first result after each MUX change comes from the previous channel. Keep INITDLY = 0 and buy acquisition time with SAMPCTRL.SAMPLEN instead, which is what the high-impedance pots need anyway All revisions
2.15.1 TWI output pin override does not function as expected Ensure PORTA.OUT bits 2 and 3 are 0 before enabling TWI0, or the lines can be held high A4, A5 — fixed in B0
2.15.2 TWI Flush non-functional Host-mode only. Sticky is an I²C client, so this does not apply All revisions
2.3.1 Increased offset in single-ended mode −3 mV typical. Immaterial against a paddle's range, and against a 90 % rail threshold A4 only
2.12.1 TCA restart resets count direction in NORMAL/FRQ mode TCA0 and TCA1 run in normal mode as quadrature counters and are never restarted by command or event. Avoid issuing RESTART to either A4, A5 — fixed in B0
2.9.1 PD0 input buffer floating Does not apply — names 28- and 32-pin packages only. PD0/AIN0 is normal on the 64-pin part, which matters because it carries J1's POT X 28/32-pin only

Bring-up verification list:

  1. Paddle acquisition — sweep a real 1 MΩ paddle across its range and confirm the ADC tracks it monotonically with the chosen pull-down and SAMPLEN. This is the one place the datasheet gives no usable guidance: it publishes a 10 kΩ typical source impedance and no impedance-versus-sample-time curve, so the working value has to be found on the bench. If the result is noisy or compressed, switch to RC timing — every pot pin is also a plain GPIO, so discharging a cap and timing its charge needs no hardware change and reproduces the original nonlinearity that period software expects
  2. Short protection, deliberately — short DB9 pin 5 to ground on a live port and confirm the rail monitor shuts it down within 3 ms and backs off. Then do it on pin 7 and confirm the 220 Ω holds the current inside the pin's rating. Do this before shipping, not after
  3. Sega 6-button state machine — verify against a real 6-button pad and a 3-button pad on the same port, and confirm the counter resets cleanly when TH is left high
  4. Three-wire handshake timeouts — unplug a Team Player mid-transaction and confirm the scan recovers rather than hanging. BlueRetro notes that some games expect ~14 µs before TL responds; check the reverse case, where a slow tap needs Sticky to wait
  5. Hot-plug — plug and unplug each family repeatedly with autodetect running, and confirm no port latches into the wrong mode and no port energises into a grounded pin 5
  6. Pro-Line safety interlock — plug a CX40 into a port set to MODE_ATARI_2BTN and confirm the interlock refuses and falls back rather than driving pin 6 high

Future Considerations

  • Sega Team Player and Mega Mouse are already in the mode list; EA 4-Way Play needs a port pair. It occupies two DB9s — one carrying pad data, one acting as a 3-bit selector on pins 6, 7 and 9 — so it is expressed as a paired mode across ports rather than a single-port one. All the pins are already bidirectional; this is firmware only
  • XE-1 AP timing needs measuring. The protocol is well documented in MAME, but that source's own comments flag the timings as fudged rather than measured. Four 8-bit analog channels over 12 nibbles, triggered by a falling edge on pin 7 — everything else about it fits Sticky's existing machinery
  • Hardware quadrature, if a port ever needs it — the CCL is entirely unallocated and both TCA1 and TCD0 are free. One port could be promoted to a CCL sequencer plus an up/down counter, the way Clicky's jog dial works. It only decodes one axis per pair of LUTs and it needs one of the port's four direction lines relocated to a pin a LUT can reach directly, so it is worth doing only for a genuinely fast spinner — an ordinary mouse costs the interrupt path a few percent of one core
  • Per-port current sensing — the rail sense detects voltage collapse, which catches shorts. A shunt into a spare ADC channel would additionally catch a port that is merely overloaded. PF3 and PE7 are free and ADC-capable, enough for two ports' worth
  • A GPIO ground on port 1 — would unlock MSX mice, trackballs and paddles, and true Amstrad Y-cable alternation, by making pin 8 strobeable. It costs one pin and restricts that port to devices whose return current an AVR pin can carry, so it is a port-1-only proposition at best
  • Light guns — the Master System Light Phaser and the Menacer need video sync and a raster-position latch, neither of which reaches Sticky. They belong on whatever board owns the video timing, not here

Direct UART to FireStorm — transmit only

Sticky sends its input stream from PF4 (SUART_TX) to the FPGA. The FPGA-to-controller UART wire is removed; PF5 is free GPIO. Disable the unused USART receiver and its RX interrupts, and do not require FPGA acknowledgements or commands on this link. Preserve the existing transmit-side voltage translation. Other supervisor/control interfaces are unchanged.

Important: The Ant64 family of home computers are at early design/prototype stage, everything you see here is subject to change.