CM5 Supervisor — DeMon as a Raspberry Pi Compute Module
STATUS: adopted direction. Moving DeMon to a Raspberry Pi Compute Module (CM5) is now the design, not a proposal — DeMon is the CM. This document is the original design discussion (2026-08-14/15) that led there, kept for its rationale and the detail behind each decision. The current, authoritative picture lives in bringup (bring-up, firmware corrections, integration ladder), os_card (partition layout,
config.txt, EEPROM), firmware (A/B updates, recovery) and demon (the supervisor as built) — where any of those and this document disagree, they are current, and several corrections below point to them.
1. The proposal
Replace DeMon (ESP32-P4 + ESP32-C5 supervisor, hosting AntOS) with a Raspberry Pi Compute Module — CM4 Lite or CM5 Lite. The ESP32-C5 (Phreak) stays exactly as it is and continues to own all wireless.
The thread opened on CM3+, moved to CM4 for its PCIe, ruled out CM4S as substantially more expensive, and then widened to include the CM5 Lite. Both modules are now in scope and one OS image must serve both (§3).
Current product split: eZX Spectrum on CM4 Lite, Ant64 on CM5 Lite. That's an intent rather than a decision, and it doesn't reduce the work — two products sharing an OS means the image, antboot, provisioning and updates all have to handle both generations regardless.
Hardware on hand for evaluation: a CM4 Lite (1GB) with the CM4 IO board, a CM5 Lite with the CM5 IO board, and a Sipeed Tang Mega 138K (Gowin GW5AT — PCIe hard core, DVI Rx/Tx, MIPI D-PHY, 1GB DDR3) with a PCIe edge connector and an x1-to-x4 adapter.
Note on naming: "DeMon" is used throughout for the supervisor role. Under this proposal that role is filled by a compute module rather than an ESP32-P4.
What drives it
- The softraster ceiling. The measured editor spike (ImGuiColorTextEdit at 427×240) cost 35.7ms of software rasterisation per frame — about 28fps for that component alone. The measured end-to-end editor result was about 17.5fps. The FPGA primitive-rendering path existed to remove that cost. A compute module removes it a different way — and not merely with a faster CPU: the VideoCore GPU means ImGui uses the stock GLES backend and doesn't softraster at all.
- Compute per pound. Four Cortex-A72 cores and 1–8GB against two RV32s and PSRAM is not a close comparison. The CM5's four A76s at 2.4GHz are another 2–3× on top — but that's headroom rather than the argument, since the CM4 is already well past what AntOS needs.
- Luau native codegen. Luau's JIT backends are x64 and A64 only. On RISC-V the P4 is interpreter-only; on ARM the JIT is available.
- A real OS underneath. SQLite, the SMB client, the network stack, filesystem drivers — all maintained by someone else.
What it costs
- The platform layer is the actual bill. AntOS is ESP-IDF-shaped: FreeRTOS
tasks,
IRAM_ATTRdiscipline,sys_i2c/sys_eth/sys_usb_port/sys_imgui, the boot-stage instrumentation. Very little of that survives the move, and some of it stops making sense. This is easy to under-count because the Luau layer above ports cleanly. - Boot time is the one metric where a compute module is structurally worse. See §8.
- Power and thermal. Roughly 0.5W → 2–4W. The CM5 wants more again, plus a 5A-class supply — though it does bring native fan PWM with tacho feedback, so cooling is a standard 4-pin fan rather than a design problem (§3).
- One PCIe lane, spent. Committing it to the FPGA means no NVMe. On the CM4 it also means no USB3, leaving storage on USB2 at a shared 480Mbps. On the CM5 that cost disappears — USB3 arrives via RP1 on the pins the v2 allocation already frees, so you get both the FPGA link and fast storage.
2. Module choice
Lite, wireless-less on both generations. Lite because the OS boots from SD — and on non-Lite modules the eMMC occupies the SD interface, so the OS card wouldn't work at all. Wireless-less because Phreak owns all radio.
Base build is a 1GB CM4 Lite for the eZX and a CM5 Lite for the Ant64. Note the CM5 has no 1GB variant (it starts higher and reaches 16GB), so the entry module differs by generation — see §13 for what that means for the user-upgrade label.
- The carrier asserts the wireless-disable pins, so either variant works — a wireless module simply runs with its radio off. This matters for the user-upgrade path (§11), since higher-RAM modules are easier to source in wireless flavours.
- Assert both
WL_nDISABLEandBT_nDISABLE. Killing only Wi-Fi leaves a Bluetooth controller present on some units and not others, and an unexpectedhci0appearing on a subset of machines is exactly the variance that breaks device enumeration. Phreak owns BLE HID. - Back the jumper up with
dtoverlay=disable-wifi/disable-btinconfig.txt, so AntOS's view of the world doesn't depend on a jumper someone could move. Consider whether the jumper should be user-accessible at all.
Phreak is unchanged
The whole ESP32-C5 architecture survives untouched: the at_ant64 command sets,
monitor-mode recon, BLE HID as the input onboarding ramp, the radio_caps
constraint model, single-antenna coexistence policy. Nothing has to be
re-litigated against a second radio.
Two things get better:
- The C5 flasher simplifies dramatically.
sys_c5_flash's hand-rolled EN/BOOT phases become "runesptoolwith two GPIO lines" — a maintained tool shipped in the rootfs. A chunk of firmware work gets deleted rather than ported. - Raise the AT link baud. 115200 was fine for network AT commands; it's tight
once BLE HID reports at 7.5–15ms intervals share it with recon output.
AT+UART_CURgoes well into the Mbps range and the module's UART keeps up.
3. Dual-module support and board identification
Goal: one OS image serving both CM4 Lite and CM5 Lite. Current intent is eZX Spectrum on CM4, Ant64 on CM5, but the OS must handle either.
The question that decided it
CM4 supports three simultaneous displays in any combination. CM5 also supports three, of any type — HDMI, DSI or DPI. That was the top open item, since the link allocation (§4) needs a DSI panel plus both HDMI ports concurrently. It resolves in CM5's favour and the split stands as stated.
Correction worth recording
The CM5 datasheet is explicit — pins 116–124 are "PCIe GEN 2" — so size the
link budget at ~400MB/s on both modules. But Gen3 is genuinely available and
widely used: dtparam=pciex1_gen=3 unlocks 8 GT/s, roughly 800–1000MB/s.
The reason it's only advertised as Gen2 is jitter, not capability. A Raspberry Pi engineer's explanation is that the controller can do Gen 3.0 speed but doesn't meet the Gen 3.0 jitter requirements — most devices don't care, but since it fails the specification it's certified at Gen 2.0 only, which is why the datasheet says Gen 3.0 is unsupported and may not work.
Two things make this design's odds different from the forum evidence, one in each direction. Against: "most devices don't care" is evidence from NVMe controllers, designed against wide jitter margins because they go in everything; a Gowin PCIe hard-IP endpoint hasn't been characterised the same way. For: most instability reports come from ribbon-cable NVMe HATs, and a short, well-routed trace on a purpose-designed carrier is a materially better electrical environment.
So: budget Gen2, build the carrier as though Gen3 will be attempted (impedance,
length matching, minimal vias), then test. Watch dmesg for AER corrected
errors as the canary, and verify the link actually trained at 8 GT/s — there are
reports of the parameter being ignored, with the bridge logging "Forcing gen 2"
regardless.
What differs, and what the carrier must do
| Pin(s) | CM4 | CM5 | Decision |
|---|---|---|---|
| 128–142, 157–171 | CAM0 + DSI0 (2-lane each) | Two USB 3.0 ports | Route as USB3 — CM5 gains two ports, CM4 has nothing there. v2 already drops DSI0, so no loss |
| 16, 19 | SYNC_IN, Ethernet nLED1 | Fan_Tacho, Fan_PWM | Wire for the fan; lose a second Ethernet LED on CM4 |
| 92 | RUN_PG (reset) | PWR_Button (short = wake/shutdown, long = force off) | Genuinely different semantics — drive per-module in firmware, don't wire a user switch straight to it |
| 104, 106 | Reserved | PCIE_DET_nWAKE, PCIE_PWR_EN | Free win — safe to wire for both; PCIE_PWR_EN helps FPGA power sequencing |
| 100 | nEXTRST | CAM_GPIO1 | Still driven low during boot to emulate nRESET, so reset behaviour survives |
| 94, 96 | AnalogIP1/0 | CC1/CC2 | USB-C PD negotiation for 5 A on CM5; inert on CM4 |
| 111 | VDAC_COMP | VBUS_EN | An output from the module (RP1 GPIO42) — the carrier consumes it |
| 76 | Reserved | VBAT (RTC battery) | See §15 — CM4 has no RTC at all |
Roughly 23 pins differ overall; walk the full list against both datasheets rather than trusting a summary.
ESD protection is now the carrier's job. CM4's extra protection on HDMI, SDA, SCL, HPD and CEC is removed on CM5 for space reasons, with Raspberry Pi suggesting it be applied on the baseboard. With DDC and HPD routed on both TMDS links that's real work, not a footnote.
Things the CM5 gives you for free
- Native power button (
PWR_Button, 92) plusLED_nPWR(95, needs buffering) andLED_nACT(21). FeedingLED_nPWRinto Sticky gives it a liveness signal without an I²C heartbeat — it works even when the CM is too dead to talk. - Fan control with tacho feedback (
Fan_PWM19,Fan_Tacho16), so thermals reduce to fitting a standard 4-pin fan. GPIO_VREF(78) lets GPIO0–27 run at 1.8V instead of 3.3V. If the FPGA banks facing JTAG/SPI are 1.8V, that removes level shifters entirely.- More GPIO than expected. Beyond 0–27 there are
SDA0/SCL0(82/80 = GPIO38/39) andCAM_GPIO0/1(34/35). Moving the peripheral I²C onto SDA0/SCL0 leaves 0–27 free for SDIO, quad SPI, the Phreak UART, JTAG and the boot-mode pins — roughly 20 of 28 with real headroom. - USB 3.0, arriving on pins the v2 allocation already frees. It comes via RP1 rather than the PCIe lane, so committing PCIe to the FPGA no longer costs USB3 on CM5 (it still does on CM4).
The OS side
config.txt has [cm4] and [cm5] conditional filters, so one boot partition
carries both kernels and DTBs and selects at runtime. CM5 needs Bookworm from
2024-11-19 or later, which comes free from rpi-image-gen (§11). One AntOS binary
should serve both; antboot can select a different one only if a real divergence
appears.
Known [cm4]/[cm5] branches so far:
- DTB selection
- SDIO overlay:
dtoverlay=sdioversusdtoverlay=sdio-pi5(§10) - EEPROM write-protect mechanism (§7)
- PIO availability — CM5 only (§16)
USB ports
CM4: one USB 2.0 port on the connector. That's the lot — the CM4 IO board's two sockets come from an external hub chip, not the module. A hub is therefore mandatory, and with roughly six devices (D:, D!:, E:, B:, Pulse, Phreak) all sharing 480Mbps.
CM5: three — two USB 3.0 ports (each with its own RX/TX pairs plus DP/DM for USB 2.0 fallback) and a separate USB 2.0 port on pins 103/105.
What's shared
Everything the design actually uses is common to both modules:
- All GPIO0–27, plus
SDA0/SCL0andCAM_GPIO0 - The SD interface (Lite-only on both)
- Both HDMI ports, complete — TMDS, DDC, CEC, hotplug
- The 4-lane MIPI pair — CM4's DSI1/CAM1 are CM5's MIPI0/MIPI1, so the TFT on DSI1 works on both
- All of PCIe — data pairs, REFCLK, PERST#, CLKREQ#
- USB 2.0 (
USB_N/USB_P/USB_OTG_ID), Ethernet pairs, power rails,GPIO_VREF,WL_nDisable,BT_nDisable,nRPIBOOT,PMIC_Enable,LED_nPWR,LED_nACT
None of the ~23 differing pins carries anything the v2 allocation depends on. The divergences are all either CM5-only extras (USB3, fan control) or the pin-92 semantics change.
Board identification
Module type comes from the CPU — BCM2711 versus BCM2712 — so it needs no
strap, and config.txt's [cm4]/[cm5] filters give the same distinction at
firmware level.
Board identity is separate, and it lives on Clicky, not the expander. Clicky carries a single board-type strap — KPRODUCT on its PF2 — because it boots in milliseconds and must pick a matrix-scan path long before DeMon exists. AntOS reads the same value back over Clicky's I²C status interface at 0x28: 0 = eZX, 1 = Ant64.
- One strap, one reader. This used to be two pins on the MCP23017; it moved to Clicky so there is a single source of the fact. Two straps for one truth is a build fault waiting to happen — Clicky scanning one product's matrix while AntOS loads the other's descriptor — and a strap read only by Clicky is electrically trivial: a plain tie to 5 V or GND, no translation, where a net shared with the 3.3 V expander would have worked in neither direction.
- Unstrapped falls to a default, not a wrong product: Clicky's
KPRODUCThas a weak internal pull-up, so a floating or misassembled board reads a defined default rather than a garbage ID. - Cache it in the RTC. Board ID now depends on Clicky running working firmware — exactly what is not true during
update clickyor after a bad flash, when a recovery boot most needs to know its port map, panel presence and drive letters. So AntOS writes the value into a byte of the MCP79410's battery-backed SRAM on first successful read and falls back to that cache when Clicky is silent. Board type is immutable for the machine's life, so the cache can never go stale. It is still not available before Linux (Clicky is reached over I²C), but that doesn't bite: everything pre-Linux depends on module, not board.
The board descriptor
Rather than a flag per feature, the board ID selects a descriptor table describing the carrier: USB port map and socket count, which ports are USB3, panel presence, fan, LED counts, which codec is populated.
That's the right shape because the drive-letter-by-physical-port scheme needs a topology map anyway, and because the next board difference becomes a new field rather than another pin.
USB topology per board, as designed:
Ant64 (CM5)
| Interface | Attached |
|---|---|
| USB3 port 1 | D: direct, no hub |
| USB3 port 2 | 4-port hub → B:, C:, D!:, E: |
| USB2 port | 4-port hub → ESP32-C5, ESP32-P4, mouse, keyboard |
eZX (CM4) — a single USB2 port into a 7-port hub carrying D:, D!:, B:, E:, the C5, the P4 and mouse/keyboard. No C:, since there's no cartridge.
Three things follow from the difference:
- D: and D!: share a hub on the eZX, so the fault isolation the Ant64 gets — primary and shadow on separate controllers — is lost there. The shadow still covers the likely failures (drive dies, filesystem corrupts), but it's a known limitation rather than an assumed property.
- The eZX's shadow backup is bandwidth-constrained. 480Mbps is ~35–40MB/s real, and a D:→D!: copy crosses that interface twice, so ~15–20MB/s. Incremental backup matters far more there than on the Ant64.
- 7-port hubs are usually two cascaded controllers, so paths run deeper —
1-1.2.3rather than1-1.3. Another reason to build the port map by measurement.
Power: D: alone on its own port gets the full 900mA, which matters if it's an NVMe enclosure. But the USB3 hub must supply B:, C:, D!: and E: between them, so it should be self-powered from the board 5V rail, not bus-powered — four storage devices will brown out a bus-powered hub. That load sits alongside a CM5 already wanting a 5A-class supply.
Keyboard: Clicky provides it in the end; USB keyboard is dev-only. So a hub
port frees up on both products once Clicky lands. On the Ant64 that freed port
could become a printer port — though better as a general USB2 socket that AntOS
presents as the printer port when a printer is attached. And since modern USB
printers want PCL or PostScript rather than raw bytes, a personality's PAR:,
Centronics or ZX Printer output should default to a virtual printer rendering
to PDF/PNG on D:, styled as tractor-feed paper, with usblp/CUPS as the
optional real-printer path. The virtual one works with no driver, on every
machine — and it's the better demo.
Drive letters (definitive):
| A: | SD card |
| B: | Internal |
| C: | Cartridge, over USB — n/a on the eZX |
| D: | DBFS — D!: is the SBFS shadow backup |
| E: | External USB |
| F: | Read-only shared asset library (§20) |
"Is USB3 wired?" needs no flag. If the pins aren't routed, Linux brings up an xHCI controller with an empty root hub. No error, no timeout, nothing to suppress — USB3 presence is simply one field of the descriptor.
Two things to get right when the descriptor is built:
- Each USB3 socket needs two sysfs paths. Under xHCI a physical USB3 socket
appears under both the SuperSpeed root hub and the USB 2.0 companion — a
SuperSpeed stick enumerates as
2-1.3while a USB 2.0 stick in the same socket comes up as1-1.3. One path per socket means a slow device in a fast socket gets the wrong letter or none, presenting as an intermittent bug depending on which stick someone happens to use. The eZX doesn't have this problem — one tree, all USB2. - Build the map by measurement, not from the schematic. Hubs don't reliably enumerate downstream ports in physical socket order, so the mapping has to be established by plugging something into each one and recording what appears. That's a bring-up task per board revision.
If the hubs support per-port power switching, the descriptor also enables power-cycling a wedged device or idling the fast ports — cheap capability if the hardware already has it.
4. Link architecture
The hard constraint
BCM2711 has seven pixel valves but only three HVS channels, so three
concurrent display outputs, maximum — not four. Requires vc4-kms-v3d, not
FKMS.
Allocation (v2, current)
| CM4 output | Destination | Purpose |
|---|---|---|
| DSI1 (4-lane) | 480×800 portrait TFT, 5-finger multi-touch, by the keyboard | Local touch UI |
| HDMI0 | FPGA pins | 720p over-plane — Accelerator top layer + ImGui, composited over the FPGA output; carries HDMI audio |
| HDMI1 | FPGA pins | 720p under-plane — Accelerator bottom layer, composited under the FPGA output |
| DSI0 | unused | — |
| PCIe x1 | FPGA PCIe hard IP | Bidirectional comms — and the CM↔FPGA data path |
An earlier v1 put the TFT on DSI0, DSI1 → FPGA MIPI RX, HDMI0 → FPGA. v2 is better for three reasons:
- DSI0 drops out entirely. It's the 2-lane output with a history of encoder-wiring trouble on CM4. The biggest unknown in the link plan vanishes.
- It deletes the hardest FPGA work. Receiving DSI meant D-PHY RX plus making the FPGA impersonate a panel convincingly enough for the vc4 driver (dummy panel driver, declared timings, DCS init). TMDS receive is solved, self-clocked, and the Tang Mega already advertises DVI Rx.
- Both FPGA links become identical. One receiver design instantiated twice, one CM4-side path, one set of timings to debug.
Both HDMI links are now the Accelerator's two video planes — HDMI0 the over-plane (top), HDMI1 the under-plane (bottom) — which the FPGA composites around its own output (under → core → over). The over-plane's transparency is a three-mode register (chroma-key / 6:6:6:6 / 8:8:8:6); and since HDMI carries audio as standard, the CM's audio rides HDMI0 into the FireStorm mixer. The data the second link once carried moves to PCIe (below).
The FPGA generates the final video output
The CM4 never drives the main display. This resolves the compositing question and means the dev-monitor problem disappears once the links work — you watch the FPGA's output.
Pin budget
The two TMDS links (8 differential pairs) are paid for by the pins previously allocated to the accelerator. Dropping the primitive-render path frees the supervisor MIPI uplinks, the LVDS TX pairs feeding the LT9211 bridges, and the QSPI side channel. The currency matches — LVDS TX pairs are high-speed differential IO on banks already qualified for it.
Net BOM effect: both LT9211 LVDS-to-MIPI-CSI bridges come off the board, along with their aux-bus I2C configuration. This feeds the existing assumption that the aux I2C bus goes away.
Bandwidth
Two HDMI links at 1080p60 is roughly 2 × 370MB/s — now both video, the Accelerator's over- and under-planes (the over-plane optionally 30-bit 10:10:10 for the 8:8:8:6 alpha mode, ~+25%). Measured draw lists
were 115KB/frame at 427×240 ≈ 7MB/s; even several times that for a
full-resolution editor is nowhere near the ceiling. You are not bandwidth
limited anywhere — you're limited by concurrent output count.
PCIe now carries the CM↔FPGA data path (the role once floated for the HDMI1 data channel), capped by the CM — one lane, ~400MB/s on a CM4 (Gen2), ~800–1000MB/s on a CM5 (Gen3); the tighter CM4 case is the number to confirm in bring-up. That single lane is all the FPGA needs from the CM, and its second SerDes block isn't an idle PCIe core — it's the DisplayPort transmitter driving the Parade PS176 DP→HDMI bridge for the main 4K output (see hdmi_out).
The over-plane (HDMI0) stays a fixed 720p — ImGui lives there and must not modeset — with the classic-resolution graphics scaled up into that frame and the FPGA scaling 720p to the display. The under-plane (HDMI1) is free to modeset in hardware (no UI rides it) to whatever classic mode or aspect the content wants; it can even run a wide, short frame packing two or four layers side-by-side for the FPGA to demux and thread between its own layers (a later option the free bottom link leaves room for). Using both ports a CM4 is capped at dual-1080p60 — far above what classic-res planes need (~148MHz vs the ~300MHz per-port limit, ~185MHz at 10-bit on the top); dual-4K60 is a CM5 capability. Two planes plus the small TFT are the BCM2711's three-HVS-channel maximum, and since the FPGA — not a monitor — receives, modes are forced via the per-link EDID EEPROM, never negotiated.
Pin cost of the CM links (settled)
Each CM→FPGA link is 8 FPGA pins — the four TMDS pairs only. None of the other HDMI signals need to reach the FPGA:
- CEC — dropped from these links entirely. The CEC that matters comes from the real HDMI output to the TV, and Pulse owns it (§14).
- HPD — the CM's input is internally pulled down, so a pull-up resistor on
the carrier is the whole solution. The FPGA is permanently present; there is
nothing to detect. Or
hdmi_force_hotplug:0=1and don't wire it at all. - SDA/SCL — EDID is just a 256-byte I²C EEPROM at address 0x50, so a 24C02
per link with a programmed blob costs pennies, needs no FPGA pins and no
firmware, and is up the instant power is applied. Alternatively force the
timings in
config.txtand leave DDC unconnected.
Plus vsync and hsync back to the CM (below), so the full CM-link cost is 18 FPGA pins. JTAG is free — TCK/TMS/TDI/TDO are dedicated configuration pins on the GW5AT, outside the fabric budget — as are PCIe and DP, which live on the SerDes.
Current FPGA budget: 18 pins spare after all of the above.
Those spares are routed to the cartridge port, along with the cart's other signals — a connector beats an unpopulated header, because spare IO that reaches the outside world can be used by a cartridge without a mainboard respin. That changes what they are, though: they become a public, user-accessible, hot-pluggable interface, which brings four requirements.
- Protection. Series resistors and ESD diodes on every line. That adds capacitance and caps usable speed, so treat them as slow-to-medium signals given the connector-plus-cartridge stub.
- Hot-plug sequencing. Pins mate in arbitrary order, and an FPGA output driving into an unpowered cartridge can leak through its protection diodes. Staggered contacts so ground and power mate first, and the FPGA holds every cart pin high-Z until a detect pin says a cart is seated and powered.
- One bank, one VCCIO, so a cartridge designer sees a consistent interface. Worth recording in the schematic which bank each spare sits in and which are properly paired.
- The pinout freezes the moment a cartridge uses it. Define it deliberately now — assign functions, mark genuinely reserved pins as reserved, and put an ID EEPROM or ID resistors on the cartridge so AntOS knows what's plugged in and can refuse a cart expecting a revision it doesn't have.
The keyboard panel's other pins
Beyond the four DSI lanes, a small panel brings out three things worth planning:
Backlight — LED-A and LED-K. These are the raw ends of the LED string, not a control input. A 3.5–4″ panel is typically six to ten LEDs in series at ~3.2V each, so 20–30V forward at ~20mA — far above what an MCU pin can drive, and LEDs want constant current rather than a voltage. So Sticky's PWM drives a driver's input: a low-side N-FET plus a current-setting resistor if the total forward voltage is under the 5V rail, otherwise a constant-current boost with a PWM input. Keep PWM above ~20kHz to avoid inductor whine and camera banding.
Sticky owns the backlight, because it's up in milliseconds and can hold it off from the instant power is applied — which is how the keyboard TFT stays dark through the Linux boot (§8). Pull the driver's EN pin down by default, so that behaviour holds even before Sticky's firmware runs. The hardware earns its place anyway for brightness control, idle dimming and standby blanking.
Note: don't power the panel down instead. DSI lines driven by the CM into an unpowered panel can forward-bias its ESD diodes, and the kernel's panel driver initialises at probe time, so a late power-up leaves it uninitialised.
Reset — RSTB. This goes to a CM GPIO, not Sticky, declared in the device
tree as the panel's reset-gpios. The kernel's panel driver owns the reset
sequence and its datasheet timing, so a Sticky-held reset would make probe fail.
Pull it down — CM GPIOs come up as inputs, so RSTB would otherwise float and
the panel could leave reset at an undefined moment.
Worth reading in the panel datasheet before the schematic is fixed: LED count, series/parallel arrangement, VF and IF (these decide which backlight circuit); the power-on sequence diagram (VDD versus VDDI ordering and minimum delays); and whether there's a TE pin, which matters only for command-mode panels.
One thing that would break the plan: a panel whose brightness is set by DCS
commands over DSI (MIPI DCS 0x51) rather than a separate backlight pin. That
puts the backlight under the CM's control with nothing for Sticky to hold.
The eZX has no cartridge port, so anything routed there serves the Ant64 only. The eZX's equivalents live in its service hatch (§12).
Sync signals back to the CM
- VSYNC — the real vsync, pulsed at the start of vblank (bottom of screen, just outside drawing time).
- HSYNC — the source hsync, before line repetition for upscaling. At 9× vertical upscale that's one pulse per nine output lines.
Both pulse when the sync actually starts, outside drawing time.
Vsync is an interrupt; hsync is reserved. At 50–60Hz a vsync interrupt costs
nothing and closes the loop the decoupling framebuffer opens (§6) — the CM
otherwise has no idea when the FPGA consumed a frame. Source hsync at ~15.7kHz
gives a 63.6µs period against Linux IRQ latency of 10–100µs, so the jitter
exceeds what you're trying to resolve. Line position comes from the vsync
anchor plus ARM's CNTVCT instead — local, nanoseconds, no bus access,
re-anchored each frame.
Hsync is wired as HSYNC_RESERVED regardless: one pin, free at layout time,
expensive to retrofit, and useful during bring-up to prove the derived model
matches hardware. Choose a pin that is PIO-capable on CM5 and interrupt-capable
on CM4 so both futures stay open, and give it a defined idle state so an unused
input doesn't float. The one case the derived model can't cover is a core that
changes line timing mid-frame — rare, but the reason the pin exists.
Other practical notes
- Keep the over-plane at HDMI 1.4 timings for the packed-alpha modes. The
6:6:6:6/8:8:8:6alpha modes need FireStorm to read exact pixel bits; above ~340MHz character rate, HDMI 2.0 scrambling makes that much less pleasant. Chroma-key mode 0 and the under-plane are ordinary video and don't care. - One plane per output on the FPGA links. The HVS must fetch every plane's pixels in real time; three channels live at high resolution can hit FIFO underflow. Compositing is the FPGA's job now anyway.
- The Sipeed dev board likely has only one HDMI Rx connector, so proving two simultaneous receivers may need the real carrier. Plan first tests around one link.
- Panel choice. At numpad size (~3.5–4″) DSI panels are thinner on the ground than SPI or parallel RGB. Choose a panel whose controller already has mainline support (ILI9881C, JD9365) — vendor init sequences are where DSI projects lose weeks. If the panel ends up SPI, DSI1 frees up and the allocation opens back out.
PCIe sidebands
Of the module's PCIe control pins:
| Signal | CM5 pin | Verdict |
|---|---|---|
PCIe_CLK_P/N |
110, 112 | Mandatory — the 100MHz reference; the FPGA can't train without it |
PCIe_nRST (PERST#) |
109 | Mandatory — tells the endpoint to begin link training |
PCIe_CLK_nREQ (CLKREQ#) |
102 | Needed as a net, not an FPGA pin. Pulled up on the module = "not requesting", so tie it low on the carrier and REFCLK is always on |
PCIE_nWAKE (WAKE#) |
104 | Not needed — only if the FPGA must wake the host from sleep |
PCIE_PWR_EN |
106 | Optional, and Reserved on CM4 so free to wire on a dual-module board. Useful for FPGA power sequencing |
Considered and rejected: USB 3.0 as a second link
Wiring a CM5 USB 3.0 port to the FPGA as another bidirectional channel doesn't work, for four reasons that stack:
- Both SerDes blocks are spoken for — PCIe to the CM, DisplayPort to the PS176. SuperSpeed needs a third; the spare lanes in the PCIe block are tied to its hard controller.
- USB is worse on the axis that matters. Packet-based and host-scheduled, so latency runs tens to hundreds of microseconds, and it can't memory-map at all. Bandwidth was never the problem.
- The ports are worth more as storage — one of the concrete CM5 wins.
- It's CM5-only, breaking the dual-module symmetry protected everywhere else.
If a need ever appears, the realistic version is USB 2.0: those same connector pins carry the USB 2.0 pair, so an external ULPI PHY plus a soft device controller gives ~60MB/s on about twelve FPGA pins with no transceiver. The attraction isn't throughput but standard class drivers — the FPGA could enumerate as mass storage, USB audio or HID with no custom Linux driver, which is how a cartridge could appear as a drive letter without kernel work.
FPGA reconfiguration and the PCIe link
Reconfiguring the FPGA — for a new core, or a bitstream update — drops the link. The link half is easy: once it goes down with PERST# still deasserted, the LTSSM re-trains automatically. The problem is above that. Reconfiguration wipes the endpoint's config space, so the BARs Linux assigned are gone and its view is stale.
So it's software orchestration, not hardware. AntOS quiesces traffic, writes to
the device's remove, reconfigures the FPGA, then triggers
/sys/bus/pci/rescan and re-maps. AntOS is the thing initiating the
reconfiguration, so it's in the right position to sequence all of it.
Control signals live on the IO expander. RECONFIGURE, READY and
CONFIG_DONE all get MCP23017 pins rather than direct CM GPIO. That's right for
a specific reason: I²C stays up when the PCIe link goes down. Reconfiguration
control must not run through the thing being reconfigured, and none of these
signals is timing-critical, so I²C's ~100–200µs access time is irrelevant.
Two cautions:
- The MCP23017 powers up with every pin as an input. The
RECONFIGUREnet therefore floats until AntOS initialises the expander, a second or more after power-on. Give it a passive pull to its safe state, or the FPGA risks being held in reset or spuriously reconfigured during early boot. Same logic for anything else on the expander with a dangerous default. - Use INTA/INTB rather than polling if
CONFIG_DONEshould signal asynchronously — interrupt-on-change means AntOS is told when configuration completes rather than waking to check, which matters if core switching is meant to feel instant.
Also note the recovery slot needs the expander driver, since recovery is exactly the situation where a bad bitstream needs replacing.
The enumeration race, and why it isn't one
The PCIe spec allows roughly 100ms between PERST# deassertion and the host's
first configuration read. A GW5AT-138 self-loading from SPI flash may well take
longer, which at first looks like it forces a hardware fix — gating PERST# with
CONFIG_DONE, or a delay circuit.
It doesn't. Just have AntOS rescan unconditionally at startup. The FPGA configures in a few hundred milliseconds while Linux userspace doesn't exist for a second or more, so by the time AntOS runs the endpoint is long since ready and the rescan always succeeds. No gate, no delay circuit, no race to design around — and the same code path serves both first boot and post-reconfiguration.
Worth asking Gowin: whether the GW5AT supports partial or dynamic reconfiguration that leaves the PCIe hard IP and its transceivers configured. If it does, cores could be swapped without the link ever dropping — no remove, no rescan, no stale BARs. That would be a materially nicer core-switching story.
5. The accelerator consequence
The FPGA primitive-rendering accelerator is no longer needed. The CM4 is one.
This is the strongest single argument in the proposal, and it's stronger than "the CPU is faster" — the CM4 has a VideoCore VI with OpenGL ES 3.1, so ImGui uses the stock GLES backend and doesn't softraster at all.
More importantly it deletes the requirement that was shaping FireStorm hardest:
a texture sampler for the font atlas, plus whatever
ImGuiBackendFlags_RendererHasTextures would have demanded for runtime font
scaling.
Scope caution: what dies is the accelerator for the AntOS overlay. The invariant still says AntOS composites over whatever the personality shows, so the FPGA still needs video ingest, an overlay framebuffer and alpha blend (§6) — much smaller than a primitive renderer with a sampler, but not nothing. FireStorm cores still rasterise their own chipset.
Immediate payoff to test
ImGui at proper resolution and full colour, against the current 427×240
RGB332. The colour jump may be the bigger half: ImGui leans hard on alpha
(window backgrounds, hover highlights, docking overlays), and two bits of blue
has been quantising all of it. The editor stops being a netImgui-only feature,
and the visible-predicate workaround becomes unnecessary.
Design question worth deciding rather than defaulting into: more pixels isn't automatically better. A retro machine at 1080p with a 13px UI font looks like a Linux box. Consider 720p with a deliberately chunky scaled font, or a raised ImGui global scale.
Luau, honestly
The A64 codegen is real, but less automatic than it sounds:
- Opt-in. Needs
LUAU_ENABLE_CODEGENand explicitluau_codegen_create/luau_codegen_compilecalls. - Needs executable mmap. A hardened rootfs denying
PROT_EXECdrops it to interpreter silently. - Gains concentrate where types are known. Annotated numeric code sees large speedups; untyped table-heavy script code much less. Annotating hot paths is what unlocks it.
- Compile cost is real. Blanket JIT at load adds latency, cutting against the boot-time work. Selective codegen on hot functions.
6. Video output architecture
Both output paths are already fully specified on ant64.com — fetch rgb_out and hdmi_out rather than reasoning from scratch. This section records only what the CM4 move changes about them.
The two ports
| Port | FPGA resource | Bridge chip | Output |
|---|---|---|---|
| Main | Second SerDes block, DP out | Parade PS176 (DP→HDMI) | HDMI up to 4K |
| Retro | Fabric-pin LVDS, TMDS out | Algoltek AG6201 (HDMI→VGA + audio DAC) | DE-15 VGA + 3.5mm audio |
The retro port costs ~11–12 FPGA pins (4 TMDS pairs + HPD + 2 I²C for DDC) and includes the 240p/288p superresolution trick for genuine 15kHz CRT output. The AG6201's embedded 8-bit DAC also provides the analog audio, so no separate audio chip or FPGA audio pins are needed on that path.
The SerDes path is supported by vendor IP
Gowin's catalogue carries an eDP PHY (eDP being an embedded extension of DP and fully DP-compatible), an SDI PHY (SMPTE HD/3G, TX and RX simultaneously), and a DP Aux IP which "configures DPTX, DPRX, and DP PHY parameters through the APB bus". That last one is the important find: naming DPTX and DPRX as configurable targets means Gowin ships the controllers, not just the physical layer, so the whole DP stack is available as stock IP.
Link training belongs on the hard RISC-V core, not AntOS. It's slow, sequential, retry-heavy control logic driven over APB — miserable in HDL, trivial in C, and the GW5AT's hard core is already in the design for FireStorm hardware debugging. Critically it must not be driven over PCIe from the CM4, because that would make display bring-up wait for Linux and destroy the property we actually want: the FPGA self-loading from flash and showing something within a few hundred milliseconds while the CM4 is still in its bootloader. Putting training on the hard core takes the CM4's boot time off the critical path for first pixel (see §8).
The hard core then has two jobs — FireStorm debug and DP link management — but they don't contend: training runs at bring-up and on hotplug, debug runs when a target has fallen over.
Pin cost: AUX pair plus HPD — three, not two. HPD isn't optional here, because the PS176 uses it to forward downstream HDMI events (TV switched on, unplugged, EDID changed) to the DP source, and DP's IRQ_HPD short-pulse mechanism rides the same line. Two things to confirm before routing: whether Gowin's AUX implementation wants a true bidirectional differential pair or separate TX/RX plus direction via an external AUX transceiver (which would make it five), and whether the PS176 is strap-configured or needs I²C — if I²C, it belongs on Pulse, with any reset/enable on the IO expander.
SDI is not a route to 4K. 3G-SDI caps at 1080p60; 4K would need 12G, which these transceivers won't reach. It's interesting as an output in its own right (unusual on a retro machine, useful for clean capture) but it isn't part of the HDMI plan.
Why 4K, precisely
Not for 4K content. The point is to output at 4K so the FPGA does the upscaling rather than the TV — a TV's own scaler won't look right — and so raster/scanline behaviour can be simulated accurately from a low-resolution feed.
The arithmetic is the argument:
- 240p → 2160p is exactly 9×. Into 1080p it's 4.5× — non-integer, so source lines land unevenly however you dither them.
- Nine output lines per source line is enough to shape a beam profile (say six bright, three dark, with controlled falloff). At 4.5× you can't even do it symmetrically. That's the difference between scanlines as a checkbox and scanlines that look like a CRT.
- PAL 288p is 7.5× into 2160, so it doesn't land as neatly. Worth knowing which modes get the clean treatment.
Bonus: the FPGA's scaler is also lower latency than a TV's, which commonly adds a frame or more.
The line-rate question (open)
4K60 at 8bpc needs roughly 12.5 Gbps of stream.
- HBR2 (5.4 Gbps/lane) × 4 lanes ≈ 17.3 Gbps after 8b/10b — comfortable.
- HBR (2.7 Gbps/lane) × 4 ≈ 8.6 Gbps effective — not enough for 4K60.
Largely answered: Gowin's PCIe controller page states the same SerDes support x1/x2/x4 at up to 8 GT/s (Gen3). If the transceivers reach 8 Gbps, HBR2 at 5.4 Gbps is comfortably within range and 4K60 8bpc is achievable. Worth confirming against the transceiver datasheet rather than the PCIe IP page before the spec claims it, but the earlier worry about being stuck at Gen2's 5 Gbps appears unfounded.
Gowin also lists DP and eDP development kits for GW5AT parts alongside the PCIe one, so reference designs exist for both paths on this family.
Mitigating factor: since the content is a 9×-upscaled retro framebuffer, chroma detail beyond the source is fiction, so 4:2:0 costs essentially nothing here — unlike for real 4K material. And 4:2:0 keeps Y at full resolution, so if scanlines are applied as a luma modulation the beam profile survives intact. 4K60 4:2:0 is a genuine answer rather than a compromise.
The overlay: one 720p screen, both ports
The CM4 renders two 720p planes — the Accelerator's over-plane (HDMI0: ImGui + top graphics) and under-plane (HDMI1: bottom graphics) — and the FPGA composites them around the core's output (under → core → over), showing the result on both output ports.
Why 720p:
- 720p → 4K is exactly 3× on both axes.
- 240p → 720 is exactly 3× too, so the same integer property holds one step down for the retro port. (288p is 2.5× into 720 — doesn't land cleanly.)
- 4:3 content in a 16:9 frame gets a transparent border — accepted, and correct behaviour anyway since you don't want 4:3 stretched.
- Chunky pixels are the intent. 1080p would also scale integer (2×), so this is an aesthetic choice, not a scaling constraint.
- Practical margin: 74.25MHz pixel clock instead of 148.5 on the CM4 links, much lighter HVS load with three channels live, and the VideoCore barely noticing.
Note the distinction: "retro look" for content comes from the core's own framebuffer resolution. Running the AntOS overlay at 720p is a separate aesthetic decision about the UI itself. It's still transformative against today's 427×240 — roughly 160 columns instead of 50 — but it's half the text real estate a 1080p overlay would give, which cuts against the editor being comfortable.
720p on the retro port
The AG6201 covers 480p60 through 1920×1200p60 (HDMI's 25MHz TMDS floor is the lower bound), so 720p is well inside its range. Monitor compatibility is better than it first appears:
- 1280×720@60 is a real VESA DMT mode — 74.25MHz, 45kHz horizontal. That sits comfortably inside almost every multisync's range.
- 1280×720@50 is CEA-861 mode 19 — same clock, wider htotal, 37.5kHz horizontal. Not in a DMT-based EDID, so it won't be advertised. CRTs generally take it; many VGA LCDs refuse 50Hz outright.
Running the retro port at 1280×720 means the overlay is 1:1 with no resampling at all — the FPGA scales the core's output up into 720p and composites the overlay untouched.
EDID clamping still applies: check what the monitor advertises, fall back to 640×480 when in doubt.
Refresh rate and the decoupling framebuffer
The retro port runs at 50 or 60Hz matched to the core's native rate — a PAL core on a 60Hz output either judders or needs interpolation, so matching is the whole point.
That would normally mean the CM4's overlay link has to change rate whenever the core changes, turning a core switch into a KMS modeset with a link drop and retrain. Decouple it instead:
- CM4 HDMI links stay at a fixed rate.
- The FPGA framebuffers the overlay and resamples it to whatever each port is running.
- Cost is one frame of latency on the overlay only — UI, where latency barely matters. The core's own path stays at native rate.
- Cores can switch between 50 and 60Hz freely and the CM4 never renegotiates.
That framebuffer does double duty as the per-port scaler, since it's already being read at a different rate than it's written. Double-buffer it, and make the swap safe for two independent readers — a swap that's atomic for the HDMI reader but lands mid-scanout for the RGB reader shows up as an occasional flicker on one port only. Two 720p buffers is nothing against the board's 1GB of DDR3.
With rates decoupled, the overlay links don't have to run at 60Hz at all. UI doesn't need it and ImGui is already render-on-change, so a 30Hz link mode would halve the TMDS clock and HVS load if the display pipeline ever gets tight.
Loose ends in this section
- Superresolution and the shared overlay don't coexist. At 240p on a CRT the 720p overlay isn't legible whoever scales it. Decide deliberately: suppress the overlay in 15kHz mode, or give that mode its own ImGui context drawn for 240 lines. Contexts are already per-surface — that's how the local display and the netImgui client differ — so a third context fits the existing pattern.
- The second link is now the under-plane. HDMI1's payload — long unspecified once the FPGA primitive renderer went (§5) — is settled: it is the Accelerator's under-plane video, the bottom of the under → core → over sandwich. The one-way asset-streaming role once floated for it (core assets, emulator framebuffers, bitstream loading) moves to PCIe, which now carries all CM↔FPGA data.
- Clock-domain budget. The FPGA now has two TMDS receivers with independent recovered clocks coming in, plus the DP TX and the AG6201 TMDS TX going out — four video clock domains where rgb_out was written assuming fewer, and the retro port's pixel PLL generally isn't shareable with the main output. Tally PLLs and the CDC between them before the carrier is fixed; this is the sort of thing that's fine on paper and awkward in the fitter.
- EDID handling gets easier. The AG6201 exposes the monitor's EDID over HDMI DDC, and with PCIe in place AntOS can read and interpret it directly rather than needing modeline-clamping logic in fabric.
Correction worth recording
An earlier claim in discussion — that MiSTer's analog output comes from a direct FPGA DAC and that 15kHz through an HDMI→VGA converter was impractical — was wrong. MiSTer's Direct Video is exactly an HDMI→VGA path on the same AG6200/AG6201 silicon, and the superresolution modeline handles 15kHz. The rgb_out design follows that prior art deliberately, which is why the MiSTer community's cable ecosystem and compatibility findings carry over.
7. Boot media and the OS card
- Boot from an internal SD card, marked "OS", separate from the user-facing A: SD card.
- The card is never written except when updating firmware. Read-only rootfs.
- USB remains main storage, as today.
- A second, slow SPI-mode SD card is reachable via Pulse.
- Three slots: A/B pair plus a third, never-updated recovery slot.
Recommended shape
- Skip systemd entirely. The gap between "minimal Linux" and "trimmed Pi OS"
is most of the boot budget. Set
init=to our own shim (see §9). - squashfs rootfs. Compressed, inherently read-only, integrity-checkable, and
each slot becomes a single image blob rather than a filesystem to keep
consistent. Overlay a tmpfs upper for
/var,/tmp, runtime/etc. Logs to tmpfs or USB, never the card.
Bootloader EEPROM
- Correction worth recording: the CM4 default is
BOOT_ORDER=0xf25641, read right-to-left — SD is tried first, then USB-MSD, NVMe, USB XHCI, network. On a healthy SD boot the fallbacks never run, so there is no time cost. An earlier claim in discussion that the default probes USB first was wrong. - Still worth pinning, for determinism not speed. With the default order, a
momentarily unreadable OS card falls through to USB mass storage — and E: is a
user-accessible external port. A bootable stick left plugged in becomes the OS.
BOOT_ORDER=0xf1(retry SD forever) or0xe1(SD, then stop with an error the LED codes can signal). The second is more honest. ENABLE_SELF_UPDATE=0. Otherwise the bootloader can update itself on network or USB-MSD boot — a second, invisible firmware path bypassing A/B.- Write-protect the EEPROM once the config is settled — see §12 for how to
get back if it's set wrongly. On CM4 it's two-part:
pulling
EEPROM_nEPlow does nothing unless the Write Status Register is also configured. On CM5 it's simpler —EEPROM_nWP(pin 20) is internally pulled up and grounding it prevents writes, full stop. One more[cm4]/[cm5]divergence (§3). - Write protection also blocks recovery. The rpiboot route (nRPIBOOT jumper,
USB device mode,
recovery.bin) can't reflash a protected EEPROM. SoEEPROM_nEPmust not be hardwired to ground:- Jumper or test point — strongest, unlocking requires opening the case, can't be defeated remotely, costs a service visit for bootloader updates.
- Driven from a spare MCP23017 pin or GPIO — allows deliberate in-field
unlock/relock, but anything running as root can also unlock.
Decide before the carrier layout is fixed; retrofitting access to that net
is unpleasant. Assert protection only after a full cold boot is verified on the
final config. If
flashromis anywhere in provisioning, note it can't clear write-protect regions and will just fail against a locked part.
8. Boot time budget
Power-on to AntOS's first instruction, Buildroot-class minimal system, squashfs, no initramfs pivot:
| Stage | Estimate | Notes |
|---|---|---|
| Bootloader → kernel handoff | 600ms – 1.0s | Hard floor. SDRAM training, VPU firmware, reading start4.elf (~3MB) + kernel |
| Kernel | 400ms – 1.0s | Trimmed arm64, drivers built in, quiet loglevel=0, no serial console |
| Userspace → AntOS | 50 – 150ms | Mount squashfs, exec |
| Total | 1.2 – 2.2s | ~1.5s is a fair planning number |
Two budgets, not one — this table is CM4/2711-shaped. The
start4.elfread is the 2711 path; the CM5/2712 reads no ELF at all (its bootloader has the firmware embedded and loads the kernel directly), so the eZX (CM4) and Ant64 (CM5) have different handoff floors — measure both, don't assume ~1.5 s covers the pair. See bringup corrections 4–5.
Helpful settings: boot_delay=0, BOOT_UART=0, and a boot partition containing only what's actually loaded. disable_splash=1 is CM4-only — it suppresses start4.elf's rainbow, and there is no start4.elf on the CM5; the CM5 equivalent is the EEPROM's DISABLE_HDMI=1, HDMI_DELAY=0, NET_INSTALL_ENABLED=0 (bringup correction 4). start4cd.elf (smaller, faster, drops display output) is likewise CM4-only, viable only if the CM4 never drives a display itself.
printk over UART at 115200 is genuinely expensive and is the most common reason a "minimal" build boots slowly.
Against the current system
AntOS on the P4 reaches app_main done at T+1713ms and first pixel at ~3.1s. So
the CM4 arrives at roughly where the P4's app_main finishes, at about the same
time. The swap is close to boot-neutral, not obviously worse.
The actual lever: who configures the FPGA
- If the FPGA self-loads its bitstream from SPI flash, it can be configured in a few hundred ms — potentially showing something before the CM4's bootloader has handed off to the kernel. The "FPGA displays the loading bar, AntOS feeds it progress" design then works properly, perceived boot time decouples from Linux entirely, and we beat the current 3.1s comfortably.
- If the CM4 pushes the bitstream, the whole Linux boot lands in front of first pixel and the CM4 loses on the metric we care most about.
Resolve this early. It matters more than any kernel tuning.
What the user sees
The rule: nothing from Linux, ever. No bootloader splash, no kernel text, no fbcon, no mode-change flicker.
The clean mechanism isn't suppression on the CM side — it's that the FPGA gates
the overlay layer. It doesn't composite the CM's stream until AntOS signals
ready, so everything Linux emits during boot is invisible by construction rather
than by hunting down each source. Belt and braces on the CM anyway:
logo.nologo, quiet loglevel=0, fbcon off, plus disable_splash=1 on the CM4 (the CM5 uses the EEPROM's DISABLE_HDMI / HDMI_DELAY / NET_INSTALL_ENABLED instead — bringup correction 4).
The FPGA shows its own splash, and that's the point. Self-loading from flash in a few hundred milliseconds, it covers the entire Linux boot — so perceived boot time becomes the FPGA's ~300ms rather than Linux's ~1.5s. That's the lever from "who configures the FPGA" above, realised.
Sequence: black → FPGA splash (~300ms) → AntOS banner.
- Output black, not no-signal. A valid black frame from the moment the FPGA is configured, so the TV locks sync early. Otherwise you get a "no signal" OSD and a second of sync when the picture appears — more noticeable than the boot text you were hiding.
- Fade, don't cut. The FPGA already alpha-blends the overlay, so fading the splash out as the overlay comes up costs a multiply it's doing anyway.
- Indeterminate animation, not a progress bar. PCIe doesn't train until the kernel initialises it around a second in, so the FPGA can't know progress for most of the window it's covering. AntOS can feed real progress for the last stretch once the link is up.
- The splash lives in the bitstream, which is product-specific anyway — no board-ID lookup needed.
The keyboard TFT can't be gated — it's a panel the FPGA doesn't drive. So Sticky holds its backlight off until AntOS signals ready (§4), which gives the same result by a different route.
The power button is the intended exception. Sticky lights it within milliseconds, so the machine visibly comes alive while the screen stays black. The button says "I'm on"; the screen says nothing until it has something worth saying.
One safety valve: if AntOS never starts, a permanently black screen is indistinguishable from a dead machine. The FPGA or antboot needs a timeout — after N seconds with no overlay, show an error or the boot log.
First bench measurements, in this order
- Set
BOOT_UART=1temporarily and timestamp the bootloader stage. This number tells you whether the rest of the optimisation is worth doing. dmesgtimestamps for the kernel.- PCIe round-trip latency to the Gowin hard IP under display-list traffic.
- Cold boot to first pixel on a minimal rootfs.
- DSI1 driving the chosen TFT, standalone — highest-risk item, cheap to test, expensive to discover late.
- Transceiver maximum line rate, from the datasheet — gates whether the main port can claim 4K60 (§6).
9. Boot mode selection — three levels
Each mechanism does only what it's actually capable of.
| Level | Mechanism | Selects |
|---|---|---|
| Bootloader | os_prefix + one GPIO conditional |
Kernel/rootfs slot; the single GPIO = recovery |
| Bootloader | tryboot |
One-shot rootfs slot trial |
| Userspace | antboot + RTC SRAM |
Which AntOS binary |
The I2C blocker
The Pi bootloader cannot do I2C. It reads config.txt / autoboot.txt and
can branch on GPIO state — that's the entire vocabulary. Nothing before the
kernel can see the MCP79410. This is why RTC-based selection lives in userspace
(§10) rather than in a pre-Linux stage.
An earlier idea — a tiny selector kernel + initramfs that reads the RTC and
kexecs the real kernel — would work but costs an extra kernel boot (~0.5–1.0s
on a 1.2–2.2s budget) on every boot, to serve a decision that is almost always
"slot A, as usual". Only worth it for a policy the cheap mechanisms can't
express, e.g. "three failed boots across power cycles, fall into recovery" —
tryboot is one-shot and can't count. (Update: the CM5 bootloader now has [boot_count>N] expression filters, so that policy is expressible in config.txt on the Ant64 — but they are Pi 5-family only, so the CM4/eZX still needs the userspace equivalent in antboot. See bringup correction 8.)
GPIO combinations → one binary decision
(Updated per bringup correction 1 — the original four-state plan does not work.) All GPIO conditionals in config.txt are the same filter type, so a second one replaces the first rather than combining with it: there is no AND, and the four-state table this section originally proposed cannot be expressed. The pins are not spare either — demon's GPIO map now allocates GP0–GP27 completely, with nothing free on the CM5.
So the firmware level gets one binary decision, and it should be the only thing that genuinely needs pre-Linux effect: recovery. That goes on the single GPIO conditional the bootloader can honour, choosing an os_prefix within the boot partition (each prefix's cmdline.txt sets a different root=; use os_prefix, not autoboot.txt, since the partition is picked before config.txt is read). Everything else — dev mode, verbose boot, safe mode, skip splash — becomes a latched key modifier read by antboot or Clicky at startup, not a pre-Linux GPIO state.
- Check the default pull state so "nothing held" boots normally — a floating recovery pin must not strand a customer in recovery.
- Clicky race: Clicky must be powered, have scanned the matrix, and be driving the recovery pin before the bootloader samples it. An AVR128DB64 comes up in milliseconds so it'll probably win — time it on the bench before Clicky owns the decision.
- Unconfirmed on the CM5: do
[gpioN=…]filters work at all on a 2712, where the GPIOs sit behind RP1 over an internal PCIe link? Every worked example is Pi 4-era. It is a ten-minute test that gates recovery selection on the Ant64 — see bringup ("the one nobody has confirmed").
tryboot and the RTC
tryboot gives A/B revert nearly free: autoboot.txt selects the partition,
reboot "0 tryboot" is one-shot and leaves no persistent state, so an image
that fails to come up reverts on the next power cycle with zero writes to the
card. Writes happen only when committing a known-good slot.
The RTC SRAM holds the state, not the selection: boot attempt counter, last-known-good slot, a reason code for the last revert. Written and read by Linux, never in the critical path.
RTC cautions: 64 bytes total, already carrying the NTP server — allocate
offsets deliberately. A dead CR2450 means the contents are simply gone, so the
failure path must be "boot the default", never "can't decide, won't boot". Give
the boot block its own magic plus a checksum rather than sharing the existing
0xA5 guard; a torn write here costs a boot rather than a hostname.
10. antboot and the AntOS binaries
The AntOS executables live in the FAT boot partition. antboot lives in the
squashfs rootfs.
antboot reads the RTC non-volatile SRAM to decide which AntOS executable to
start. There can be many of them on the partition — a few hundred MB of FAT
holds a lot of AntOS builds, so rollback stops being a recovery procedure and
becomes just picking a different one.
Why antboot is in the rootfs
init= resolves against the mounted root, so antboot has to be in the rootfs.
That's the right home anyway — it's the thing that must never fail to start, so
it belongs in the immutable image, statically linked, with no mesa or libdrm
dependency, making it immune to the ABI coupling that could break an AntOS
binary. It mounts /boot and execs from there.
Don't mount that partition noexec — vfat has no permission bits, so everything
is executable by default, which works in our favour.
antboot should fork, not exec
If it execs, AntOS becomes PID 1 and inherits zombie reaping and signal handling. If antboot forks and stays as PID 1, we get three things free:
- Proper reaping.
- Crash detection — AntOS exiting unexpectedly is observable.
- Somewhere to put the fallback policy: if the selected binary dies within N seconds, try the previous one and record why.
Same crash-isolation instinct already applied to the FPGA CPU, one level up.
Manifest, not filename, in SRAM
Put a one-byte index in SRAM pointing into a manifest on the FAT partition, rather than a filename. SRAM is tight; the manifest can carry versions, hashes and the minimum-rootfs-version check on storage we can afford to spend. Keep a hardcoded default so a dead battery means "run the default".
Version the contract from day one
antboot updates on the Tier 2 cadence (§13), so what it reads is a long-lived contract — a machine running a two-year-old squashfs will still be parsing manifests written by today's build process. Version the manifest format, have antboot ignore unrecognised fields rather than rejecting the file, and spend a byte on an SRAM format version. Free now, painful to retrofit once units are in the wild running an immutable rootfs.
Also worth noting
udev, or a replacement. The drive-letter-by-physical-port scheme depends on
USB topology, and hotplug arrives as netlink uevents. Either run udevd as the
one background daemon, or consume the netlink socket directly in AntOS — the
latter is less code than it sounds and removes a dependency.
Filesystems get easier. The kernel provides vfat, exfat and ntfs3, so
the drive-letter layer stops being a translation over FATFS and becomes a thin
mapping over already-mounted POSIX paths. Less code than today, and exFAT on the
user-facing E: stick arrives for free.
11. Updates
Two tiers
On the P4 these couldn't be separated — AntOS was the firmware. On Linux they change at completely different rates:
| Tier | Contents | Cadence |
|---|---|---|
| 1 | AntOS binary | Constant. Small, fast, trivially reversible |
| 2 | rootfs slot: kernel, mesa, libdrm, base system, antboot | Rare |
Tier 1 mechanics
- A/B by filename, don't overwrite in place. FAT has no journal; a power cut
mid-write leaves a truncated binary and no AntOS. Write
antos-b, fsync, verify the hash, then switch which one runs — a directory-entry update rather than a data write. Pairs naturally with theos_prefixscheme. - The ABI coupling will bite. AntOS is dynamically linked against mesa and libdrm from the rootfs — exactly the libraries that move with kernel updates. A Tier 1 update against an older rootfs can fail in ugly ways. The fix is a rule, not a mechanism: the AntOS payload declares a minimum rootfs version, the loader checks it, and a mismatch falls back to the previous binary rather than trying.
Tier 2 mechanics
- Ship the slot payload, not a disk image. A full
.imgcarries a partition table and both slots; we only need the squashfs for the inactive slot. Smaller transfer, no repartitioning, write confined to one region. rpi-image-gen can emit device-agnostic filesystem tarballs alongside bootable images — that's the artifact. - Sign it, verify before writing, public key in the recovery slot.
- Same shape as what
sys_firmwarealready does withmanifest.inf, bigger payload.
Transfer
Three routes, and probably we want more than one: the existing network path, a USB stick for offline updates, and the recovery slot able to take an image from either. Given the machine may never be networked, USB shouldn't be an afterthought.
Build system: rpi-image-gen
Probably yes, but for a specific reason and with one deliberate deviation.
The real argument isn't convenience — it's that rpi-image-gen uses the same
library versions as Raspberry Pi OS and installs binary packages rather than
cross-compiling. That buys the Pi kernel, VideoCore firmware, vc4 KMS stack and
DSI panel drivers exactly as Raspberry Pi ships and tests them — precisely the
two areas (three concurrent HVS channels, a DSI panel) where the Pi display stack
is fussiest and where owning the integration ourselves in Buildroot would cost
weeks. It also emits an SBOM and CVE report, and integrates with
rpi-sb-provisioner for signed boot.
The deviation: a Debian rootfs with systemd will not reach AntOS in 1.5s —
but we don't have to fight that. Set init= in cmdline.txt to antboot and
systemd never runs. We keep the Pi-blessed kernel, firmware and libraries, and
skip the distro's startup entirely. Better than either trimming systemd or
rebuilding the world in Buildroot.
Read-only rootfs, squashfs and A/B are ours to add. rpi-image-gen builds an
image; it has no opinions about slot layout or tryboot. It's layer-based, so
it's a place to hook rather than something to work around.
Practical snag: it expects a Debian host and invokes mmdebstrap in a private
mount namespace needing CAP_SYS_ADMIN; Bookworm/Trixie arm64 is the only
formally supported host. From Windows that means WSL2 or a privileged container,
and non-arm64 hosts run under QEMU — slower and explicitly unsupported. Verify
this path works before committing the build process to it.
Desired workflow
Build images on Windows → send to the board → the board writes the "firmware" to the SD card → reboot.
12. Recovery and service access
The ICP cartridge no longer applies, and isn't needed: the whole system now lives on a removable OS microSD card. A service hatch underneath gives access to that card, the internal USB drives, the EEPROM write-protect jumper and a recovery USB socket.
The chain is: the card recovers the CM, and the CM recovers everything else.
FPGA flash over JTAG, Clicky, Phreak via esptool — all reachable once Linux is
up. Only one thing sits outside that chain, and it's the last rung below.
The ladder
Each level is only reached if the one above it fails.
| # | Failure | Recovery | Who acts |
|---|---|---|---|
| 1 | Bad AntOS binary | antboot sees the child die inside N seconds and runs the previous binary from the FAT partition | Automatic |
| 2 | Bad OS slot (kernel/rootfs) | tryboot is one-shot and leaves no persistent state, so a power cycle returns to the previous slot with zero writes |
Automatic |
| 3 | Both slots bad | Hold the recovery chord at power-on; Clicky drives the GPIOs, config.txt selects the never-updated recovery os_prefix |
User, no tools |
| 4 | Bad FPGA bitstream | CM boots normally (it's independent of the FPGA), AntOS reflashes the FPGA's SPI flash over JTAG | AntOS, from recovery slot |
| 5 | Bad Clicky or Phreak firmware | CM reflashes them — esptool for Phreak, UPDI for Clicky |
AntOS |
| 6 | OS card corrupt, all slots gone | Open the hatch, pull the microSD, reimage on a PC, refit | User + card reader |
| 7 | Bootloader EEPROM misconfigured | Plug a USB cable into the hatch's recovery socket, run rpiboot on a PC |
User + PC |
| 8 | …and the EEPROM is write-protected | Move the write-protect jumper in the hatch first, then level 7 | User + PC |
Levels 1–3 need nothing. Level 6 needs a card reader. Levels 7–8 need a PC and a USB cable, and are the only ones that require opening anything beyond the hatch.
Why level 4 works
The FPGA and the CM boot independently — the FPGA self-loads from its own SPI flash, the CM from the SD card — so a bad bitstream doesn't stop Linux coming up. JTAG is on the GW5AT's dedicated configuration pins, outside the fabric budget, so it survives whatever state the fabric is in.
The recovery slot therefore needs the IO expander driver and a JTAG programmer, since it's the image that has to be able to revive a board with a bad bitstream.
Levels 7–8: the rpiboot path
This is the only failure the OS card can't fix, which makes hatch access to the write-protect jumper the difference between a service operation and a dead machine.
The official CM4 IO board faces the same hub-versus-recovery conflict — one USB2 port, a hub on it, and a need for device mode — and solves it with a multiplexer rather than a jumper. Sense VBUS on the recovery socket through a 2k2/2k2 divider, and use that one signal to:
- flip a bidirectional USB mux between the internal hub and the recovery socket,
- drive
USB_OTG_ID, putting the module in device mode, - drive
nRPIBOOTlow through an inverter.
So plugging the cable in is the recovery mode. No jumper, no sequence to get wrong — which matters, because the person following these steps has a machine that won't boot.
The CM5 makes this easier: it has a dedicated USB 2.0 port on pins 103/105 alongside the two USB3 ports, so the recovery socket can own it outright and the internal hub can hang off USB3. Only that port has OTG capability, so it's the one to reserve. The CM4 has a single USB2 port and therefore needs the mux.
Software gotcha that comes with it: on the CM4 the USB2 port defaults to OTG
mode, and without dtoverlay=dwc2,dr_mode=host the hub fails with "config
failed, hub doesn't have any ports". That line has to be in config.txt from the
first boot.
How the electronics work
One signal does everything: RECOV_DET, the presence of VBUS on the recovery
socket.
The socket's 5V goes through a 2k2 / 2k2 divider to roughly 2.5V. The divider does three jobs at once — it brings 5V down to something a 3.3V input tolerates, it gives a defined low (through the lower 2k2 to ground) when no cable is fitted so nothing floats, and its low-side resistance is stiff enough to override the CM's internal pull-ups.
| Signal | Cable absent | Cable present | How |
|---|---|---|---|
RECOV_DET |
~0V (pulled down through 2k2) | ~2.5V | Divider from socket VBUS |
USB_OTG_ID (101) |
Pulled to GND → host mode, drives the hub | ~2.5V → device mode | Wired directly to the divider midpoint — 2k2 to ground beats the CM's internal pull-up, and 2.5V is comfortably above VIH |
nRPIBOOT (93) |
High (internal pull-up) → normal boot | Driven low → USB device boot | Schmitt inverter (74LVC1G14 / SN74HC14) from RECOV_DET |
USB mux SEL |
Hub upstream | Recovery socket | Same inverter output, polarity to suit the part |
USB_OTG_ID needs no logic at all. Tying it straight to the divider midpoint
gives both states for free: grounded through 2k2 when idle, held at 2.5V when a
cable is present. That's the neatest part of the arrangement.
The Schmitt inverter matters. The divider's edge is slow and its midpoint is
a soft 2.5V; a plain gate would chatter through the transition. A Schmitt input
gives one clean transition, and the same output can drive both nRPIBOOT and the
mux select.
The mux is an ordinary 2:1 USB 2.0 high-speed switch — TS3USB221, FSUSB42 or similar. Common port to the CM's D+/D−, one branch to the internal hub's upstream port, the other to the recovery socket. These parts are specified for 480Mbps and chosen for low channel capacitance, which is what keeps eye diagrams intact.
Do not power the board from the recovery socket. VBUS is used for sensing only — no diode-OR onto the 5V rail. A CM5 wants a 5A-class supply and a PC port offers a small fraction of that; the official IO boards carry the same warning about not relying on the programming connector for power. The machine is powered normally throughout.
ESD protection belongs on the recovery socket's D+/D− like any other external port.
The write-protect jumper is separate and simple: EEPROM_nWP (CM5 pin 20) is
internally pulled up, so a jumper to ground protects the EEPROM and removing it
allows writes. On the CM4 the pin behaves the same way but the Write Status
Register also has to be configured in software (§3).
The sequence, in order
nRPIBOOT is sampled at power-on, so the cable must be connected before the
machine is powered — this is the one step that's easy to get wrong.
- Power the machine off.
- Open the hatch. If the EEPROM is write-protected, move the jumper to the unprotected position.
- Plug a USB cable from a PC into the recovery socket. VBUS appears,
RECOV_DETrises: the mux swings to the socket,USB_OTG_IDreleases to 2.5V,nRPIBOOTis driven low. - Power the machine on. The boot ROM sees
nRPIBOOTlow and enters USB device mode instead of booting. - Run
rpibooton the PC. It pushesrecovery.binover USB and reflashes the bootloader EEPROM. - Power off, unplug the cable, refit the write-protect jumper, close the hatch.
- Power on. The module boots normally from the OS card.
Provisioning uses the same path
A freshly swapped compute module arrives with factory EEPROM defaults —
BOOT_ORDER falling through to USB, self-update enabled, no write protection
(§13). The same recovery socket is how the factory, or a user after an upgrade,
writes the correct configuration and locks it.
That argues for warn-rather-than-halt when AntOS finds an unprotected EEPROM: the unprotected state is the expected state after a legitimate module swap, so halting would meet the user at exactly the moment they followed the documented upgrade path.
Development and debug access
This is where the two modules diverge most, and not in the direction you'd expect — the CM4 can bring hardware debug out through a carrier and the CM5 can't.
CM4. ARM JTAG is available as an alt-function on GPIO22–27, enabled with
enable_jtag_gpio=1 — which is exactly where SDIO for drive A: lives, so the two
are mutually exclusive.
Decision: the eZX doesn't get JTAG. A: is kept unconditionally. The deciding argument isn't the pin clash but the upgrade path: JTAG on GPIO22–27 would vanish the moment an eZX owner fitted a CM5, since those pins then come from RP1 and have no SoC JTAG. Building a feature that disappears on a documented upgrade is worse than not having it.
So GPIO22–27 is SDIO always — both boards, both modules. No dev-mode hardware
reconfiguration, one fewer [cm4]/[cm5] divergence, and no user-facing wart
where A: stops working in a particular boot mode. The dev boot mode stays about
which OS slot and AntOS binary to run, not about reconfiguring hardware.
The CM4's bootloader UART is on GPIO14/15 as on every earlier Pi, so the eZX's debug story is the same 3-pin header as the Ant64's.
CM5. The connector's GPIO0–27 come from RP1 and have no JTAG access to the
SoC at all. BCM2712 does have JTAG alt-functions, but on its own GPIOs, which
aren't the ones on the module connector. Pi 5-generation debug is SWD, on the
3-pin JST header, with enable_jtag_gpio=1 switching that connector between
serial and A76 SWD.
And that connector isn't on the 200-pin interface. The CM5 datasheet (§2.23) provides space on the module for the user to fit a 3-pin 1mm JST-SH (BM03B-SRSS-T), unpopulated from the factory, with test points TP35/TP36 underneath. The CM5 IO Board has no connection to it either. So a carrier cannot reach the debug UART or SWD.
Silver lining: unlike the Pi 5, the CM5 IO Board's kernel console is back on
UART0 (ttyAMA0), GPIO14/15. So a plain 3-pin header on those pins gives a
kernel console on both modules for two GPIO. What it doesn't give is bootrom and
bootloader output, which lives on ttyAMA10 on the dedicated connector.
The optional debug link
A mating 3-pin JST-SH goes on the main board, wired through to the cartridge port. The module-side connector is not fitted at production — a user who wants bootrom-level debug can fit it and wire the two together themselves.
That split works because modules are user-swappable: nothing on the carrier depends on the mod, and a replacement CM5 doesn't break anything. The special part is the module, not the machine.
Three details worth getting right, since they're free at layout time:
- Same connector family both ends (BM03B-SRSS-T), so an off-the-shelf SH-to-SH jumper works — the cable already exists because it ships with the Raspberry Pi Debug Probe.
- Match Raspberry Pi's pin order (TX, GND, RX) rather than mirroring it, or a standard cable puts TX against TX.
- Series resistors on each line. Not for floating — with neither end connected it's just unconnected copper, and there's no CMOS input to protect — but to limit contention current if a cartridge ever drives those pins while a debug cable is fitted, and to damp ESD arriving at the cart connector. A 115200 UART won't notice a hundred ohms. Mark those pins reserved-for-debug in the cart pinout so a future cartridge doesn't claim them.
Practical note: fit the JST on your lab CM5 now, before it's buried under a heatsink in a case. Check clearance first — the footprint sits near the mounting hole, so a vertical connector may foul a heatsink or standoff, and wires to TP35/TP36 may be the lower-profile answer.
Service access differs by board
| Ant64 (CM5) | eZX (CM4) | |
|---|---|---|
| Recovery USB | Dedicated USB2 port (103/105) can own it — no mux needed | Single USB2 port — needs the VBUS-sensed mux |
| Kernel console | GPIO14/15 to hatch or cart port | GPIO14/15 to hatch |
| Bootrom console / SWD | Optional module-side JST → cart port | GPIO14/15 already carries it |
| Hardware debug | Not reachable through a carrier | Declined — A: keeps GPIO22–27 |
| Spare-pin expansion | Cartridge port | No cartridge port — hatch header only |
The awkward inversion: the product with no cart port and the fiddlier module needs more service hardware, not less. Worth deciding early whether the eZX gets a small internal header as standard, because retrofitting service access to a board with no expansion port is exactly the situation to avoid.
13. User-upgradeable compute module
The compute module is socketed, not soldered, so the CPU card can be swapped — but the two machines start from different points. The Ant64 is CM5-only: it ships with a 2GB CM5 (the smallest CM5 variant), upgradable up the CM5 ladder. The eZX Spectrum takes either module — it ships with the cheaper CM4 (1GB base) and can move up the CM4 RAM ladder to 8GB, or across to a CM5 (see Upgrading generation below). A retro machine with an upgradeable CPU card is the right kind of anachronism. Three things a module swap needs to survive real users:
- Specify the SKU precisely — one axis of it is fatal. On non-Lite CM4s the eMMC occupies the SDIO interface and the SD card slot is unavailable. A user who buys "an 8GB CM4" and gets the eMMC variant has a module that physically cannot boot the OS card. With the wireless-disable pins asserted (§2), the documented rule collapses to one word: Lite. Put that on a label inside the case, not just in a manual.
- The bootloader EEPROM lives on the module, not the carrier. Every setting —
BOOT_ORDER,ENABLE_SELF_UPDATE=0, write protection — is gone the moment someone swaps modules. A fresh CM4 arrives with factory defaults and will happily fall through to USB boot and self-update itself. Needs a provisioning flow: on boot, AntOS reads the EEPROM config, notices it's unconfigured, and offers to write our settings and lock it. Probably a job for the recovery slot, as the one image guaranteed sane on an unprovisioned module. - The connectors aren't a DIMM socket. Two 100-pin Hirose DF40C mezzanine connectors with limited mating cycles. Fine for occasional upgrade, not casual experimentation. Needs proper standoffs and screws if user-accessible, and documentation framing it as an upgrade rather than a swappable part.
Already in our favour: machine identity is on the carrier, not the module. The EUI-48 in the MCP79410 is serial number and MAC source, so a module swap doesn't change what the machine is — same serial, same MAC, same registration.
Upgrading generation, not just RAM
Because board identity is independent of module type (§3), an eZX owner could upgrade from a CM4 to a CM5. Buy the cheaper machine, upgrade the compute later. Three consequences:
- The dual-module carrier work stops being optional. ESD protection on the baseboard, pin-92 handling, fan wiring, USB3 routing — all of it has to be in the eZX carrier from the first spin, including on units that ship with a CM4 and never change. It can't be retrofitted.
- Route USB3 on the eZX even though the CM4 can't use it. Those pins are dead on a CM4 and live on a CM5; if they aren't routed, the upgrade loses most of its point, since faster storage is one of the CM5's main advantages.
- Power and cooling must be specced for the CM5 on every eZX. The CM5 wants a 5A-class rail and more thermal headroom. Either the eZX carries that cost on every unit, or the upgrade ships with a "you'll also need a different PSU" caveat — a poor experience for something sold as a drop-in. Worth pricing before committing, since the cost lands on the cheaper product.
Note also that the CM5 has no 1GB variant, so the upgrade is inherently a RAM step up as well as a faster CPU.
Taken together — the CM4→CM5 jump, the RAM step, and the optional Studio I/O audio expansion the eZX shares with the Ant64 — an eZX can be built up to nearly Ant64S capability. That shared upgrade surface is much of why the two machines are brought up on a common carrier design.
14. Audio
Keep a P4 for audio. 3× SID plus SAM speech under Linux means either
isolcpus + PREEMPT_RT and a lot of tuning, or accepting occasional glitches.
Keeping a P4 preserves code that already works and leaves the CM4 doing rendering
and OS — the job we actually want it for. The same logic argues for keeping Pulse
rather than folding AMY into a CM4 process.
Open: whether Pulse is needed at all under a CM4 — i.e. whether an IO system plus a process on the CM4 would cover it. Not resolved.
Pulse's expanded role
Beyond audio, Pulse has become the display-capabilities and remote-control authority (§4):
- I²C master on the AG6201's DDC — reads the VGA monitor's EDID for modeline clamping, without Linux being up.
- I²C master on the real HDMI output's DDC — reads the TV's EDID and extracts the CEC physical address from the HDMI VSDB in the CEA extension block, which is what makes CEC work properly rather than guessing.
- CEC decode, presented to the CM as an I²C slave alongside the above.
Two things to settle. Bus contention: the PS176 normally masters the HDMI DDC itself to present EDID over DP AUX — either configure it off the bus, or read the EDID through the FPGA's DP Aux IP instead. Power domain: if Pulse is on the main rail, CEC only works while the machine is already running; a remote power-on would need the CEC line on something with standby power, i.e. Sticky.
Also worth knowing: ESP32 I²C slave leans on clock stretching, and the CM4's Broadcom I²C block has a long-standing clock-stretching bug (RP1 on the CM5 is fine). Design around it — Pulse raises an interrupt, the CM reads a short fixed-size pre-staged block.
Codecs
WM8960 for the built-in path, PCM3168A for the audio add-on.
The built-in path is internal speaker, headphones, and line/tape in. The WM8960 covers all of it on one cheap QFN with 1W/channel Class-D speaker drivers, headphone drivers, and a mic PGA with ALC and noise gate — that PGA is what makes a tape/EAR input tractable without an analogue front end. Its 8–48kHz ceiling does not limit the main output, because the audio that matters is embedded in the FPGA's HDMI/DP stream and comes out of the AG6201's 3.5mm jack; the WM8960 only serves the local analogue path.
The add-on is multichannel line-level, which is the PCM3168A's home: 6-in/8-out, 24-bit, 96kHz ADC / 192kHz DAC, TDM.
Practical notes:
- PCM3168A outputs are differential only — single-ended line outs need op-amp stages. The CS42448 does single-ended on both sides and is otherwise the same class, so it's worth weighing for the add-on.
- PCM3168A TDM requires 24-bit words; 16-bit works only in plain I2S, right-aligned.
- It needs a 5V analogue rail alongside 3.3V digital — the add-on connector must carry it.
- The two parts need different fabric blocks (plain stereo I2S versus TDM). They're on separate interfaces anyway, so it's additive work, not a conflict.
- Budget the speaker rail — 1W/channel Class-D is a real load alongside a CM5 that already wants a 5A-class supply.
- Check the WM8960's lifecycle status with Cirrus. It's a Wolfson-era part, still in current Pi HATs and ReSpeaker boards, but worth confirming it isn't heading for NRND.
Instruments: where they run
A rule worth stating explicitly, because otherwise the instrument layer gets split across two chips by accident of which repo targeted which processor:
Pulse hosts what must be sample-accurate to retro hardware. The CM hosts musical instruments.
By that rule the SIDs, SAM and chipset audio stay on Pulse, where determinism and proximity matter. Anything MIDI-driven with no timing coupling to a chipset goes on the CM.
DX7 / Dexed — decided: on the CM. MiniDexed itself won't work: it's a bare-metal application built on Circle, so it can't coexist with Linux and AntOS. Take Synth_Dexed, the embeddable library it's built on, or msfa underneath that.
- Licensing matters here. Dexed is GPL v3 and MiniDexed is GPLv3, but the msfa engine is deliberately Apache 2.0 "to be able to collaborate between projects". Check Synth_Dexed's own terms; msfa direct is the safe route for a commercial product.
- Feasibility was never the question — a Pi 3 runs eight tone generators of sixteen voices, so a CM5 has an order of magnitude spare. (It would have run on a P4 too: an ESP32 port reaches 8-note polyphony at 240MHz, and Teensy 4.0 at 600MHz manages ~10 with some glitching. That's just not where it belongs.)
- MiniDexed is worth reading rather than running. Its multi-timbral tone-generator layer, reverb and chorus, and performance/patch management are what turn one DX7 into a TX816 — and they map neatly onto the component and preset system, a TG becoming a component and a performance a preset.
- Open: AMY already does DX7-flavoured FM, so msfa's distinctive value is
bit-accurate
.syxpatch compatibility with the existing DX7 cartridge banks. If that isn't the goal, AMY may already cover it.
AcidBox — candidate. copych/AcidBox: two TB-303s plus TR-808-style drums with an FX chain, MIT licensed, which avoids the GPL awkwardness entirely.
- The distinctive value over AMY is the 303 voice — the filter work derives from Open303 and Diakopoulos's Moog-ladder collection, and the accent/env-mod/slide interaction is the part everyone gets subtly wrong. The FX chain and sampler overlap with AMY.
- Same structural caveat as MiniDexed: it's an application, not a library. It
owns both cores, owns I²S, owns the audio loop, and it's Arduino
.inorather than ESP-IDF. Lift the voice and filter classes; leave the wrapper. - Budget the CPU. It saturates both cores of a 240MHz ESP32 — perhaps 1.2 cores' worth on a 400MHz P4, before AMY and anything else. Which is another argument for the CM under the rule above; the DSP is portable C++ and the ESP32-ness is incidental.
Rejected: ADAU1701
Considered as a digital insert — I²S out to it, back in, then on to the WM8960 and the DP/HDMI embed — for its programmable effects. Not proceeding, for three reasons:
- It costs the second I²S interface, which is earmarked for the add-on connector.
- An in-line insert is the wrong place in the chain: processing before the fan-out applies the same treatment to HDMI, line out, headphones and internal speaker, when what's wanted is per-destination.
- For effects specifically its delay memory is tiny — around 2K words, tens of milliseconds — which is exactly what reverb and echo need. A larger SigmaDSP (ADAU1452/1466 with external SRAM) would be the right part if going that route.
Doing effects in the FPGA costs no chip, no pins and no round trip: biquads and comb/all-pass sections are cheap in DSP blocks, the DDR3 gives delay lines of seconds, and it's per-output by construction since the FPGA is already the thing splitting the audio.
I2S pin allocation
5 FPGA pins each for two interfaces, 10 total: MCLK, BCLK, LRCLK, SDIN, SDOUT. That's exactly right for full-duplex with a master clock.
Keep MCLK on the add-on connector even though it could be trimmed on an internal link — an add-on's codec may require it, and a connector pin can't be retrofitted. For the same reason, design the fabric so the add-on can drive the clocks: a quality DAC module will often want to supply its own low-jitter clock. Put I²C and power on that connector too, so AntOS can enumerate what's fitted rather than being told.
Inter-chip audio does not use these links — Pulse sends blocks over MIPI to the FPGA's MIPI RX, and the CM uses the TMDS and PCIe channels.
15. Device identity
The MCP79410's EUI-48 is writeable (protected by an unlock sequence, not read-only), so it's a poor serial number for anything that must be unforgeable. Fine as a MAC source — MACs were never meant to be unforgeable. It's only weak as an identity claim, which means it matters for registration and activation and nowhere else.
Idea: use the ESP32-C5 to provide the serial. The C5's eFuse is OTP and
write-protectable, so its base MAC can't be rewritten the way the EUI-48 can. More
usefully, the C5 has eFuse key blocks plus an HMAC peripheral that can use a
read-protected key — software can never read it, only ask the hardware to
compute HMAC over a challenge. That's the device_key the registration design's
per-purpose derivation assumes but currently has nowhere safe to live.
Suggested split: EUI-48 as the identifier, C5 as the proof. Writeability stops mattering once the proof is hardware-backed, because forging an identifier without the matching key buys nothing. Nothing already designed has to change.
Costs to weigh: burning eFuses is irreversible and needs a per-unit production step; identity moves onto Phreak (soldered, so not user-swappable — but a C5 replaced in repair changes what the machine is, and licences currently bind to the EUI-48 in DBFS); and every verify becomes a UART round-trip, adding a boot ordering dependency that doesn't exist today.
Parked. See the registration/activation design.
16. Hybrid emulation and core categories
The supervisor being a real CPU opens a category the platform didn't have before. Three kinds of core now make sense:
- Pure FPGA core — chipset and CPU both in fabric. Maximum accuracy.
- Hybrid — FPGA does chipset/GPU/audio, CM emulates the CPU.
- Pure software — the whole machine emulates on the CM; the FPGA is only the output stage, doing integer upscaling, scanlines and the analogue path.
Category 3 is worth naming explicitly: any Linux emulator gets the FireStorm output pipeline. That's a real differentiator over a pure-FPGA machine, and it costs nothing extra architecturally.
What decides whether a hybrid split works: memory topology
The constraint is not CPU speed — a recompiler on an A76 will outrun any soft core you can fit. It is coupling. A soft CPU in fabric reaches chipset registers in the same clock domain at zero latency; a CM-side emulator crosses PCIe for every access that touches FPGA-side hardware.
| Machine | Memory | Verdict |
|---|---|---|
| PlayStation | Split — CPU owns 2MB main RAM, GPU owns separate 1MB VRAM, fed by DMA'd display lists | Good fit. The push model is native, not a workaround |
| Amiga (A500 + fast RAM) | Chip RAM shared, but the "keep working data in fast RAM" discipline already exists | Workable. Maps onto the split naturally |
| Amiga (stock A1200) | 2MB chip RAM, no fast RAM at all — the entire working set is in the slow memory | Hard. Chip RAM shadowing becomes mandatory, not an optimisation |
| N64 | Unified RDRAM shared by CPU and RDP | Bad fit as a hybrid — use category 3 instead |
The GTE must stay on the CM. It's coprocessor 2, interleaved with ordinary R3000 code thousands of times a frame. On the FPGA every access would be a bus round trip. It's fixed-point integer maths that an A76 handles trivially.
The split is per-component, not per-system
Even "the CM does everything" isn't purely software. Pulse takes the audio in every case — PS2's SPU2, Dreamcast's AICA, the GameCube DSP, N64 audio tasks. These cost more CPU than their complexity suggests, are timing-sensitive, and Pulse is a P4 already doing AMY. That's CM cores freed for the expensive work, at no extra hardware.
And several systems have separable coprocessors — the same own-memory-and- DMA-fed test, applied component by component:
| System | Separable into fabric | Must stay on the CM |
|---|---|---|
| N64 | RSP — own DMEM/IMEM, DMA-fed tasks | RDP (shares RDRAM), CPU |
| PS2 | VU1, the IOP | VU0 (COP-coupled), EE, GS |
| GameCube | DSP | CPU, Flipper |
| Dreamcast | ARM7/AICA | SH-4, PowerVR |
| PSP | Media Engine | Allegrex |
| Arcade | sound CPUs, PSG/YM/SCSP | main CPU, video |
The N64 RSP is the interesting one: it's one of the two big emulation costs and it's architecturally clean, so moving it into fabric isn't a compatibility hack — it's what the hardware does.
This is the actual differentiator. MiSTer can't run what won't fit in fabric; a plain Pi has no fabric to offload into.
The forward plan: software first, fabric where it earns it
Treat category 3 as a starting point, not a verdict. A system ships fully emulated with audio on Pulse; components migrate into fabric as cores get written, and the titles get faster without the emulator changing shape.
| Phase | What moves | Effect |
|---|---|---|
| 0 | Everything on the CM, output stage in FPGA | Playable, consistent look |
| 1 | Audio → Pulse | CM cores freed; audio timing improves |
| 2 | Separable coprocessors → fabric (RSP, IOP, VU1, DSP) | The largest single wins |
| 3 | Chipset/GPU → fabric where it fits | Becomes a hybrid core |
| 4 | CPU → fabric where it fits | Becomes a pure FPGA core |
Each step is an independent FPGA project — an RSP implementation is not a weekend — so the sequencing matters more than the destination. The point is that a system never has to wait for phase 4 to be usable.
Estimated CPU speedup
| System | CPU | Approach | Est. CPU speedup | Render res | Scale to 4K |
|---|---|---|---|---|---|
| Vector / 6502 arcade | 6502, 68000 @6MHz | FPGA | ~100×+ | 320×224 ×3 | 9× |
| Neo Geo | 68000 @12MHz + Z80 | FPGA | ~30–50× | 320×224 ×3 | 9× |
| Sega System 16 | 68000 @10MHz + Z80 | FPGA | ~30–60× | 320×224 ×3 | 9× |
| CPS1 / CPS2 | 68000 @10–16MHz + Z80 | FPGA | ~25–50× | 384×224 ×3 | 9× |
| TMS34010 boards (MK, NBA Jam) | TMS34010 @6–8MHz | FPGA | ~20–40× | 400×254 ×3 | ~8× + border |
| Amiga A500 | 68000 @7.09MHz | FPGA | ~30–60× | — | — |
| Amiga A1200 | 68EC020 @14.2MHz | Hybrid | ~20–30× | — | — |
| GBA | ARM7 @16.8MHz | FPGA | ~100×+ | 240×160 ×4 | awkward |
| 3DO | ARM60 @12.5MHz | Hybrid | ~80–120× | 320×240 ×3 | 9× |
| PlayStation | R3000A @33.9MHz | Hybrid / CM | ~20–40× | 320×240 ×3 | 9× |
| CPS3 | SH-2 @25MHz | Hybrid | ~25–40× | 384×224 ×3 | 9× |
| Sega Model 1 | V60 @16MHz + TGP DSPs | Hybrid / CM | ~30–50× (CPU) | 496×384 ×2 | 4× + border |
| Sega Model 2 | i960KB @25MHz + geometry DSPs | CM | ~20–40× (CPU) | 496×384 ×2 | 4× + border |
| Namco System 22 | 68020 @49MHz + DSPs | CM | ~10–20× (CPU) | 640×480 ×2 | 4× |
| Saturn | 2× SH-2 @28.6MHz | CM | ~3–6× | 320×224 ×3 | 9× |
| Sega Model 3 | PowerPC 603e @66–166MHz + Real3D | CM (marginal) | ~2–4× (CPU) | 496×384 ×2 | 4× + border |
| N64 | R4300i @93.8MHz | CM | ~8–15× | 320×240 ×3 | 9× |
| PSP | Allegrex @333MHz | CM | ~3–6× | 480×272 ×4 | 7× + border |
| Dreamcast | SH-4 @200MHz | CM | ~3–5× | 640×480 ×2 | 4× |
| PS2 | EE @294MHz | CM | ~1–1.5× | 640×448 ×2 | 4× |
| GameCube | Gekko @486MHz | CM | ~1–1.5× | 640×480 ×2 | 4× |
| Wii | Broadway @729MHz | CM | ~0.7–1× | 640×480 ×2 | 4× |
| PS3 / 360 / Wii U / Switch | — | Out of reach | — | — | — |
That column is CPU headroom, not overall speed. From Dreamcast upward the bottleneck is GPU emulation, not the CPU — a system showing 3× headroom can still drop frames. Its real use is deciding whether CPU overclocking is available: PS1 at 20–40× can run its R3000 at 4× to fix framerate dips and long loads, while PS2 at ~1.2× has nothing to give. It's also the column that phase 2 of the migration plan moves, since offloading a coprocessor returns headroom directly.
Arcade splits cleanly at the 3D boundary. Everything pre-3D is comfortable FPGA territory with enormous headroom — the CPUs are 68000-class and the video hardware is tile-and-sprite. The 3D boards flip it: the CPU is still easy (Model 2's i960 at ~25MHz is trivial to emulate) but the geometry DSPs are the real cost, and they're exactly the kind of DMA-fed coprocessor phase 2 of the migration plan targets.
Vertical (tate) games are a genuine problem for the integer rule. Portrait natives like 224×288 or 240×320 don't divide 2160 cleanly — 288 gives 7.5×, 320 gives 6.75×. On a landscape 4K panel the best available is 7× with a border, and physically rotating the monitor makes 3840 the tall axis without landing any better. Worth a per-game orientation flag in the core metadata and an explicit choice between integer-with-border and fill, because the arcade audience cares about this more than almost anything else.
On the PS2/GameCube rows, sources genuinely conflict. A Batocera-focused survey from May 2026 reports ~50% of the PS2 catalogue and ~60% of GameCube running smooth on Pi 5 hardware; a piece from August 2026 calls GameCube a proof of concept at 20–30fps. Both are probably true of different game sets. The honest headline is "about half the library at 30fps" for each. Note also that passive Pi 5 cases throttle under sustained load there — so the CM5's fan control (§3) is a requirement for these systems, not a convenience.
Fabric clock, and what it constrains
The fabric clock is likely around 300MHz, possibly 380MHz — the figure Z80NG reaches with its μop cache, which is the BSRAM ceiling on this part class.
On the CPU side that helps. At 380MHz a 68000NG at a realistic 0.6–0.8 IPC is 230–300 MIPS against an A1200 020's ~4 MIPS, so 55–95× — three to six times the CM dynarec, which strengthens the phase-4 case rather than weakening it.
The bigger consequence is the output stage. 4K60 needs ~594M pixel clocks per second including blanking, which is impossible at one pixel per fabric clock. So multi-pixel-per-clock is mandatory, not an optimisation.
The sweet spot is three pixels per clock at ~198MHz, because the 720p→4K horizontal upscale is exactly ×3 — the scaler then consumes exactly one source pixel per clock and emits three, with no fractional-pixel state machine. Costs: line buffers read three times per line, a three-wide scanline multiply, and a DP TX that accepts a multi-pixel interface (worth checking against Gowin's IP early).
BSRAM makes this workable. It's dual-port with independent clocks, which is what lets the TMDS receiver write a line at 74.25MHz while the scaler reads it at 198MHz — without that, you'd need an explicit CDC FIFO in front. Check whether the widest width restricts you to simple dual-port (one write, one read) rather than true dual-port; for a line buffer that's the topology you want anyway.
And 72-bit words are a good fit in three places: 3 × 24bpp is exactly the three-pixel output group; a 1080p source (×2 horizontally, so 1.5 source pixels per clock) can be read three-at-a-time instead of needing fractional logic; and a 68k μop needs more than Z80NG's four bytes — more registers, 32-bit immediates, longer addressing modes — so 72 bits keeps it to one read per μop where 32 would need two.
What the fabric clock doesn't constrain: the chipset itself, running at Amiga and console rates with enormous headroom; PCIe and the TMDS receivers, in their own SerDes domains; and audio.
Rendering at native resolution is cheaper — and free here
Emulators normally upscale internally, 2× or 4×, because that's the only way to look acceptable on a modern display. That costs 2–4× the GPU work and, on a CM5 where CPU and GPU share one LPDDR4X bus, 2–4× the memory bandwidth.
Here you don't need to. The FPGA does integer upscaling and scanline shaping downstream, so rendering at 1× native looks more authentic and costs less. The usual quality-versus-performance trade becomes a free win — and on the marginal systems it's exactly the margin that matters. PCSX2 at native versus 4× internal is often the difference between 30fps and a slideshow.
So the FPGA output stage isn't cosmetic: it materially improves emulation performance, and it does so most on the systems that need it most.
Line-by-line upscaling
The FPGA upscales as the lines arrive, like a TV, rather than buffering a frame. Vertical integer upscaling emits each source line N times with scanline weighting; horizontal is pixel replication within the line. The whole scaler is a line buffer and a repeat counter — no frame buffer, no DDR3 traffic, and the added latency is one source line rather than one frame.
Two requirements:
- Genlock. The output clock must be derived from the incoming stream's recovered TMDS clock, or the two drift and periodically duplicate or drop a line.
- A constant integer line-rate ratio. Raw 320×240 can't be an HDMI mode — it's below the 25MHz TMDS floor — so it travels inside the 720p link. Sending 240 active lines with 480 blank ones makes arrival bursty against a steady output. Instead the CM nearest-neighbour blits its native render 3× into 960×720, so each source line occupies three link lines and the FPGA emits three output lines per incoming line — a constant 3:1 ratio with a trivial buffer. The rendering is still at native, where the saving lives; the 3× blit is essentially free.
Note this differs from the overlay path, which is deliberately decoupled through a framebuffer (§6). Simplest is for the CM to composite game and overlay itself and send one genlocked stream, accepting a chunky overlay at 3× — which is the chosen aesthetic anyway.
Raster effects
Integer scaling plus a shaped beam profile does most of the work, and 9× is what makes a profile possible rather than a hard on/off grille. One thing isn't automatic: brightness. Darkening every third line removes real luminance, and a naive implementation looks murky — real CRTs didn't look dim because the lit lines bloomed to compensate. The scanline weighting wants a gain term tuned to hold average brightness roughly constant. That's the commonest reason scanline filters look wrong, and it's free in the per-line multiply already being done.
Two smaller ones: horizontal pixel replication is sharper than the original hardware managed, since a real CRT had limited horizontal bandwidth — a slight horizontal filter is more faithful, though many prefer the sharp version. And different displays looked genuinely different, so aperture grille versus shadow mask, arcade monitor versus consumer TV, belong as presets per system rather than one global look.
The bigger win is consistency. Because this lives in the FPGA on the line stream, it applies identically to everything — a pure FPGA core, a hybrid, and a software emulator all get the same treatment. A PS2 game in PCSX2 and a Mega Drive core in fabric look like they belong to the same machine, rather than one going through RetroArch shaders and the other through fabric logic. That coherence is hard to achieve any other way.
PCIe as a memory bus: the numbers that shape everything
PCIe is memory-mapped — BARs map into the CM's physical address space and a plain store becomes a Memory Write TLP. But it addresses like a memory bus without behaving like one:
- Writes are posted — fire-and-forget, no stall. With
ioremap_wcconsecutive stores coalesce into large TLPs: hundreds of MB/s. - Reads are non-posted — a full round trip, typically 1–3µs.
For scale, a 68000 at 7.09MHz reads chip RAM in ~565ns. So a PCIe read is comparable in absolute time to a contended Amiga chip access — but the CPU is 340× faster, so in relative terms it's thousands of cycles instead of four. Bandwidth goes the other way: PCIe Gen2 x1 is ~50–140× the entire Amiga chip bus.
Emulated on a CM5 against an A500 baseline, roughly:
| Access | Ratio |
|---|---|
| Fast RAM read/write | 60–280× faster |
| Chip RAM write | 30–200× faster |
| Chip RAM read | 2–5× slower |
That asymmetry is the whole design problem, and it's worse on an A1200 (3–10× slower on reads, and no fast RAM to escape into).
Therefore: push, never pull
- Register shadow. The FPGA bus-masters a status block into CM DRAM; the CM reads its own memory. Lay hot registers out contiguously (a 4-byte TLP is ~17% efficient, a 64-byte burst ~76%), rate-limit to a natural boundary rather than writing on every change, use a seqlock so mid-update reads become retries, and remember Pi PCIe isn't I/O-coherent — either a DMA-coherent (uncached, ~60–100ns) buffer or a cacheable one with explicit invalidate.
- Derive rather than read. Beam position is a function of elapsed time, not
data. Anchor on the vsync interrupt and use ARM's
CNTVCT(local, nanoseconds, no bus access), re-anchoring each frame. - DMA for bulk, in the FPGA→CM direction especially.
What you cannot do is make the FPGA's 1GB DDR3 into system RAM. You can BAR-map every byte, but it stays MMIO — never cacheable memory the kernel allocates from. That needs CXL, not PCIe.
The side-bus option (scoped: emulation only)
PiStorm gets around the read latency by not using PCIe at all — it sits in the CPU socket and drives the real 68k bus through GPIO with a CPLD (or an FPGA on the PiStorm32-Lite) doing handshaking, giving ~200–400ns per access. Its fast RAM lives in the Pi's own DRAM, and it runs bare metal (Emu68) precisely to keep timing deterministic.
A GPIO side-bus is only needed for hybrid CPU emulation — the core architecture needs none of it. If built:
- Parallel, not serial. SPI throws away the whole advantage; PiStorm's win is putting a whole address out in one or two register writes.
- CM4 only, realistically. On CM5 the GPIO lives on RP1 behind an internal PCIe link, which is exactly why PiStorm doesn't support the Pi 5. RP1's PIO can move pin-level timing onto RP1, but the FIFO accesses are still PCIe, and PIO read bandwidth is only ~500–600 Mbps.
- Reserve pins, don't build it. A handful of GPIO on the carrier, unpopulated.
Note: Claude initially over-weighted the RP1 finding as an argument for inverting the product split. Since it only affects one optional feature, it isn't.
ZX Spectrum Next compatibility layer
The Next's Pi Zero attaches to a 40-pin header, and per the SpecNext wiki the only connectivity is GPIO plus documented protocols — a secondary UART added in core 3.00, with SPI/I²C/I²S overlayable and some GPIO control nextregs. No shared memory, no bus access; NextZXOS integration is IO redirection to the serial link.
That thinness is what makes impersonating it easy: reproduce the interface, not the transport. The FPGA presents the UART FIFOs as PCIe-mapped registers, a shim exposes them as a tty, and Pi-side software runs unmodified. I²S audio return becomes samples over PCIe into Tempest.
It also generalises the core contract: today every core provides an AntOS support layer; this adds the reverse — a core can request a host-side service, and AntOS spawns the process.
Three caveats: you may need to throttle the virtual UART to an emulated baud
rate, since Pi-side software is written against serial pacing; only
protocol-level software ports (anything poking BCM registers or /dev/gpiomem
won't); and ship the interface, not the image, given the licensing around
NextPi.
17. Personalities and the Interface Block
The personality cartridge system is specified in full at ant64.com/personality. That page was written against the ESP32-P4 supervisor, so parts of it are stale under this proposal — this section records what changes, and what the Interface Block has to become.
What FireStorm is, and when it exists
FireStorm exists only in native mode — when the FPGA is running the Ant64's own boot image. A personality bitstream replaces the entire fabric, so FireStorm's execution engine, rasteriser and Ant64 chipset are simply not present.
And FireStorm does not run AntOS. The FireStorm EE is a slave CPU inside the FPGA — a control plane for sequential work that's miserable in HDL, not a host. AntOS runs on the CM, always.
Two consequences follow, and they're the load-bearing ones:
- The Interface Block is the only Ant64 logic present in a personality bitstream. It must therefore be entirely self-sufficient pure HDL, with no dependency on a soft CPU. DP link training in particular has to live inside it, not in the EE.
- The Interface Block is the platform footprint that §4 says should be measured — and it's the natural static region for partial reconfiguration (§4), with the personality as the reconfigurable region. That mapping is exact: the thing that must never disappear and the thing that must swap are already separated.
Because every personality pays the Interface Block's LUT cost out of the same 138K, trimming it is a permanent tax cut on every core in the catalogue.
What the CM plan changes in the personality spec
Most of the spec is unaffected — the manifest/component/preset system,
config.lua building the OSD from it, one-scanline-at-a-time video with
independent per-output post-processing, the XOR-delta rewind ring, multi-system
bitstreams, faithful-plus-enhanced modes, and MiSTer/Jotego core sourcing all
survive unchanged. What needs revising:
| Spec says | Under the CM plan |
|---|---|
| FireStorm EE runs AntOS | AntOS runs on the CM; the EE is a slave, native mode only |
| DeMon is an ESP32-P4 | "DeMon" is a role, filled by the CM |
| Cartridge is microSD read over SPI | Cartridge is USB on the cart port — ROM loading gets much faster |
| register window | PCIe BAR-mapped, so the debug register bank lands in the CM's address space |
| Interface Block generates the OSD | Delete it — the CM renders real ImGui, the FPGA only composites |
| Debug app runs on the FireStorm EE | Runs on the CM over PCIe against the Interface Block's debug bank |
| DeMon reprograms FireStorm over QSPI | CM over JTAG, or FPGA self-load from flash |
That last row matters more than it looks: a ~200ms reprogram drops the PCIe link, which is why partial reconfiguration is worth chasing (§4).
One internal inconsistency to fix in the page itself: it has the FireStorm EE hosting the debug application during a personality session, which can't happen since FireStorm isn't present. The CM plan resolves it by construction.
And two product lines need distinguishing. Personalities are pure-FPGA cartridges. The CM-emulated systems in §16 — PS2, N64, Saturn, Dreamcast — are a different mechanism that shares the output pipeline but not the cartridge format. Worth stating explicitly, or the catalogue and the emulation chart will look inconsistent to a reader.
The AAA Amiga personality
The Amiga cartridge builds on Minimig-AGA (MiSTer-devel), GPL v3, and extends it with AAA — the chipset Commodore never shipped. AGA software runs on AAA hardware transparently via a translation layer; both sprite engines run in the same frame; and chip RAM moves to the fast 36-bit 200MHz SRAM bus with the synthesised chips pulling working data from BSRAM, so the Amiga's defining bandwidth constraint largely disappears.
It's the most interesting thing in the catalogue, and better founded than it first looks. My initial objection — that AAA has no software — is answered completely by the translation layer, which inverts the proposition: it isn't a mode nothing runs in, it's the best way to run the existing library. It also answers the verification problem indirectly. There's no AAA reference to check against, but the test becomes "does AGA software behave correctly on it", and there are thousands of testable titles for that.
The Picasso96 insight is the cleverest part — chunky-native hardware means RTG is native rather than emulated, which turns the Amiga personality into a usable computer rather than only a games machine.
Five things worth settling:
Licensing is the strategic one. Minimig-AGA is GPL v3, so an AAA extension is a derivative work and must be GPL v3 too. The first AAA implementation in existence would have to be published. Entirely workable — plenty of hardware ships GPL cores, and it suits a catalogue built on the MiSTer ecosystem — but a deliberate decision rather than a detail discovered at release.
The CPU list is a far bigger commitment than the chipset. The section header says softcore 68000/68020, but the OSD options and the AAA pairing promise 68030/040/060 — and no open-source core exists for any of those. TG68K and fx68k are 68000-class; Apollo's 68080 is closed. Offering an 040 or 060 means building one, with FPU, MMU and caches: the 68000NG project (§16) several times over. Either narrow the list or mark it explicitly as roadmap.
Contention removal is a compatibility hazard, not only a benefit. A great deal of Amiga software — demos especially — is timed against chip RAM contention: Copper splits, blitter-wait interleaving, code assuming the CPU gets every other cycle. Faithful mode needs to model contention; enhanced mode removes it. The same applies to HAM-8 → HAM-10: HAM is delta-encoded, so higher modify precision reduces the fringing that's part of how HAM pictures look.
Two documentation fixes. The prose correctly says AGA extended ECS registers in place — BPLCON3/4, FMODE at $1FC, all within $DFF000–$DFF1FF — but the table then gives $DFF200–$DFF3FF as "AGA extended", which isn't where they live. And $E00000–$E01FFF is worth checking against the standard memory map and Zorro II autoconfig space. Separately, the Picasso96 claim needs a caveat: P96 requires a board driver for whatever register interface Monica presents, so either write a small P96 driver for Monica or emulate an existing card's registers.
Positioning. Lead with "the Amiga Commodore was building when it died, finished". Almost nobody recognises "AAA"; the counterfactual is the story.
The porting block
The plan is to supply a simple block for core developers to attach to — stick data in, line buffer out — so cores are easy to port and get the CM's ImGui overlay for free. That block is the thing that decides whether the catalogue is achievable, so it's worth over-investing in.
Mirror MiSTer's emu module port list as closely as possible. Every core in
the catalogue already implements that interface, so matching it turns a port into
"instantiate this instead of that" rather than interface surgery. The signals
that matter:
Video — this is the group to get right.
| Signal | Width | Purpose |
|---|---|---|
CLK_VIDEO |
1 | Core's pixel-domain clock |
CE_PIXEL |
1 | Clock enable — pixels are valid only when high |
VGA_R, VGA_G, VGA_B |
8 each | Pixel data |
VGA_HS, VGA_VS |
1 | Sync |
VGA_DE |
1 | Display enable (active area) |
VGA_F1 |
1 | Interlace field |
VGA_SL |
2 | Scanline hint |
VIDEO_ARX, VIDEO_ARY |
12–13 | Aspect ratio |
HDMI_WIDTH, HDMI_HEIGHT |
12 | Output size, in |
Audio — CLK_AUDIO (24.576MHz), AUDIO_L/AUDIO_R (16-bit),
AUDIO_S (signed flag), AUDIO_MIX.
Input, via hps_io — joystick_0 … joystick_5 (32-bit bitmaps),
joystick_l_analog_0 / joystick_r_analog_0, paddle_0, spinner_0,
ps2_key (11-bit), ps2_mouse (25-bit).
Configuration — status[63:0] (wider in newer framework versions), driven
from the OSD via the CONF_STR parameter, plus buttons, forced_scandoubler,
direct_video, OSD_STATUS.
File and media loading — ioctl_download, ioctl_index[7:0], ioctl_wr,
ioctl_addr[26:0], ioctl_dout, ioctl_wait; img_mounted, img_readonly,
img_size; and the SD block interface sd_lba, sd_rd, sd_wr, sd_ack,
sd_buff_addr, sd_buff_dout, sd_buff_din, sd_buff_wr.
Memory — DDRAM_* (64-bit, burst, DDRAM_BUSY/DDRAM_DOUT_READY
handshake) and SDRAM_* (16-bit). Both map onto the Gowin DDR3 controller.
Misc — LED_USER, LED_POWER, LED_DISK, RTC, TIMESTAMP, gamma_bus,
UART_*, USER_IN/USER_OUT.
(Exact widths vary between framework revisions — check against the MiSTer
sys/ version being targeted.)
Four design notes on the block
Accept a pixel stream, not scanlines. MiSTer cores don't hand over a
completed line — they emit pixels with CE_PIXEL and sync. The personality page
describes the obligation as one scanline at a time, which is right
architecturally but would mean every porter restructuring their video path. Take
the pixel stream and do the line assembly inside the block; the porter connects
five signals and stops thinking about it.
Shim the manifest registers onto status[63:0]. Cores read their
configuration from that bitfield today. If the manifest's register-window/PCIe register
writes land there, an unmodified core reads its settings exactly as it always
did, while the manifest system drives it from outside without the core knowing.
That's most of the OSD integration solved for free.
Version the interface. Once cores exist against the block, changing it breaks them — so a version register, and a declared minimum per core, from day one. Same discipline as the cartridge pinout (§4).
Ship a reference core and a testbench. A minimal personality — colour bars with a joystick-driven sprite — that exercises every signal lets a porter validate their toolchain before touching a real core. With a Verilator testbench they can do it without hardware at all.
The porting work then stays where the personality page already puts it: BRAM primitive mapping, PLL replacement, DSP block mapping. Matching the port list is what keeps it there rather than adding interface rework on top.
18. AntOS on Linux
The framing that governs everything here
Linux sits under AntOS. AntOS is still the system — its console still runs Luau scripts. Linux is substrate, not the product, exactly as ESP-IDF and FreeRTOS are today. The standard to hold it to is the one already met: nobody using an Ant64 knows FreeRTOS is under there.
Several decisions already enforce it structurally — no systemd, init= straight
to antboot, no getty, no login. The places it will leak need deliberate closing:
- Paths. Luau must never see
/mnt/…. The usual leak isn't the API, it's error messages carrying the real path through. - errno. "Input/output error" and
strerroroutput. AntOS errors want to be AntOS-shaped at every syscall boundary. - A console shell escape, which turns support from "which AntOS?" into "which Linux?".
- The compiler, most of all — a Linux program with Linux paths and GCC-shaped diagnostics, and the newest thing being added.
The three screens
| Output | Role | |
|---|---|---|
| 1 | DSI1 | Small TFT beside the keyboard — ImGui context, later |
| 2 | HDMI0 | 720p ImGui overlay — ImGui context |
| 3 | HDMI1 | Under-plane — the Accelerator's bottom video layer |
So initially there's one ImGui context, with a second when the TFT lands. Build the display layer for N contexts from the start; the local-versus-netImgui split already establishes the pattern.
The TFT is a 480×800 portrait, 5-finger multi-touch panel by the keyboard, driven as a render-on-change ImGui / gui context — static between touches, so it draws only on a touch or a dirty state (plus a few settle frames for hover / cursor animation), and the DSI runs a low refresh (≤ 30 Hz); idle it costs the GPU nothing and the panel re-scans its last framebuffer, keeping it gentle on the three-HVS-channel budget the two video planes already press on.
It is the same UI as the monitor overlay, run through a different surface profile:
- Scale from physical DPI, not pixels. At ~4″ diagonal, 480×800 is ~230 PPI, so the context uses a larger font atlas and
ScaleAllSizesfactor derived from the panel's physical size — never inferred from its pixel count (a 4″ 480×800 and a large monitor can share a pixel count and need wildly different scale). - A touch-target floor — controls carry a physical minimum hit size (~9 mm, ≈ 80 px here) regardless of layout, a theme setting rather than a code fork; and the portrait shape wants a simple vertical strip of a few chunky controls (a channel strip, a pad column), not a dense editor.
- Multi-touch, past one pointer. Five simultaneous points (a capacitive controller over I²C, e.g. GT911) route to different widgets at once — chords on pads, a fader per hand, two-finger gestures — beyond ImGui's single-mouse default, which the
guilayer resolves for the touch context.
Initial layouts. The panel is multi-view, switched by context — each a view in the same touch context, so a layout change is a view change, not a modeset, and render-on-change keeps an idle one free:
- Numeric keypad / programmer's calculator (default) — the numpad the compact keyboard omits, doubling as a dev calculator: hex / dec / bin / oct, bitwise ops and shifts, the arithmetic a hardware-and-firmware machine actually reaches for.
- Firmware-update progress — the flash / OTA status surface.
- Debug information — live system and debug readouts.
- Game secondary display — a companion screen a game or demo drives (a map, inventory, status, extra touch controls) — a second screen beside the main output, DS-style.
- Original-system keys — for a loaded personality, the keys the emulated machine had that the Ant64 / eZX keyboard lacks (a Spectrum's Symbol Shift, a C64's RUN/STOP and RESTORE, an Amiga's Help, machine-specific symbols) — a per-personality touch layout feeding the guest's key input, the 5-point touch letting you hold a modifier and press a key just as on the real keyboard. It is the input counterpart of the Accelerator's peripheral redirect: the panel stands in for the keys the original had, as the Accelerator stands in for its hardware.
(These are UI content, not board detail — the exact feed for the game view, and any further layouts, belong with the gui surface story; noted here because they are what the panel is for.)
The over-plane goes on HDMI0 and the under-plane on HDMI1 because HDMI0 is the primary output where the bootloader and fbcon land. Boot text on the over-plane is harmless — the FPGA can even show it during bring-up — while stray text on the under-plane just sits behind the core.
Both planes are ordinary rendered surfaces — a DRM buffer per HDMI link, composited by the FPGA. The bit-exact discipline the old data channel needed now applies to the top plane's packed-alpha modes (6:6:6:6 / 8:8:8:6): FireStorm reads exact pixel bits to unpack colour + alpha, so on the over-plane keep full-range RGB (not YCbCr), no dithering, no CM-side scaling, and verify with a counting ramp read back through the FPGA before trusting it — a subtle pipeline transform otherwise surfaces as mystery corruption weeks later. Chroma-key mode 0 and the under-plane are plain video. One plane per output (§4): an HVS underflow shows as a flicker on that plane.
Getting ImGui up
There's no X11 or Wayland, so the chain is DRM/KMS → GBM → EGL → GLES3 → ImGui, with input from evdev.
For bring-up, use SDL2's KMSDRM backend — SDL_VIDEODRIVER=kmsdrm does the
whole dance plus evdev input, and then it's stock imgui_impl_sdl2 and
imgui_impl_opengl3. That's the right way to answer bench item 2 without
spending days on plumbing first. A bespoke backend later is ~400 lines and buys
back startup time and a megabyte or two against a 1.5s budget.
Gotchas: DRM master is exclusive (fine — nothing else is graphical); fbcon draws
until AntOS takes over, so quiet and console suppression; and devtmpfs
provides /dev/dri/* and /dev/input/event* without udev, with hotplug
available as netlink uevents read directly.
Docking branch. The colour jump pays off most visibly here — dock previews, splitter highlights and tab hovers are all semi-transparent, and two bits of blue was mangling exactly those. Three caveats: multi-viewport won't work locally (no window system when rendering fullscreen to KMS) and is netImgui-only; 720p fits two panels side by side, not four; and dock rearranging is mouse-centric, so ship named layout presets rather than expecting drag on a keyboard or gamepad.
Layout does not persist. No imgui.ini, so no writable location is needed —
io.IniFilename = nullptr with the layout built in code. For an appliance that's
arguably correct anyway: a stray panel drag shouldn't become permanent state a
user can't undo.
The shell
The Ant64 shell runs Luau scripts as commands. A line starting with ; is
native Lua. Otherwise it searches the current path, then D:/scripts.
Running Linux executables should be explicit, not a fallback. Silent fallback
lets a command change meaning when something gets installed, and teaches Linux
command names as though they were AntOS ones — the "which Linux?" problem
arriving through the front door. * collides with globbing; ! is better and
matches vi's :!cmd, which is exactly the semantic. It also reads naturally
beside the existing ;.
Three tiers:
- Native Luau commands — the shell's own vocabulary.
- Curated wrappers — Luau scripts invoking Linux binaries, translating paths
in and shaping errors out, so
cc main.cfeels native. This is where the compiler, assembler and flashing tools live. !escape — raw Linux, explicitly outside AntOS, reasonably dev-mode only.
Scripts get it too, as os.linux(cmd) — fitting the existing os library
rather than a new global, and named so it says what it is. That's how the
wrappers get written in the first place.
One split worth planning: pty versus pipes. A pty merges stdout and stderr —
a terminal has one stream by construction — which is right for the console, where
you want live ordered colourised output. It's wrong for a wrapper: gcc puts
diagnostics on stderr and the compiler wrapper needs them separately to feed the
editor's line-keyed error markers. So !cmd is interactive-and-merged;
os.linux() captures with the streams separate, returning something like
out, err, code.
Use forkpty, not a pipe, for the interactive path — programs check isatty()
and switch to 4KB block buffering with no colour against a pipe, so a compiler
produces nothing until it exits. Set TERM, set the width with TIOCSWINSZ,
read the master with epoll off the render thread, and cap the output ring.
Ctrl-C is SIGINT to the foreground process group, which needs setsid() and
TIOCSCTTY in the child. And os.linux() mustn't block the render task — worker
context only, or a handle the caller polls.
Running ncurses apps
AntOS already has substantial VT100 emulation, and the gap to running vim is
smaller than it sounds, because ncurses reads terminfo — you declare what you
support rather than matching a real VT100. Ship a custom terminfo entry in the
rootfs and point TERM at it; anything undeclared, ncurses works around.
Essential: cursor addressing (CUP), erase (ED/EL), SGR attributes and colour,
alternate screen (CSI ?1049h/l) so the console is restored on exit, scroll
region (DECSTBM), autowrap, and size via TIOCGWINSZ plus SIGWINCH.
The underestimated half is input. Whatever the terminfo promises, the console must produce — arrows, function keys, Home/End/PgUp/PgDn encoded from ImGui key events, including the classic bare-Esc versus Esc-prefix timing ambiguity that vim leans on constantly.
Skippable: mouse reporting, bracketed paste, 256/true colour, DEC line-drawing.
Test with vttest, check with infocmp. Roughly a week on an existing VT100
core — and it makes vim an alternative to the planned ImGui editor, which is a
different bet on the self-hosted development story.
DBFS, FUSE and init.zip
D: is DBFS, so D:/scripts is a folder inside a database, not a filesystem
directory. That has a sharp consequence: a Linux executable can't open those
paths. !vim D:/scripts/foo.lua has nothing to open().
The Linux answer is FUSE. Expose DBFS as a filesystem and every Linux tool sees drive letters as real directories — gcc reads sources from D:, vim edits in place, and the path-translation layer the wrappers would otherwise need largely evaporates because the mount point is the translation. It costs a daemon and some per-syscall overhead, but it's the standard shape for a database-backed store, and it's exactly the sort of thing FreeRTOS couldn't offer.
init.zip seeds D:/scripts on init rather than being searched at runtime, and
it should live on the boot FAT32 partition. That puts it on the Tier 1 cadence
alongside the AntOS binaries — the right pairing, since scripts version with the
API they call, not with the kernel — and makes it replaceable from any PC with a
card reader through the service hatch, with no update mechanism at all.
Because the scripts are copied into DBFS and DBFS persists, init.zip is a bootstrap, not a runtime dependency: inert on an established machine, so per- version naming would achieve nothing and a deleted file does nothing either. The residual risk isn't rollback but newer scripts running against an older binary after a Tier 1 revert, which is an API-compatibility discipline rather than a file-placement problem. Still write it temp-name-verify-rename, since a torn init.zip seeding an empty DBFS would seed garbage.
A local AI provider: Needle 2
The AntOS AI subsystem already has a provider catalogue dispatching on API style, with a designed tool-use architecture and Ollama occupying the "local, no key" slot. Needle 2 (cactuscompute.com/needle) is a much better fit for that slot: Apache 2.0, 45M parameters, a single dependency-free C++ binary at 14MB with 28MB of session RAM, and ~500 tokens/s decode on Pi 5-class hardware. It's purpose-built for tool calling, device use and structured extraction — explicitly not chat or world knowledge — with grammar-constrained output and a learned confidence score.
It sidesteps the blocker already identified in the subsystem. The known gap is that the flat JSON extractor can't parse nested tool-call arrays, and that structured parsing is real new C work. Needle's grammar-constrained decoding returns the call structurally, so adding it first would let the whole tool-use loop be built and tested with no nested-JSON parser, no network, no API keys and no rate limits — after which the cloud handlers are a second implementation of a proven loop rather than the thing you debug it against.
It slots in as a fourth style — in-process rather than HTTP, so no key, no timeout, no model list. The internal tool vocabulary already designed is exactly its interface. And the confidence score maps onto the existing multi-provider architecture: run local, escalate to a cloud provider below threshold.
Where it earns its place on this machine: the manifest system (§17) is already a tool schema — typed components with presets and ranges — so a personality's own manifest becomes the tool list with no extra authoring. "Turn on scanlines", "switch to PAL", "enable turbo", on a machine whose input device is a gamepad. The planned MCP server work turns inward for free. And structured extraction is a genuine chore-solver for messy TOSEC and No-Intro filenames.
Three cautions. Register it with a tools capability rather than chat, or people will try to converse with it. Absolute accuracy is modest — 63.7% on Mobile Actions, around 30% on the harder Seal-Tools sets — so always show an action preview, always keep the manual path, and always confirm anything destructive regardless of confidence. And expect to fine-tune, since AntOS's vocabulary is nothing like its consumer-device corpus — though the manifests can generate the training data, which makes that tractable.
Language and toolchain — the plan
AntOS moves to fully modern C++23. C only where genuinely needed.
Go was considered and rejected for the core. The decisive issue is cgo:
C→Go callbacks cost 1–2µs and involve the scheduler, and that's the direction
AntOS uses most — Luau scripts calling os.* constantly. Go→C is cheaper at
50–70ns, so ImGui's few thousand calls a frame would be wasteful but survivable;
the callback direction wouldn't. And the headline advantage evaporates on
contact: pure Go cross-compiles from Windows with no sysroot at all, but cgo
needs a C cross-toolchain and sysroot anyway, and every major dependency —
ImGui, Luau, SDL, mesa, msfa, Needle — is C or C++.
Go remains a good fit for peripheral services that need no C: the update daemon, networking and SMB, the MCP client, the cloud AI provider layer, the netImgui bridge, manifest tooling. The process isolation already planned makes that natural rather than contrived.
Standard: C++23, statically linked
The constraint that catches people isn't the compiler, it's the target's
libstdc++ runtime. Cross-compiling with GCC 15 against a Bookworm sysroot
(libstdc++ 12) works for header-only features — span, expected, concepts,
ranges — but std::format needs libstdc++ 13 and std::print needs 14.
-static-libstdc++ lifts the cap. The standard library goes into the AntOS
binary, the target runtime stops mattering, and you get whatever the
cross-compiler supports. A few megabytes on a card that's mostly empty.
It also shrinks the Tier 1 / Tier 2 ABI problem (§11): a binary carrying its own libstdc++ is far less coupled to the rootfs, so a Tier 1 update against an older Tier 2 image is much less likely to break. That was the one genuinely awkward interaction in the two-tier design.
Build with GCC 14 or 15 from the WSL side. Avoid -std=c++2c for anything
shipping — C++26 is incomplete everywhere — and verify specific features against
libstdc++'s implementation-status page rather than trusting a version number.
Memory safety, ranked by bugs found per unit of effort
1. An ASan + UBSan dev build. Use-after-free, heap and stack overflow, leaks, signed overflow, null dereference, misaligned access — caught where they happen rather than three functions later. ~2× slower, ~3× memory, which is nothing on a CM5. TSan for data races needs its own build; it can't combine with ASan.
The part that matters here: ASan works across the C boundary, and since ImGui, Luau and msfa are built from source they can be instrumented too. That's exactly where the risk lives — the wrapper code between C++ and C libraries.
2. Hardening flags on always, including release, at near-zero cost:
-D_FORTIFY_SOURCE=3 -D_GLIBCXX_ASSERTIONS
-fstack-protector-strong -fstack-clash-protection
-Werror=format-security
_GLIBCXX_ASSERTIONS is the quiet winner — bounds checks on vector::operator[],
string and iterators, cheap enough to ship.
3. -Werror with -Wall -Wextra -Wconversion -Wshadow.
4. clang-tidy with bugprone-* and cppcoreguidelines-* in CI.
5. Discipline that costs nothing: no owning raw pointers, no raw
new/delete, std::span at every boundary, enum class throughout.
Never measure boot time from a sanitizer build — the 2× slowdown distorts a number you'll be making decisions on.
Two rules that make it hold
Wrap each C library once, in one translation unit, behind a C++ interface. SDL, DRM, EGL, Luau, msfa — each gets one file that owns the raw handles, and nothing else ever sees one. Without that, C-isms leak outward and you end up with modern C++ that's really C with namespaces. ImGui, Luau and msfa are C++11/14-era code that compiles fine under C++23 but can't be made modern; the wrapper boundary is what contains that.
Don't rewrite working code for style. Port it, wrap it, and modernise only
what you're changing anyway. A language decision invites a rewrite, and the
boot-time and behaviour work already banked is worth more than uniformity. For
files being ported largely intact that stay C, __attribute__((cleanup(fn))) —
as used throughout systemd — gives the same scope-exit guarantee.
Also worth considering regardless of language: arena allocators for frame-scoped work. Allocate from a region, free the whole region at once, and there's no per-object cleanup to forget.
The AntOS console replaces the Linux console
Anything using the standard libraries should print into the AntOS/ImGui console, not to a Linux console that nothing is looking at. Three mechanisms, because they're genuinely different problems.
In-process output — printf, iostreams, third-party libraries. At startup,
create a pty, dup2() its slave onto fds 1 and 2, and drain the master into
the console buffer. That catches everything written by anything linked into
AntOS, including libraries you don't control.
Use a pty rather than a pipe: isatty() stays true so libc keeps line
buffering instead of switching to 4KB blocks — otherwise output appears in
chunks, or not until exit. Colourised output also arrives as escape sequences the
VT100 emulation already handles.
Three things to get right: drain from a separate thread or non-blocking in the
event loop, because a full pipe deadlocks the writer; guard against recursion
if console rendering itself prints; and route Luau's print through the console
API directly so it can be tagged per script, with the fd redirect as the safety
net.
Child processes — the ! escape, the compiler — are already covered by the
forkpty design above. Separate pty per child.
Kernel messages can't be caught at fd level, but /dev/kmsg is readable as a
live stream, so AntOS can display them. Read the whole ring at startup, not
just new messages, and the complete boot log appears retroactively — which is
exactly what someone wants when diagnosing a machine that misbehaved before AntOS
existed.
Gate the display, not the capture. Capture is free — the kernel holds the log
regardless — so only showing it needs a trigger. Hold Shift during boot, and
provide a dmesg-style command so it stays available afterwards without one.
Don't spend a pre-Linux GPIO boot mode on that: AntOS is running by then, so it can ask Sticky. Better still, have Sticky latch whether the modifier was pressed at any point since power-on, so the user presses once at switch-on rather than guessing how long to hold.
That gives a clean two-tier split worth adopting generally:
| Tier | Mechanism | For |
|---|---|---|
| Pre-Linux | GPIO boot modes (§9) | Recovery slot, dev mode — anything the bootloader must see |
| AntOS-level | Latched key modifiers | Verbose boot, skip splash, safe mode, reset settings |
The second tier costs no pins and has as many combinations as you have keys.
What Linux newly makes possible
Roughly in order of how much each would reshape AntOS:
- Process isolation. Cores, the toolchain, the NextPi impersonation and netImgui as separate address spaces, supervised the way antboot supervises AntOS. That extends the existing crash-isolation instinct inward — on FreeRTOS every task shared one address space and one bad pointer killed everything.
dlopen. Cores, codecs and extensions as shared objects on D: rather than a firmware rebuild. That changes what the platform is, because third parties can ship modules without you shipping a release.- The PSRAM/SRAM juggling simply ends.
heap_caps_malloc_extmem_enable, the static-versus-heap diagnosis, the bootmark heap deltas — with real virtual memory none of it exists. An entire category of bugs and instrumentation gets deleted rather than ported. - Real SMP. Four cores,
sched_setaffinity,SCHED_FIFO,isolcpus— which is what makes the hybrid-emulation ideas in §16 plausible at all. - The development loop.
sshin,gdbon a running AntOS,perf,strace, and core dumps on D: for crashes nobody witnessed. - Libraries. SQLite under DBFS, zstd or lz4 for compressed cores and assets, real TLS with a cert store, mDNS so netImgui finds the machine.
One to choose deliberately and early: an epoll/timerfd/eventfd event
loop rather than FreeRTOS-style task-per-thing. It's the kind of architecture
that's painful to retrofit once twenty subsystems each have a thread.
Toolchain note: builds need writable scratch and the OS card is read-only. tmpfs is the right answer — ephemeral, no wear, faster than any card — with artefacts landing on D:.
19. The asset drive (F:)
F: is a read-only shared asset library — folders of fonts, sprites, samples and patch data, living in spare space on the USB stick. Games can rely on it always being there. It only ever grows.
Why it matters more than it looks
Games get smaller in distribution, because they don't ship fonts and sprites
that already exist on F:. The right framing is that it makes AntOS software work
like MIDI — a .MID file is tiny because the patches live in the synth
rather than the file. Reference the asset; don't ship it.
Two places that pays off beyond the obvious:
- The gossip mesh. A 200KB game that's mostly code and a manifest of F: references is genuinely shareable machine-to-machine. The same game with bundled art isn't.
- Deduplication. The stick stops holding twenty copies of the same font across twenty games.
The patch-list case fits especially neatly: AMY patch data, SoundFonts, GM sets,
DX7 .syx banks. A music program becomes a tiny file that names its instruments.
It's also the natural home for AntOS's own non-code assets — ImGui fonts, the custom terminfo entry, CRT preset LUTs, and Needle's model file (§18).
Four things it needs to keep the promise
A version contract. The moment F: grows, a game written against v3 can use an asset v2 doesn't have. So F: needs a declared version and a manifest games can query, with a "requires F: ≥ n" field the loader checks. Otherwise "always there" quietly becomes "usually there", and the failure lands on the game author.
Append-only, immutable. Never rename, replace or remove a published asset. A better version of a font is a new path, not an update in place. This is the rule shared libraries always break, and one rename breaks every game that used it.
Games must declare their F: dependencies. The risk compounds — every game
depends on F:, so a missing, corrupt or wrong-version F: breaks everything
rather than one thing. Treat F: as part of the system image rather than user
data, diagnose a miss at load rather than when a sprite is drawn, and provide
a verify F: command.
Checksums and a size budget. A shared library that silently rots breaks many things at once, and "only ever grows" fills the stick over years — so the updater should refuse to grow F: past a set fraction of capacity.
Licensing — the policy and the trap
Only completely free, redistributable assets. Anything not free to distribute is not included.
Worth phrasing around the user's rights rather than yours, because "free to redistribute" isn't the whole test. Three permissions are needed:
- you may redistribute it on the stick
- a game author may use it in a derivative work
- that author may redistribute their game containing it
Plenty of assets pass the first and fail the others — "free for personal use" fonts being the classic.
| Acceptable | Excluded |
|---|---|
| CC0, Unlicense — no obligations | CC-NC — bars commercial use |
| MIT, BSD | CC-ND — bars modification |
| SIL OFL — note the reserved-font-name clause | CC-BY-SA — viral into the game |
| CC-BY, if AntOS surfaces the attribution | GPL without a font exception |
CC-BY-SA is the real trap: a user who drops in one BY-SA sprite may have to license their entire game under BY-SA.
The actual risk is provenance, not licence theory. Most "free sprite packs" circulating online have laundered origins — someone reposting work they don't own under a licence they invented. So record a source URL, the licence text as published, and a date, per asset, machine-readably. Clean sources: Kenney.nl (CC0, game-oriented, unambiguous), Google Fonts, The League of Moveable Type. OpenGameArt and freesound are mixed and need per-asset checking.
Sample libraries deserve the most caution of all — "free" drum packs routinely forbid redistribution as samples and permit use only inside finished music, which is exactly what an asset drive does.
The feature that falls out
Because games declare their F: dependencies, AntOS can generate the credits list automatically. That removes the attribution burden from the game author entirely — and it's what makes CC-BY safe to include rather than a liability.
20. First steps
Superseded ordering: bringup's Risk Retirement table carries the current version of this sequence, with what has changed since — several items are now Ant64-only, and two corrections could change what you port to. Read that first.
In order of what could kill or reshape the plan, using hardware already on hand, and deliberately before any carrier design or AntOS porting.
- Three displays on the CM5IO. DSI panel plus two HDMI monitors on
vc4-kms-v3d. Confirms the link allocation on the module that will ship it. - Prove the premise on stock Pi OS. ImGui on GLES at 720p full colour with the editor page open; measure frame time. The whole justification is that 35.7ms of softraster disappears — confirm it on the easiest possible setup, via SDL2's KMSDRM backend rather than a bespoke DRM/GBM/EGL stack (§18).
- Tang Mega into the CM4IO PCIe slot. Does it enumerate in
lspci? Can you map a BAR and read/write it? Then the architectural question: does Gowin's PCIe IP expose a requester interface so the FPGA can master writes upstream? That decides push-model versus PIO fallback. - Boot floor.
BOOT_UART=1, timestamp the bootloader stage, thendmesg. Until you know the fixed cost, you can't tell whether boot tuning is worth it. On the CM5 this needs a prepared module — bootloader output goes to the module-side debug connector, not GPIO14/15 (§12). Fit the JST or wire TP35/TP36 before the module is in a case. - DSI1 driving the actual chosen TFT, standalone. Highest-risk component, cheap to test, expensive to discover late.
- Transceiver maximum line rate, from the datasheet.
Then, once those return:
- rpi-image-gen under WSL2 — confirms the Windows build path works with the privileges it needs.
- PCIe round-trip and throughput under display-list-like traffic.
- TMDS ingest — CM HDMI into the FPGA's DVI Rx, one 720p frame into DDR3.
Items 1–5 are all answerable on stock boards with stock software. Resist writing
antboot or touching the sys_* port until they're done — two of them could
change what you're porting to.
21. Summary of open questions
- Does Gowin's PCIe IP expose a requester interface? Whether the FPGA can bus-master writes upstream decides push-model versus PIO fallback, and shapes the register shadow, the emulation work and the side-bus question. The most architecturally load-bearing unknown. (§16)
- Does the FPGA self-load its bitstream from SPI flash? Partly answered — DP link training on the hard RISC-V core already takes the CM off the critical path for first pixel, but bitstream loading is still open. Biggest remaining lever on perceived boot time. (§6, §8)
- Does the GW5AT support partial/dynamic reconfiguration that keeps the PCIe hard IP alive? If so, core switching never drops the link. (§4)
- Does DSI1 drive the chosen TFT? Highest-risk hardware item. (§4)
- Is the panel actually DSI? At numpad size it may well be SPI, which frees DSI1 entirely. (§4)
- Confirm the transceiver line rate against the transceiver datasheet. Gowin's PCIe IP page implies 8 GT/s, which would put HBR2 and 4K60 8bpc in range — but confirm before the spec claims it. (§6)
- What does the second link carry? — Resolved. HDMI1 is the Accelerator's under-plane video (§4, §6); CM↔FPGA data moves to PCIe. (§6, §16)
- Overlay behaviour in 15kHz superresolution mode — suppress it, or a separate ImGui context drawn for 240 lines? (§6)
- PLL and clock-domain budget across two TMDS receivers, the DP TX and the AG6201 TMDS TX. Check before the carrier is fixed. (§6)
- Does the PS176 master the HDMI DDC itself? If so, Pulse can't also master it — read the EDID via DP AUX instead. Affects the CEC physical-address path. (§6, §14)
- AUX pin count — bidirectional pair (3 with HPD) or external transceiver (5)? And is the PS176 strap- or I²C-configured? (§6)
- EEPROM write-protect access — jumper or GPIO? Must be decided before carrier layout; the mechanism differs between CM4 and CM5. (§3, §7)
- Is the two-stage kexec selector needed at all, or do
tryboot+ GPIO + antboot cover the policy? (§9) - udev or direct netlink for USB topology. (§10)
- Does rpi-image-gen build cleanly under WSL2 with the required privileges? (§11)
- Which sysfs paths map to which physical sockets, per board revision? Must be measured, not derived — and USB3 sockets need two paths each. (§3)
- Recovery socket: mux or dedicated port? On CM5 the dedicated USB2 port can own it outright; on CM4 it needs the VBUS-sensed mux. Decide per board, since the eZX may later host a CM5. (§12)
- Does the eZX get an internal service header as standard? It has no cartridge port, so there's nowhere else to put debug or recovery access, and it can't be retrofitted. (§12)
- Is Pulse on standby power? Decides whether a TV remote can wake the machine or only control it while running, and so whether CEC sits with Pulse or Sticky. (§14)
- Does the Interface Block's port list track a specific MiSTer
sys/revision? Widths and signals vary between framework versions; pinning one decides how much per-core adaptation remains. (§17) - Does re-seeding overwrite user-edited scripts? An easily-replaced init.zip makes this more likely. Version-stamp and replace only untouched ones, or split a user namespace shadowing a read-only built-in set. (§18)
- FUSE for DBFS? It would let every Linux tool see drive letters as real directories and largely remove the path-translation layer — at the cost of a daemon and per-syscall overhead. (§18)
- Event-loop shape —
epoll/timerfdversus task-per-thing. Cheap to choose now, painful to retrofit. (§18) - Does Sticky have three spare PWM pins for the power-button RGB? If the matrix has eaten everything, moving columns to a shift register frees a dozen. (§14)
- WM8960 lifecycle status with Cirrus — it's a Wolfson-era part. (§14)
- How do the two RTCs relate on CM5 — sync the module's from the MCP79410, or ignore it? Two clocks quietly disagreeing is a nasty class of bug. (§15)
- Where device identity lives — carrier EUI-48, C5 eFuse, or both. (§15)
- Is publishing the AAA implementation under GPL v3 acceptable? Minimig-AGA is GPL v3, so the derivative must be too. Strategic, not technical. (§17)
- Does the Amiga CPU list stay at 68000/68020? No open 030/040/060 softcore exists; offering them means building them. (§16, §17)
- What's the F: size budget, as a fraction of the stick? It only ever grows, and the updater needs a limit to enforce. (§19)
-fno-exceptionsand-fno-rtti? Defensible for a system crossing into C constantly, and-fno-exceptionspairs withstd::expected— but it rules out parts of the standard library's error paths. Decide deliberately. (§18)- Instruments on Pulse or the CM? The stated rule puts musical instruments on the CM and sample-accurate retro chips on Pulse — worth confirming before the layer splits by accident. (§14)
Written up 2026-08-14, revised 2026-08-15, as the design discussion that led to adopting the CM5 supervisor. For the current design see bringup / os_card / firmware / demon.