Skip to content

Magma Hub Firmware Spec

magmacrunchmedia edited this page Aug 29, 2026 · 1 revision

Magma Hub Firmware Specification

This page is for the Pico firmware author. It specifies exactly what the Raspberry Pi Pico must produce over I2C so that texastoast can read it. If you are writing a game, you do not need this page — see Magma Hub and I2C instead.

Overview

The Magma Hub is a Raspberry Pi Pico that acts as an I2C slave. The Pi (host) is the I2C master. The Pico continuously samples controller hardware (Wii Nunchuck, breadboard joystick + buttons, etc.), stores the latest state in local memory, and serves that memory to the Pi on demand.

┌─────────────┐       I2C (i2c0)       ┌──────────────┐
│  Raspberry   │ ◄────────────────────► │    Pico      │
│  Pi 500      │   slave @ 0x08         │  (Magma Hub) │
│  (master)    │                        │              │
└─────────────┘                        │  ┌────────┐  │
                                        │  │ Nunchuck│  │
                                        │  │  or     │  │
                                        │  │ Buttons │  │
                                        │  └────────┘  │
                                        └──────────────┘

The Pi communicates with one or more Magma Hubs on the same I2C bus. Each hub must have a unique I2C address (see Address Assignment).

I2C Configuration

Parameter Value
Pico role I2C slave
I2C interface i2c0 (GPIO 4 = SDA, GPIO 5 = SCL)
Clock speed 100 kHz
Slave address 0x08 by default (see below)

The controller hardware (Nunchuck, joystick) connects to i2c1 (GPIO 2/3) or to GPIO pins directly — that is the Pico's internal business and does not affect the Pi-facing protocol.

Address Assignment

Each Magma Hub on the bus must have a unique I2C address. The default addresses are 0x08, 0x09, 0x0A, and 0x0B (supporting up to 4 hubs).

Address selection should be done via configuration pins (e.g., two GPIO pins read at startup to select one of four addresses). Alternatively, the Pico can attempt to detect controller hardware at startup and assign an address based on what it finds.

The texastoast Python engine probes only these four candidate addresses during discovery (MagmaHub.scan_buses), so the Pico must use one of: 0x08, 0x09, 0x0A, or 0x0B.

Memory Map

Each controller occupies 2 bytes, packed contiguously starting at address 0x00. Controller 0 starts at 0x00, controller 1 at 0x02, and so on.

Address  Content
───────  ──────────────────────────────────────
0x00     Controller 0 — buttons (8-bit bitmask)
0x01     Controller 0 — joystick (8-bit value)
0x02     Controller 1 — buttons
0x03     Controller 1 — joystick
...      ...

CONTROLLER_SIZE = 2 bytes per controller. For the initial v0/gen1 with a single controller, only addresses 0x00 and 0x01 are used.

Button Byte

Byte 0 of each controller is an 8-bit bitmask, active high. Each bit represents one button:

Bit Mask Button
0 0x01 Up
1 0x02 Down
2 0x04 Left
3 0x08 Right
4 0x10 A
5 0x20 B
6 0x40 Start
7 0x80 Select

Multiple buttons can be active simultaneously (e.g., Up + A = 0x11).

Nunchuck Mode Mapping

Nunchuck Input Magma Hub Bit Value
Z button A (bit 4) 0x10
C button B (bit 5) 0x20
Joystick up Up (bit 0) 0x01
Joystick down Down (bit 1) 0x02
Joystick left Left (bit 2) 0x04
Joystick right Right (bit 3) 0x08
Start 0x00 (nunchuck has none)
Select 0x00 (nunchuck has none)

The Pico should apply a dead zone to the joystick: below a threshold, report neutral (no bits set). Above the threshold, quantize to 8 directions and set the appropriate bits. Diagonal positions set two bits (e.g., up-right = 0x09).

Breadboard Mode Mapping

Map physical buttons and joystick directly to the same bit positions. The layout is hardware-dependent; the key requirement is that the byte format matches the table above.

Joystick Byte

Byte 1 of each controller is also an 8-bit value. The Python engine passes this byte through to ControllerState.joystick as an opaque value. For d-pad-style controllers (nunchuck, breadboard joystick), the simplest encoding is to use the same bitmask format as the button byte:

Joystick Position Byte Value
Neutral 0x00
Up 0x01
Down 0x02
Left 0x04
Right 0x08
Up-Right 0x09
Down-Left 0x06
Down-Right 0x0A
Up-Left 0x05

This duplicates the directional bits from the button byte into the joystick byte, which is intentional — it lets the engine read directional input from either or both bytes depending on the controller type.

For controllers with analog joysticks, the byte can encode a finer-grained value (e.g., magnitude or angle). The Python engine currently treats it as opaque, so any encoding works as long as games agree on interpretation.

Read Protocol (Select-Write Handshake)

This is the critical interface between the Pi and Pico. The Pi performs a two-step transaction: first a write to set the read window, then a read to fetch the data.

