Firmware & Flashing

How firmware gets onto the Ant64's programmable parts — the wireless subsystem (the ESP32-C5 Phreak) and the supervisor (the CM5 DeMon) and the FireStorm FPGA — covering the in-system flasher, the file/manifest management layer, DeMon's self-update path, blank-board recovery, and the FPGA's dual role of firmware target and soft-machine host.

DeMon is the root of a flashing tree: it either flashes a chip directly or relays the image to an intermediary that does. The direct-vs-relay split follows the wiring — only chips physically behind Pulse are relayed. The full set: DeMon self-updates its own OS image on the SD card; it directly flashes Phreak (UART), Pulse (USB), Clicky (UPDI), and FireStorm (JTAG); and it relays through Pulse to reach Sticky and Cranky (UPDI). This page covers the Phreak wireless subsystem, DeMon's own updates, and the FPGA in detail; the AVR/UPDI path is summarised in the scope note below.

The guiding principle is that every chip has both an in-system path and an external path, so nothing is ever unrecoverable. Flashing direction is strictly DeMon → C5, never the reverse. (An earlier idea had Phreak able to reflash DeMon; that was dropped — the OTA partitions plus the cartridge-port unbrick cover DeMon completely.)

Chip In-system path External / recovery path
C5 / Phreak DeMon reflashes it (EN/BOOT + UART) USB-Serial/JTAG on the DeMon hub (bench)
CM5 / DeMon A/B OS-image slot swap on the SD card Recovery slot; re-image the SD card

