Skip to content

Firmware Architecture

visrealm edited this page Apr 8, 2026 · 2 revisions

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.

Dual-Core Architecture

The two CPU cores have clearly separated responsibilities:

Core 0 — GPU and Configuration

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 an IDLE instruction 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 — Bus Interface and Display

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.

Inter-Core Communication

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 on multicore_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.

Initialization Sequence

Core 0 (main())

  1. Set safe clock speed (preset 0: 252 MHz)
  2. Initialize vrEmuTms9918 emulation library
  3. Launch Core 1
  4. Read configuration from flash
  5. Handle clock preset testing/validation (10-second stability test for new presets)
  6. Initialize GROMCLK and CPUCLK PIO outputs
  7. Set up DMA channel for display border fill
  8. Initialize VGA output (PIO programs, DMA channels, timing parameters)
  9. Initialize temperature sensor
  10. Initialize diagnostic overlays
  11. Signal Core 1 that VGA is ready
  12. Initialize GPU
  13. Enter gpuLoop() — runs forever on Core 0

Core 1 (proc1Entry())

  1. Initialize GPIO pins (data bus, control signals, /INT output)
  2. Set up TMS9918A PIO state machines (write, read)
  3. Release /INT pin (held low during init)
  4. Detect hardware version
  5. Set up GPIO interrupt for reset pin (v0.4+ only)
  6. Wait for Core 0 ready signal
  7. Enter vgaLoop() — runs forever on Core 1

PIO State Machines

The PICO9918 uses both PIO blocks with a total of 6 state machines:

PIO1 — TMS9918A Bus Interface

PIO1 is dedicated to the TMS9918A bus, running two state machines that continuously monitor the bus control signals.

SM0: tmsWrite (4 instructions)

Captures host CPU writes to the VDP:

  1. Waits for /CSW to go LOW (write active), with an 8-cycle delay for signal settling
  2. Captures the 16-bit pin state (CD0-7 data bus + MODE + control signals)
  3. Waits for /CSW to go HIGH (write complete)
  4. 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)

SM1: tmsRead (20 instructions)

Provides data to the host CPU on reads, using a read-ahead buffer:

  1. When /CSR is HIGH (idle), continuously drains the TX FIFO for the latest read-ahead value prepared by software
  2. 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
  3. Captures the final pin state and pushes to RX FIFO
  4. 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 — VGA Output and Clock Generation

PIO0 handles display output and host system clock generation using all 4 state machines:

SM0: vga_sync (4 instructions)

Generates horizontal and vertical sync signals:

  1. Pulls a 32-bit timing word from the TX FIFO (fed by DMA)
  2. Extracts a 14-bit delay count, 2-bit sync pin states, and a 16-bit instruction
  3. Outputs the HSYNC/VSYNC pin levels
  4. Executes the embedded instruction (fires IRQ4 at the start of active display)
  5. 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

SM1: vga_rgb (5 instructions)

Outputs 12-bit RGB pixel data:

  1. Clears the RGB output pins
  2. Waits for IRQ4 from the sync SM (signals start of active display)
  3. Outputs pixel data from the TX FIFO at the calculated pixel clock rate
  4. 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)

SM2: GROM Clock / SM3: CPU Clock

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

DMA Channels

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

Channel 0: VGA Sync

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.

Channel 1: VGA RGB

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.

Channel 2: Background Fill

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.

Interrupts

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)

PIO1_IRQ_0 — Bus Transactions

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 (vrEmuTms9918WriteAddrImpl or vrEmuTms9918WriteDataImpl), 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.

DMA_IRQ_0 — Display Timing

  • 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.

IO_IRQ_BANK0 — Hardware Reset

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.

Display Pipeline

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
  1. The sync DMA drives horizontal/vertical timing continuously
  2. When the RGB DMA finishes outputting a scanline, it requests the next one
  3. Core 1 renders the requested scanline using the TMS9918A emulator
  4. The rendered pixels are written to a double-buffered array
  5. 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.

VGA Mode (640x480 @ 60Hz)

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.

SCART Mode

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.

GPU Implementation

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.

GPU Triggering

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

Memory Protection and DMA

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.

GPU Preload

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.

Configuration Storage

Flash Layout

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.

Configuration Structure (256 bytes)

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)

Clock Presets

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.

Firmware Updates

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 Pin Mapping

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.

RP2040 vs RP2350 Differences

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 Summary

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

Clone this wiki locally