-
-
Notifications
You must be signed in to change notification settings - Fork 26
Firmware Architecture
This page documents the internal architecture of the PICO9918 firmware — how the RP2040 and RP2350 hardware features are utilized to implement a fully-featured TMS9918A-compatible VDP.
The PICO9918 is a demanding real-time application. It must simultaneously:
- Capture every host CPU read/write on the TMS9918A bus with sub-microsecond latency
- Generate pixel-accurate VGA (or SCART RGB) output at precise timing
- Emulate the TMS9918A display engine (tiles, sprites, bitmap layer) each scanline
- Run a TMS9900 GPU emulator on demand
- Generate clock signals for the host system's CPU and GROM
All of this runs on a dual-core microcontroller with no operating system, using the RP2040's PIO (Programmable I/O) state machines and DMA to offload the time-critical I/O from the CPU cores.
The two CPU cores have clearly separated responsibilities:
Core 0 is the "main" core. After initialization, it enters gpuLoop() which runs indefinitely:
-
TMS9900 GPU emulation — When a GPU trigger occurs (via register write, VSYNC, or HSYNC), Core 0 executes the TMS9900 instruction emulator (
run9900()) implemented in ARM Thumb assembly. This runs until the GPU program executes anIDLEinstruction or hits a fault. - Flash operations — Firmware update writes (triggered via register 63) are handled here, as flash erase/write requires careful timing and disables XIP (execute-in-place).
- Configuration saves — Writing configuration to flash when the save flag is set.
- Temperature monitoring — Reads the on-chip temperature sensor every 64 frames and updates status register 13.
Core 1 handles all real-time I/O:
-
TMS9918A bus interface — Sets up the PIO state machines that capture host CPU reads and writes. The PIO interrupt handler (
pio_irq_handler) runs on Core 1, processing each bus transaction in real time. -
VGA scanline rendering — Runs
vgaLoop(), which receives scanline requests from the DMA interrupt handler via the multicore FIFO. For each scanline, it calls the TMS9918A display emulator to generate a row of pixels, then converts palette indices to 12-bit BGR for VGA output. - Interrupt management — Drives the /INT output pin based on VDP status (frame interrupts, horizontal interrupts, sprite collisions).
- Reset handling — On hardware v0.4+, monitors the /RST pin and reinitializes the VDP state on hardware reset.
The cores communicate through the RP2040's hardware multicore FIFO (SIO):
-
DMA ISR → Core 1: The DMA interrupt handler pushes scanline numbers, end-of-frame messages, and vertical timing events into the FIFO. Core 1's
vgaLoop()blocks onmulticore_fifo_pop_blocking()waiting for these. - Core 0 → Core 1: At startup, Core 0 pushes a sync message to signal Core 1 that VGA initialization is complete and rendering can begin.
- Shared memory: Both cores share access to the TMS9918A emulation state (VRAM, registers, status registers). The PIO interrupt handler on Core 1 directly calls the TMS9918A emulator functions to process reads and writes. Core 0's GPU emulator also accesses VRAM directly. The GPU runs between scanlines or during blanking, and the critical sections are short enough that conflicts are avoided.
- Set safe clock speed (preset 0: 252 MHz)
- Initialize vrEmuTms9918 emulation library
- Launch Core 1
- Read configuration from flash
- Handle clock preset testing/validation (10-second stability test for new presets)
- Initialize GROMCLK and CPUCLK PIO outputs
- Set up DMA channel for display border fill
- Initialize VGA output (PIO programs, DMA channels, timing parameters)
- Initialize temperature sensor
- Initialize diagnostic overlays
- Signal Core 1 that VGA is ready
- Initialize GPU
- Enter
gpuLoop()— runs forever on Core 0
- Initialize GPIO pins (data bus, control signals, /INT output)
- Set up TMS9918A PIO state machines (write, read)
- Release /INT pin (held low during init)
- Detect hardware version
- Set up GPIO interrupt for reset pin (v0.4+ only)
- Wait for Core 0 ready signal
- Enter
vgaLoop()— runs forever on Core 1
The PICO9918 uses both PIO blocks with a total of 6 state machines:
PIO1 is dedicated to the TMS9918A bus, running two state machines that continuously monitor the bus control signals.
Captures host CPU writes to the VDP:
- Waits for /CSW to go LOW (write active), with an 8-cycle delay for signal settling
- Captures the 16-bit pin state (CD0-7 data bus + MODE + control signals)
- Waits for /CSW to go HIGH (write complete)
- Captures the final pin state and auto-pushes to RX FIFO
The two 16-bit snapshots are packed into a single 32-bit FIFO word. The PIO RX FIFO triggers an interrupt, and the handler extracts the data byte and MODE bit to determine whether this is a data write or an address/register write.
Clock divider: 1.0 (full system clock — must react as fast as possible)
Provides data to the host CPU on reads, using a read-ahead buffer:
- When /CSR is HIGH (idle), continuously drains the TX FIFO for the latest read-ahead value prepared by software
- When /CSR goes LOW (read active):
- Sets CD0-7 pins to output mode
- Checks the MODE pin to determine read type
- If MODE=0: outputs the data byte
- If MODE=1: outputs the status register byte
- Captures the final pin state and pushes to RX FIFO
- Returns CD0-7 to input mode
The key innovation is the read-ahead mechanism: the TX FIFO always contains a pre-computed 32-bit word with pin direction, data value, and status value. After every bus transaction (read or write), the interrupt handler immediately updates the read-ahead value. This means the PIO can respond to reads with virtually zero latency — the data is already waiting.
Read-ahead format (32 bits):
Bits 23-16: Status register value
Bits 15-8: Data read-ahead value
Bits 7-0: Pin direction mask (0xFF = all inputs when idle)
Clock divider: 4.0 (slower than write — the host CPU needs time to read the data bus)
PIO0 handles display output and host system clock generation using all 4 state machines:
Generates horizontal and vertical sync signals:
- Pulls a 32-bit timing word from the TX FIFO (fed by DMA)
- Extracts a 14-bit delay count, 2-bit sync pin states, and a 16-bit instruction
- Outputs the HSYNC/VSYNC pin levels
- Executes the embedded instruction (fires IRQ4 at the start of active display)
- Delays for the specified number of PIO cycles
Each scanline is described by 4 DMA words: active display, front porch, sync pulse, and back porch. The DMA handler switches between three data buffers (active, porch, sync) depending on the vertical position.
Sync data word format:
Bits 31-16: Instruction (e.g., irq set 4 to signal RGB SM)
Bit 15: VSYNC level
Bit 14: HSYNC level
Bits 13-0: Delay in PIO cycles
Outputs 12-bit RGB pixel data:
- Clears the RGB output pins
- Waits for IRQ4 from the sync SM (signals start of active display)
- Outputs pixel data from the TX FIFO at the calculated pixel clock rate
- Loops for all pixels in the scanline
The pixel output timing is controlled by a delay embedded in the output instruction, calculated at initialization to match the pixel clock for the chosen display mode.
Pixel format: 16 bits per output — 12-bit BGR (4 bits each for Blue, Green, Red)
Simple 2-instruction toggle programs generating host system clocks:
| Clock | SM | Frequency | Derivation |
|---|---|---|---|
| GROMCLK | SM2 | 447,443 Hz | 10,738,635 / 24 |
| CPUCLK | SM3 | 3,579,545 Hz | 10,738,635 / 3 |
Both are derived from the TMS9918A crystal frequency (10,738,635 Hz) via PIO clock dividers.
Pin assignments (v0.4+): GROMCLK = GPIO 25, CPUCLK = GPIO 24 Pin assignments (v0.3): GROMCLK = GPIO 29, CPUCLK = GPIO 23
Three DMA channels handle continuous data transfer without CPU intervention:
| Channel | Purpose | Source | Destination | Trigger |
|---|---|---|---|---|
| 0 (syncDmaChan) | VGA sync timing | Sync data arrays | PIO0 SM0 TX FIFO | PIO DREQ |
| 1 (rgbDmaChan) | VGA pixel data | Double-buffered scanline arrays | PIO0 SM1 TX FIFO | PIO DREQ |
| 2 (dma32) | Background fill | Single 32-bit color value | VGA scanline buffer | Manual |
Cycles through syncDataSync[], syncDataPorch[], or syncDataActive[] (4 words each) depending on the current vertical position. Fires DMA_IRQ_0 on completion, which updates the vertical line tracking and selects the next buffer.
Transfers from double-buffered scanline arrays (rgbDataBuffer[0] and rgbDataBuffer[1]). Each word carries 2 pixels (32 bits = 2 x 16-bit BGR). Fires DMA_IRQ_0 on completion, triggering a request for the next scanline.
Reads a single 32-bit background color value (no increment) and writes it across the scanline buffer (with increment). Used to quickly fill border regions with the VDP background color each scanline.
| IRQ | Source | Core | Handler | Purpose |
|---|---|---|---|---|
| PIO1_IRQ_0 | PIO1 RX FIFO | 1 | pio_irq_handler() |
Process TMS9918A bus reads/writes |
| DMA_IRQ_0 | DMA channels 0, 1 | 1 | dmaIrqHandler() |
Manage VGA sync and RGB output timing |
| IO_IRQ_BANK0 | GPIO 23 edge | 1 | gpioIrqHandler() |
Hardware reset (v0.4+ only) |
Triggered when the TMS9918A bus PIO state machines have data in their RX FIFOs:
-
Write FIFO not empty: A host write has been captured. Calls the TMS9918A emulator's write function (
vrEmuTms9918WriteAddrImplorvrEmuTms9918WriteDataImpl), updates the /INT pin state, and refreshes the read-ahead buffer. - Read FIFO not empty: A host read has completed. For data reads, advances the read-ahead pointer. For status reads, clears the appropriate interrupt/collision flags and updates /INT. Refreshes the read-ahead buffer.
This handler is marked __not_in_flash_func to run from RAM, avoiding flash access latency.
- Sync DMA complete: Advances the vertical line counter. Selects the appropriate sync data buffer. At the end of vertical back porch, signals Core 1 to begin rendering. At front porch, signals end-of-active-display.
- RGB DMA complete: Advances the display line counter. Handles 2x Y-scaling (each TMS9918A scanline output twice for VGA). Applies CRT scanline darkening on alternate lines when enabled. Requests the next scanline from Core 1 via multicore FIFO.
On v0.4+, a falling edge on GPIO 23 (/RST) triggers a full VDP reset: clears emulation state, drains PIO FIFOs, restores saved palette, reinitializes /INT.
DMA ISR (sync) DMA ISR (rgb) Core 1 (vgaLoop)
│ │ │
│ vertical timing │ │
├──────────────────────►│ │
│ │ push line request │
│ ├──────────────────────►│
│ │ │ vrEmuTms9918ScanLine()
│ │ │ ├─ bitmap layer
│ │ │ ├─ sprite rendering
│ │ │ ├─ tile layer 2
│ │ │ ├─ tile layer 1
│ │ │ └─ palette → BGR12
│ │ │
│ │ DMA reads buffer │ writes to rgbDataBuffer[]
│ │◄─────────────────────┤
│ │ │
│ │ outputs to PIO RGB SM │
│ ├──────────────────────►VGA pins
- The sync DMA drives horizontal/vertical timing continuously
- When the RGB DMA finishes outputting a scanline, it requests the next one
- Core 1 renders the requested scanline using the TMS9918A emulator
- The rendered pixels are written to a double-buffered array
- The RGB DMA picks up the new buffer and feeds it to the PIO for output
The double-buffer scheme allows Core 1 to render line N+1 while DMA is still outputting line N. If Core 1 falls behind (rendering takes too long), frames are dropped and tracked.
| Parameter | Horizontal | Vertical |
|---|---|---|
| Active pixels | 640 | 480 |
| Front porch | 16 | 10 |
| Sync pulse | 96 | 2 |
| Back porch | 48 | 33 |
| Total | 800 | 525 |
| Pixel clock | 25.175 MHz | |
| Frame rate | 60 Hz |
The TMS9918A's 256x192 (or 256x240 in row-30 mode) output is centered in the 640x480 frame with borders filled using the VDP background color. 2x horizontal and 2x vertical scaling maps each TMS pixel to a 2x2 VGA pixel block.
| Variant | Resolution | Field Rate |
|---|---|---|
| NTSC | 720x480i | 60 Hz |
| PAL | 720x576i | 50 Hz |
SCART mode uses 1x vertical scaling (no doubling) and interlaced output. See SCART Output Reference for detailed timing.
The TMS9900 GPU is implemented as an instruction emulator written in ARM Thumb assembly:
-
RP2040 (Cortex-M0+):
thumb9900_m0.S -
RP2350 (Cortex-M33):
thumb9900_m33.S
The GPU has full read/write access to the 64KB VRAM address space, including the memory-mapped registers at 0x6000 and status registers at 0xB000.
| Trigger | Source | Condition |
|---|---|---|
| Register write | Writing LSB to R55 | Always (when unlocked) |
| VSYNC | End of frame | R50 bit 5 set |
| HSYNC | Every scanline | R50 bit 6 set |
The ARM MPU (Memory Protection Unit) is configured to guard a region at VRAM address 0x8000. When the GPU program accesses this address, a hard fault is triggered. The fault handler intercepts the access and performs the requested DMA block transfer using parameters at 0x8000-0x8008.
At initialization, a default GPU program is loaded into GRAM at 0x4000. This program implements the startup splash screen display and version string output. It serves as a functional test that the GPU is working correctly.
| Address | Size | Content |
|---|---|---|
| 0x000000 - 0x0FFFFF | 1 MB | Firmware |
| 0x100000 - 0x1FEFFF | ~1 MB | Program data storage (256 x 256-byte blocks) |
| 0x1FF000 - 0x1FF0FF | 256 bytes | Configuration |
Configuration survives firmware updates because the firmware occupies the lower portion of flash and the configuration is stored at the very top.
Hardware detection (read-only, bytes 0-6):
| Byte | Name | Values |
|---|---|---|
| 0 | CONF_PICO_MODEL |
1 = RP2040, 2 = RP2350 |
| 1 | CONF_HW_VERSION |
0x03 = V0.3, 0x10 = V1.0+, 0x20 = V2.0+ |
| 2 | CONF_SW_VERSION |
Upper nibble = major, lower nibble = minor |
| 3 | CONF_SW_PATCH_VERSION |
Patch version number |
| 4 | CONF_CLOCK_TESTED |
Last successfully tested clock preset (0-2) |
| 5 | CONF_DISP_DRIVER |
0 = VGA, 1 = NTSC SCART, 2 = PAL SCART |
| 6 | CONF_FLASH_STATUS |
Reserved for flash operations |
User-configurable (bytes 8+):
| Byte | Name | Range | Default |
|---|---|---|---|
| 8 | CONF_CRT_SCANLINES |
0-1 | 0 (off) |
| 9 | CONF_SCANLINE_SPRITES |
0-3 | 0 (no limit) |
| 10 | CONF_CLOCK_PRESET_ID |
0-2 | 0 (252 MHz) |
| 16 | CONF_DIAG |
0-1 | 0 (off) |
| 17 | CONF_DIAG_REGISTERS |
0-1 | 0 |
| 18 | CONF_DIAG_PERFORMANCE |
0-1 | 0 |
| 19 | CONF_DIAG_PALETTE |
0-1 | 0 |
| 20 | CONF_DIAG_ADDRESS |
0-1 | 0 |
| 128-159 | CONF_PALETTE_IDX_0..15 |
16 x 2 bytes | Default TMS9918A palette |
| 255 | CONF_SAVE_TO_FLASH |
0-1 | 0 (trigger) |
| Preset | PLL Frequency | Dividers | Core Voltage | System Clock |
|---|---|---|---|---|
| 0 | 1,512 MHz | 6, 1 | 1.15V | 252 MHz |
| 1 | 1,512 MHz | 5, 1 | 1.20V | 302.4 MHz |
| 2 | 1,056 MHz | 3, 1 | 1.30V | 352 MHz |
When a new clock preset is selected, the firmware runs a 10-second (600-frame) stability test. The CONF_CLOCK_TESTED byte records which preset last passed successfully; if the system fails to boot at a higher preset, it falls back to the last known good.
The firmware supports receiving UF2 updates via the Configurator:
| Property | Value |
|---|---|
| UF2 block payload | 256 bytes |
| RP2040 family ID | 0xE48BFF56 |
| RP2350 family ID | 0xE48BFF59 |
| Flash target range | 0x10000000 - 0x10040000 (256KB) |
| Max blocks | 1024 |
| Write retries | Up to 5 with verification |
| GPIO | Signal | TMS Pin | Direction | Notes |
|---|---|---|---|---|
| 0 | HSYNC | — | Output | VGA horizontal sync |
| 1 | VSYNC | — | Output | VGA vertical sync |
| 2-5 | RED 0-3 | — | Output | VGA red (4-bit) |
| 6-9 | GREEN 0-3 | — | Output | VGA green (4-bit) |
| 10-13 | BLUE 0-3 | — | Output | VGA blue (4-bit) |
| 14-21 | CD7-CD0 | 24-17 | Bidir | 8-bit data bus |
| 22 | /INT | 16 | Output | Interrupt (active low) |
| 23 | /RST (v0.4+) | 34 | Input | Reset (active low) |
| 24 | CPUCLK (v0.4+) | 38 | Output | CPU clock (~3.58 MHz) |
| 25 | GROMCLK (v0.4+) | 37 | Output | GROM clock (~447 kHz) |
| 26 | /CSR | 15 | Input | Chip select read (active low) |
| 27 | /CSW | 14 | Input | Chip select write (active low) |
| 28 | MODE | 13 | Input | 0 = data, 1 = address/status |
| 29 | MODE1 (v0.4+) | — | Input | Extended mode (V9938 support) |
v0.3 differences: GPIO 23 = CPUCLK, GPIO 29 = GROMCLK, no hardware reset pin.
Most hardware features (PIO, DMA, GPIO, multicore) use the standard Pico SDK and work identically on both. Key differences:
| Feature | RP2040 | RP2350 |
|---|---|---|
| CPU core | Cortex-M0+ | Cortex-M33 |
| GPU assembly | thumb9900_m0.S |
thumb9900_m33.S |
| MPU format | ARMv6-M (RBAR/RASR) | ARMv8-M (RBAR/RLAR) |
| Temperature ADC | Input 4 | Input 8 (QFN80 package) |
| UF2 family ID | 0xE48BFF56 |
0xE48BFF59 (ARM Secure) |
| Integer divider | SIO hardware divider | M33 integer divider |
| Resource | Used | Total | Purpose |
|---|---|---|---|
| CPU cores | 2 | 2 | Core 0: GPU + config; Core 1: bus + display |
| PIO blocks | 2 | 2 | PIO0: VGA + clocks; PIO1: TMS9918A bus |
| PIO state machines | 6 | 8 | PIO0: SM0-3; PIO1: SM0-1 |
| DMA channels | 3 | 12 | Sync, RGB, background fill |
| Interrupts | 3 | — | PIO1_IRQ_0, DMA_IRQ_0, IO_IRQ_BANK0 |
| Flash | 4 KB | 2 MB | Configuration (top sector) |
| GPIO pins | ~26 | 30 | Data bus, control, VGA, clocks |
Hardware
- Hardware
- Hardware Setup
- Digital AV (HDMI) Dongle
- SCART AV Dongle
- TI-99 4A No-Cut Mod
- F18A to PICO9918 Dongle Adapter
Supported Devices
Firmware
Configurator
Programming
Reference