Scope note: this page covers the two supervisors. The AVR input controllers take a different in-system path — UPDI, not JTAG/OTA: Clicky direct from DeMon (see DeMon's Clicky UPDI); Sticky and Cranky via Pulse. Same every-chip principle, separate mechanism.


The C5 flasher — DeMon reflashes Phreak in-system

DeMon reprograms the C5 over a private UART plus GPIO control of the C5's EN (reset) and BOOT (strap) pins. Because DeMon physically drives EN + BOOT, it can force the C5's ROM download mode regardless of what firmware (if any) the C5 is running — this is the robust, always-recoverable path.

Control lines (via the DeMon MCP23017 I/O expander over I2C; the NFC reader reset is separately owned by Clicky):

  • C5 EN (reset) = MCP23017 PA6 (IOEXP_C5_EN)
  • C5 BOOT / GPIO28 strap = MCP23017 PA7 (IOEXP_C5_BOOT)
  • C5 GPIO27 is tied high (GPIO27=0 & GPIO28=0 would be an invalid download-entry combination)

Data link — DeMon UART1, GPIO2 (TX → C5 U0RXD) / GPIO3 (RX ← C5 U0TXD), 115200 baud, SLIP protocol. The C5's UART0 is both the AT command port at runtime and the firmware-download port when flashing; the two never overlap. Logging stays on the USB-Serial/JTAG so it never pollutes that stream.

Phases:

  • Phase 1 (done) — enter download mode, SYNC, read chip identity. Proven by phreak probe → synced = true.
  • Phase 2 (next) — FLASH_DEFL_BEGIN / DATA / END plus SPI_FLASH_MD5 verify, reading the merged image from the drive via the filesystem.
  • Phase 3 (later) — the sys_phreak AT-comms driver (below).

The C5 module is currently N16R8 (16 MB flash + 8 MB PSRAM), moving to N32R8 (32 MB) — comfortably larger than the stock 4 MB image, so every compile-in command set can be enabled. The flash image is a single merged blob (esptool merge_bin: bootloader + partition table + factory params + app), stored on the internal database filesystem and updatable without reflashing DeMon.

The C5 is also reachable over USB on the DeMon hub, so esptool can flash it directly over USB for bench work. The UART + EN/BOOT path is what makes in-system, PC-less field updates possible — the two are complementary: USB for the bench, UART for in-system.


The AT-comms driver (sys_phreak)

A sibling to the flasher: where the flasher owns the C5 link for flashing, sys_phreak owns it for runtime AT traffic. They share the same UART with time-exclusive ownership — you are either flashing the C5 or talking AT to it, never both, so the two coordinate their claim on the port.

Design:

  • A core primitive at_command(cmd, timeout) → response + OK/ERROR.
  • An RX reader task that separates command replies from unsolicited notifications (+IPD, +LINK_CONN, WIFI GOT IP, BLE reports). AT+SYSMSG bit 1 is the parse-mode knob.
  • Start at 115200; later negotiate up toward 5 Mbps via AT+UART_CUR (which needs hardware flow control — CTS/RTS).

The first milestone is an AT / AT+GMR round-trip, which can be proven against the prebuilt stock esp-at binary (flashed over USB) before any custom firmware exists. The C5 esp-at has tagged releases (v5.0.x, on an ESP-IDF v5.5-class SDK) with full Wi-Fi station/SoftAP support.

Open item: the DeMon-side AT pins need confirming against the schematic. The flasher (working code) uses UART1 GPIO2/GPIO3; the AT reference lists "TX IO11, RX IO12, CTS IO23, RTS IO24," which are most likely the C5-side pins since AT and download share the C5's UART0 (the same physical wires). Trace the DeMon net to the C5's U0RXD/U0TXD and confirm the DeMon TX/RX GPIOs, whether CTS/RTS are physically wired, and the UART number.


Firmware file management — offline-first

The C5 image (and eventually the DeMon image) is too large to embed in the running P4 firmware, so it lives as .bin files on removable storage and is flashed from there.

  • Naming: <system>_<major>.<minor>.<patch>.bin (e.g. demon_0.34.0.bin). The version is the release version, stamped from the AntOS version macros (ANTOS_VERSION_MAJOR / _MINOR / _PATCH in sys_os.h) at build time, and doubles as the version stem the tab matches on. Note it does not sort chronologically as a plain string — 0.9.0 sorts after 0.10.0 — so nothing relies on filename order: the manifest names its own latest per channel, and the on-disk list sorts by real modification time (see below).
  • Location: the firmware folder is configurable (default A:/firmware). If the configured drive is absent, the tab falls back through a fixed order — A: (SD) → E: (external USB) → B: (internal) → D: (the DBFS master) — taking the first drive present (see Filesystem & Drive Letters). A:/E:/B: are general-purpose media where you'd deliberately drop a firmware zip; D: is the last-resort floor, used only when nothing else is mounted — and since the system boots from it, it's effectively always there, so the tab can almost always find somewhere. Firmware lives in a /firmware dir on whichever drive is used, keeping it out of the root (and clear of sys.db on D:). Only if genuinely no drive is present does the tab show an amber "insert an SD card or USB drive" prompt.

A device that flashes coprocessors shouldn't depend on the network being up, so the design is offline-first, with three tiers:

  1. Drive manifest — a manifest.inf in the firmware folder is read automatically when the tab opens. This is the offline path with full richness: channels, notes, and checksum verification, all with no network. Download the firmware zip on another machine, extract it (with its manifest.inf) to the drive, and it appears.
  2. Bare listing — even with no manifest at all, the tab lists whatever <system>_*.bin files are on the drive, each with a Flash button. This always works.
  3. Network refresh — a "Refresh from network" button fetches the manifest from a configurable URL to update things when a network is available. On fetch failure it keeps the drive manifest rather than wiping it.

Precedence is drive-first: the on-drive manifest is the baseline, and the network is the refresh on top.

Manifest format

One combined manifest covering every system, each with its own release channels. Today only demon is populated; the other systems join as their images start shipping, in exactly the same shape.

{
  "schema": 1,
  "demon": {
    "stable": { "latest": "0.31.0", "builds": [ /* build objects */ ] },
    "beta":   { "latest": "0.33.0", "builds": [ /* build objects */ ] },
    "alpha":  { "latest": "0.34.0", "builds": [ /* build objects */ ] }
  },
  "phreak": {
    "stable": { "latest": "", "builds": [ ] }
  }
}

Three channels are defined — stable, beta and alpha — and a system need only carry the ones it actually publishes; a missing channel is simply absent rather than empty. latest names the version a channel currently points at, so no consumer has to sort version strings to work out which build is newest.

Each build object:

{
  "version": "0.34.0",
  "url": "https://firmware.ant64.com/demon_0.34.0.bin",
  "size": 4370864,
  "sha256": "7cc09ba2bf55bd3f1c8ba6235e863004b5b1b057d62cc3ca2fcfd3fd838d9031",
  "date": "2026-08-06T17:07:29Z",
  "notes": "AntOS 0.34.0 (alpha)"
}

The date field is the image's build time (the .bin's modification time), written in ISO 8601 UTC. It is produced by the release tooling in PowerShell rather than the batch script so the format is locale-independent, and it is optional — an older manifest without it still parses, the field simply reads empty. The firmware tab shows it alongside each available build (built 2026-08-02) next to the running image's own build date, so a glance tells you whether an update is newer than what's flashed.

