-
Notifications
You must be signed in to change notification settings - Fork 0
Magma Hub Firmware Spec
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.
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).
| 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.
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.
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.
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 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).
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.
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.
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.
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.
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.
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.
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) # → OSErrorThe 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.
To read 2 bytes from controller 0 (addresses 0x00–0x01):
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 0x02–0x03):
Pi writes: [0x02, 0x02] ← start at 0x02, read 2 bytes
(1 ms delay)
Pi reads: [buttons, joystick] ← 2 bytes from memory
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:
- The Pi always gets the latest sampled state (no stale data)
- The main loop never blocks on I2C transactions
- A slow or flaky Pi read does not affect controller sampling
-
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
OSErrorfrom the kernel, which becomesNoneinI2CBus. A successful read returning0x00means "nothing pressed" — that is valid data, not an error.
| 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.
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.
texastoast · PyPI · Apache-2.0