Step 1: Pi writes 2 bytes

The Pi calls write_i2c_block_data(address, start_addr, [num_bytes]):

I2C transaction:
  Master → Slave: [start_addr, num_bytes]

The Pico's I2C slave IRQ handler receives these two bytes:

  • Byte 1: start_addr — the memory address to start reading from
  • Byte 2: num_bytes — how many bytes the Pi will request next

The Pico must store these values and prepare to serve the read.

Step 2: Pi reads N bytes

The Pi calls read_i2c_block_data(address, start_addr, num_bytes):

I2C transaction:
  Master → Slave: [start_addr]        (address pointer set)
  Master ← Slave: [byte0, byte1, ...] (num_bytes bytes from start_addr)

The Pico returns num_bytes bytes starting from start_addr in its controller memory.

Timing

The Pi inserts a 1 ms delay between the write and the read (see hub.py line 162). The Pico has at least this long to prepare the read buffer.

Enforcement

The Pico must enforce this handshake. If the Pi issues a block read without a preceding select-write, the Pico should NACK or otherwise refuse the read. This is not optional — the simulator enforces it strictly, and real firmware must match:

# This MUST fail if no select-write preceded it:
sim.read_i2c_block_data(0x08, 0x00, 2)   # → OSError

The reason: a lenient Pico that answers any read would mask bugs in the Pi-side driver. If MagmaHub ever stopped sending the select-write, the game would silently get stale data instead of a clear error.

Example Transaction

To read 2 bytes from controller 0 (addresses 0x000x01):

Pi writes: [0x00, 0x02]     ← start at 0x00, read 2 bytes
  (1 ms delay)
Pi reads:  [buttons, joystick]  ← 2 bytes from memory

To read controller 1 (addresses 0x020x03):

Pi writes: [0x02, 0x02]     ← start at 0x02, read 2 bytes
  (1 ms delay)
Pi reads:  [buttons, joystick]  ← 2 bytes from memory

IRQ-Driven Implementation

The recommended Pico implementation uses the I2C slave IRQ handler (see pico/i2c_slave.h):

// State for the select-write handshake
static uint8_t g_readAddr = 0;
static uint8_t g_readLen  = 0;
static uint8_t g_readIndex = 0;
static uint8_t processingWrite = 0;

// Pre-sampled controller memory (written by the main loop)
static uint8_t g_mem[CONTROLLER_MEM_SIZE];

static void i2c_slave_handler(i2c_inst_t *i2c, i2c_slave_event_t event) {
    switch (event) {
        case I2C_SLAVE_RECEIVE:
            if (!processingWrite) {
                processingWrite = 1;
                g_readAddr = i2c_read_byte_raw(i2c);
            } else {
                g_readLen = i2c_read_byte_raw(i2c);
                g_readIndex = 0;
            }
            break;
        case I2C_SLAVE_REQUEST:
            if (g_readIndex < g_readLen) {
                i2c_write_byte_raw(i2c, g_mem[g_readAddr + g_readIndex]);
                g_readIndex++;
            }
            break;
        case I2C_SLAVE_FINISH:
            processingWrite = 0;
            break;
    }
}

The main loop continuously samples the controller hardware and writes the results into g_mem. The IRQ handler serves reads from g_mem without blocking the main loop. This ensures:

  1. The Pi always gets the latest sampled state (no stale data)
  2. The main loop never blocks on I2C transactions
  3. A slow or flaky Pi read does not affect controller sampling

Error Handling

  • If the controller hardware is disconnected or unreadable, the Pico should still respond to Pi reads with the last known state (do not NACK). This prevents the Pi from seeing a bus error when a controller is temporarily unplugged.

  • The Pi distinguishes "no hardware" from "all buttons released" by whether the I2C read succeeds at all. If the Pico is powered off or the wire is cut, the Pi gets an OSError from the kernel, which becomes None in I2CBus. A successful read returning 0x00 means "nothing pressed" — that is valid data, not an error.

Supported Modes (v0/gen1)

Mode Controller Hardware Pico Firmware
Nunchuck Wii Nunchuck via I2C (0x52) Reads nunchuck, quantizes to 2-byte format
Breadboard Joystick (analog) + push buttons (GPIO) Reads ADC + GPIO, maps to 2-byte format

Mode selection can be done via:

  • A configuration pin (e.g., pull-up/pull-down resistor)
  • Auto-detection: attempt to find the nunchuck at 0x52; if not found, assume breadboard mode

The mode does not affect the I2C protocol or memory layout — only the contents of the button and joystick bytes.

Future: Wireless (Pico W 2)

The Pico W 2 has WiFi capability. A future version may use point-to-point WiFi communication instead of I2C, with one Pico acting as an access point and others connecting to it. This would solve cable length problems for multi-player setups.

The protocol and memory layout would remain the same — only the transport layer changes. The texastoast Python engine would need a new transport backend, but the ControllerState parsing and input adapters would be unchanged.

Clone this wiki locally