Download & verify

Downloads stream straight to disk (through the sys_io streaming layer), computing the SHA-256 incrementally as bytes arrive — the hash primitive comes from the data library (sys_data_sha256_*), not a bundled copy. The checksum is verified before the download is declared successful — a mismatch or write error deletes the partial file, so a bad image is never left sitting where it could be flashed. The whole operation runs on a background worker so the UI never blocks; progress is polled and shown as a bar.

The tab cross-references manifest builds against what's physically on the drive by version stem: a build already present shows [on disk] with a Flash button and a Re-download button; one that isn't shows Download. So in the pure-offline case (zip extracted to the drive), every listed build offers Flash directly. Re-download exists because "on disk" doesn't guarantee "good on disk" — a download can complete but the file be truncated or corrupt (an interrupted write, a bad transfer, bit-rot on the stick). Re-download reuses the same path as a first download: it overwrites the file in place and re-verifies its SHA-256, deleting it again on mismatch, so a suspect image can be refreshed without hunting for it on the filesystem.

Below the available builds, an On disk list shows the actual files present, each with its size, a Flash button, and a Remove button. Remove deletes the file (to reclaim space or clear a corrupt image); it is disabled mid-flash so a file can't be deleted while it is being written, and it refuses any name containing a path separator so it can only ever act inside the firmware folder. The list is sorted by the files' actual modification time, newest first — not by the version embedded in the filename, because the two can diverge (a deleted-and-re-downloaded file keeps its old version stem but gets a fresh write time) and because version strings do not sort chronologically anyway. Sorting on the real mtime keeps the freshest download at the top regardless, so the list stays in a predictable order.

The zip you publish should use version-stamped filenames and include a matching manifest.inf, so the offline path gets the full channel/notes/verify experience.

The manifest is named manifest.inf rather than manifest.json even though its contents are JSON, and it is served from a dedicated subdomain, firmware.ant64.com, separate from the main site. Both are content-delivery choices driven by fleet scale. With thousands of Ant64s checking for updates, that traffic is kept off ant64.com entirely — a spike in update checks can't affect the website. The subdomain sits behind Cloudflare, and a cache rule matching the .inf and .bin extensions caches the manifest and the firmware images at the edge, so the checks hit Cloudflare's cache rather than the origin. Cloudflare does not cache .json by default (it treats JSON as dynamic), so an honestly-named manifest.json would fall through to the origin on every check; the .inf name sidesteps that at no code cost, since the parser never cared about the extension. The manifest gets a short edge TTL (it changes each release); the version-stamped .bin images are immutable, so they get a long TTL and never need purging.


DeMon self-update — A/B OS-image slots on the SD card

DeMon now runs Linux on the CM5, booting from the SD card, so self-update is an OS-image swap, not an ESP esp_ota app-partition flash. The card carries two OS-image slots plus a small recovery slot; AntOS writes the inactive slot while running normally — full UI, filesystem and network stay up throughout — and the Pi bootloader atomically switches to it on the next boot.

The safety story from the old design carries over, one level up:

  • Atomic switch. The bootloader activates the new slot only once it is marked good; a truncated or corrupt write never boots — no brick.
  • Verify before activating. The written image is hashed and read straight back before the slot is made bootable, so card cells that silently didn't take the write are caught before the switch, not after.
  • Trial boot + rollback. A freshly-written slot boots on trial (the Pi tryboot mechanism): AntOS must confirm itself healthy after boot, or the bootloader reverts to the previous slot on the next reset. "The image was written correctly" becomes "…and it actually boots and runs."
  • The bootloader and the recovery slot are never touched by a normal update — the components that do the verification and fallback are never the thing being changed.

The flash path streams the new image into the inactive slot (not staged in RAM), verifies it with a read-back hash compare, marks it as the trial target, and stops — the reboot is a separate, explicit step, and the confirm/rollback handshake runs after it.

Reboot is separate and explicit

Writing a slot does not reboot. The flow sets the trial target and stops; a separate Reboot now action triggers the restart, so the moment of switching over is the user's choice. Between a successful write and the reboot, the tab shows that a slot swap is pending.

Running-image status and slot swap

A Running image panel reports the live state: which slot is running, its version and build date, and whether it is confirmed-good or still on trial (with the rollback warning). It also shows the slot the next update will write and what that slot holds — and doubles as the on-hardware confirmation of the trial-boot step, flipping from pending to confirmed once the freshly-booted image self-checks.

When the other slot holds an image, a Swap to other slot action jumps between the two flashed images — to roll back to the previous OS, or forward to one written but not yet booted. It shows what it will switch to and confirms before rebooting, refusing if the target isn't a bootable, non-rejected image. Because the swap also goes through the trial-boot mechanism, it is self-recovering: if the target fails to boot or self-confirm, the bootloader reverts to the image you swapped from.

Image layout

The SD card carries a boot partition (config.txt, with [cm4]/[cm5] filters so one card serves both modules), two OS-image slots, a recovery slot, a write-once factory restore image, and drive F: — the built-in read-only asset library. The standard image is built with rpi-image-gen, and one AntOS image serves both CM4 and CM5 lite. Exact sizes, the FAT layout and the os_prefix / tryboot.txt switch live in os_card.

There is no data partition, and that is deliberate. The card is never written except during a firmware update, so a writable partition on it would be a hole in that property rather than an exception to it — and the moment one exists, things start living there. Runtime state has better homes already: logs go to tmpfs or USB, user data is DBFS on D:, and the ImGui layout is held in memory rather than an imgui.ini.

Provisioning data — machine identity, board revision, calibration — stays off the card entirely, in the ATECC608C's lockable slots and the MCP79410's EEPROM. That is not merely tidier. The documented recovery floor is re-imaging the card from a PC, so anything identifying the machine that lived on the card would be destroyed by its own recovery path. Identity belongs to the carrier, not to removable media — which is the same reason it does not live on the swappable compute module either.

FireStorm FPGA — firmware update and core loading

The FireStorm FPGA (GoWin GW5AST-138) is unusual among the flashable parts because DeMon reaches it over JTAG, and JTAG can do two fundamentally different things to an FPGA. Getting the distinction right is the whole story here: one operation is a firmware update, the other is what makes the Ant64 a soft machine.

Two JTAG operations

The FPGA has its own fast-boot config flash — at power-on it loads a bitstream from that flash independently and comes up fast, with no help from DeMon. That's the normal boot path. Over JTAG, DeMon can act on the FPGA in two ways:

  • Write the config flash (fpga-jtag-flash) — DeMon programs a new bitstream into the FPGA's boot flash. This is persistent: the next power-on fast-boots into it. This is the firmware-update sense — it changes what FireStorm is by default, permanently.
  • Configure the SRAM directly (fpga-jtag-sram) — DeMon loads a bitstream straight into the FPGA's configuration SRAM without touching the flash. This is transient: the FPGA runs that image now, but a power-cycle reverts to whatever's in the config flash.

Same wire, same artifact format (a bitstream), completely different persistence. "Flash FireStorm" means the first one; the second is core loading.

Core loading — the soft machine

The transient SRAM path is what lets the Ant64 become a different machine. It has two uses:

  1. Test a chipset build without committing it. DeMon loads a candidate FireStorm bitstream into SRAM, you try it, and if it misbehaves a power-cycle reverts to the known-good version in flash. Zero brick risk, no flash wear, instant rollback — the FPGA equivalent of DeMon's own image-slot rollback. Once a build is proven in SRAM, fpga-jtag-flash commits it as the new default. That's a clean develop → commit flow, the same try-transient-then-persist pattern the rest of the platform uses.
  2. Run a completely different system. A future core library — a browsable catalogue of cores — can load an entirely different personality into SRAM: an Amiga, a C64, a Spectrum, an arcade board. It runs until reboot, then reverts to the home personality (the native Ant64 chipset in config flash).

Core loading is a cousin of firmware update — same JTAG-SRAM mechanism — but a distinct feature with its own UX and lifecycle. Firmware update keeps things current; the core library launches a personality. It's tied to cartridges: a cartridge is a personality, so a cartridge can carry an FPGA core that DeMon loads into SRAM.

AntOS survives the swap — the inverted dependency

Reprogramming the FPGA momentarily tears down whatever it was doing, including video output. So a hard rule falls out of it: AntOS must not depend on the FPGA staying up. AntOS runs on DeMon, which is untouched by an FPGA reprogram — it keeps executing and holds its state across the gap. During the reconfigure window the old personality is gone and the new one isn't up yet, so display is briefly invalid (expected, like a monitor flashing on a mode change); the instant the new core is up, AntOS's overlay and input come straight back.

The consequence is an inverted dependency. It isn't "AntOS needs the FPGA" — it's "every FPGA image needs AntOS." The supervisor is the constant; the personality is the variable. Every core — the native chipset, an Amiga, anything — is expected to provide an AntOS-support layer so AntOS can:

  • feed it input — keyboard (Clicky), USB, controllers (Sticky/DE-9), through the normalised input model
  • render and display its overlay — the AntOS ImGui/overlay layer composites over whatever the personality is showing

That expectation is a stable AntOS↔FPGA interface contract every core implements — a driver-ABI equivalent. Whatever a personality is internally, it exposes the same hooks (where AntOS's overlay composites in, how input reaches the core or is intercepted), which is what makes cores interchangeable without AntOS caring what they are.

The AntOS FPGA module

The contract isn't shipped as a document for core authors to reimplement — it's shipped as a module: a reusable HDL block (Verilog/VHDL/IP core) that a core developer imports and wires up, instantiating it and connecting it to their design's bus, clocks, and video. Either it wires up correctly or it doesn't; the barrier to a compliant core drops to "import this, connect these ports." It's the same declare-the-interface-once pattern used across AntOS (the input abstraction, the radio-capability model, the LED driver), pushed down into the FPGA fabric.

The module encapsulates the three parts of the contract:

  • Overlay composite path — receives AntOS's display-list/overlay from the supervisor over the side channel and composites it over the personality's own video at scanout.
  • Input intake — the interface by which normalised input events reach the core, or are intercepted by AntOS.
  • Supervisor bus — the DeMon↔FPGA link (PCIe) exposed as clean module ports the core wires to its clock domain.

Because the module's port list is the ABI, those ports want to be designed as deliberately as any public API — changing them breaks every core built against them. "Wire it up" therefore implies a documented integration surface: clock domains, the composite path's expected pixel format and timing, reset behaviour across an FPGA reprogram, and how much fabric the module costs (LUTs/BRAM on the GW5AT). The module is also versioned — a core declares which AntOS-module version it was built against, so the supervisor knows what contract to expect, and a mismatch is detectable rather than a silent break.

The natural first artifact is a reference/template core: a minimal personality that instantiates the module and does nothing else — the "empty core that still shows the AntOS overlay and takes input." It proves the wiring end-to-end and gives core authors a skeleton to build from, the same way an empty-but-registering component would prove an extension path before any real features are added.

A core is a bundle, not a bitstream. Because every image must carry the AntOS-module instantiation (and typically its own OSD theming), a personality is a package — bitstream + module + theming — not a bare .fs. That bundle format is a core-library concern; this page covers only how the bitstream reaches the FPGA.


Blank-board & recovery — re-imaging the SD card

The image-swap update needs a working system already running; a blank or corrupt SD card can't self-update. Because the OS lives on a removable SD card, recovery is simply re-imaging the card — there is no ICP cartridge (that was the ESP32-P4's blank-board path, and it no longer applies).

Three layers:

  • Recovery slot. The card carries a small recovery slot the bootloader falls back to when both OS slots fail to boot — enough of a system to re-flash the OS slots in place, without opening the case. (This slot needs the I²C port-expander driver, since recovery is exactly when the rest of the board may be held in reset.)
  • The factory restore image. A write-once partition holding the golden rootfs and boot payload, so recovery has something on the card to restore from — no network, no USB stick, no service visit. Written at manufacture and never again: it must never become an update target, or it stops being a known-good reference and becomes just a third slot. Attached media remains the alternative source.
  • External re-image. The ultimate floor: pop the SD card and re-flash it from a PC — the Raspberry Pi Imager, or a plain image write. A first-time card, or one where even the recovery slot is gone, is restored this way. No pads, no cartridge — just the card. This is also why nothing that identifies the machine may live on the card: this step would erase it.

Important: The Ant64 family of home computers are at early design/prototype stage, everything you see here is subject to change.