Firmware & Flashing

How firmware gets onto the Ant64's programmable parts — the two supervisors (the ESP32-C5 Phreak and the ESP32-P4 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-flashes (OTA); it directly flashes Phreak (UART), Pulse (USB), Clicky (UPDI), and FireStorm (JTAG); and it relays through Pulse to reach Sticky and Crank (UPDI). This page covers the ESP32 supervisors 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)
P4 / DeMon OTA A/B partition self-update ICP cartridge on the cartridge port

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 (io45), and Sticky / Crank via Pulse under DeMon's orchestration (see DeMon UPDI Orchestration). 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, same pattern as the NFC reset):

  • C5 EN (reset) = MCP23017 PA7 (IOEXP_C5_EN)
  • C5 BOOT / GPIO28 strap = MCP23017 PA6 (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 probesynced = 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 the P4.

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) → B: (external USB) → E: (extra internal USB) → D: (the DBFS master) — taking the first drive present (see Filesystem & Drive Letters). A:/B:/E: 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-flash — OTA A/B partitions

DeMon can't rewrite the flash it's executing from, so self-updating uses OTA dual-app-slot partitions rather than a RAM-resident flashing stub. The running firmware writes the inactive slot while running normally — full UI, filesystem, and USB stay available throughout — then the bootloader atomically flips to the new slot on reboot.

This is chosen specifically for its safety properties:

  • The bootloader verifies the new image and won't boot it unless it's exactly correct. A truncated or corrupt write simply doesn't activate — no brick.
  • Rollback on boot failure (app_rollback): a freshly-flashed app is marked pending-verification and must call esp_ota_mark_app_valid_cancel_rollback() after it boots and self-checks, or the bootloader reverts on the next reset. This turns "the flash was correct" into "the flash was correct and the new firmware actually works" — even a valid-but-broken image rolls back.
  • The bootloader itself is never rewritten — OTA swaps the app only, so the component that does the verification and fallback is never the thing being changed. That is what makes it safe.

The flash path: sys_fw_flash(DEMON) reads the drive image → esp_ota_begin / esp_ota_write (streaming chunks straight to the inactive slot, no large RAM staging) → esp_ota_end (verifies) → a read-back verify → esp_ota_set_boot_partition, then it stops. The reboot is a separate, explicit step (see below), and the rollback-verify handshake runs after that reboot. The UI shows progress the whole way, since normal execution is undisturbed.

The write itself is guarded on three levels, because "the flash finished" is not the same as "the flash is correct":

  • Write-side checks. Every esp_ota_write return is checked, and when the streaming read loop ends, the code distinguishes a genuine end-of-file from a read error (an interrupted source read must not be treated as "done") and confirms the number of bytes written equals the source file's actual size. A silently-truncated read is caught here rather than flashed as a partial image.
  • esp_ota_end validates the written image's internal structure and hash.
  • Read-back verify. The SHA-256 of the source is computed for free during the write loop; after esp_ota_end but before the slot is made bootable, the just-written bytes are read straight back out of the OTA partition and hashed, and the two digests compared. This is the one check that catches flash cells that silently didn't take the write — the image validates but the bytes physically in flash are wrong. On mismatch the slot is never activated, so a bad flash can't even be attempted on reboot. It roughly doubles flash time (a full read pass on top of the write), which is a good trade for a device that may not be physically reachable.

Everything that touches flash — the write, the read-back, and the status reads below — runs on a worker task with an internal-RAM stack, never the render thread. Reading otadata or memory-mapping the flash disables the CPU cache, and a task whose stack lives in PSRAM becomes unreadable while the cache is off; keeping these operations on an internal-RAM worker (and caching their results for the UI) is a hard rule of the flash path.

Reboot is separate and explicit

Flashing does not reboot. sys_fw_flash sets the boot partition and stops; a separate Reboot now action (sys_fw_reboot_to_flashed) triggers the restart, so the moment of switching over is the user's choice. Between a successful flash and the reboot, the tab shows that a slot swap is pending.

Running-image status and slot swap

A Running image panel at the top of the Firmware tab reports the live OTA state: which slot is running, its version and build date, and its validity — green for valid, amber for pending-verify (with the rollback warning), red otherwise. It also shows the slot the next update will write to and what that slot currently holds. This doubles as the on-hardware confirmation of the mark-valid step: the state flips from pending-verify to valid once the freshly-booted image confirms itself. (All of this reads flash, so it is filled by an internal-RAM worker and cached; the panel populates a frame or two after the tab opens rather than reading flash on the render thread.)

When the other slot holds an image, the panel offers a Swap to other slot action — a quick jump to the other flashed image, to roll back to the previous firmware or forward to one flashed but not yet booted. It shows what it will switch to (→ ota_1 (v0.5.0)) and asks to confirm before rebooting. The swap first checks that the other slot actually holds a bootable, non-rejected image and refuses with a reason if not, so it never reboots into a guaranteed bounce. Because it goes through esp_ota_set_boot_partition with rollback enabled, the swap is self-recovering: the target is marked for monitoring on its first boot, so if it fails to boot or self-confirm, the bootloader automatically reverts to the image you swapped from. The confirm dialog says as much. (The one caveat is not to swap twice before a boot completes — an image still pending verification blocks a further swap — but since the board confirms itself within a second or two of booting, letting it come up between swaps avoids this entirely.)

Version strings shown here come from the image's embedded esp_app_desc.version, which is stamped from the AntOS version macros (ANTOS_VERSION_* in sys_os.h) via PROJECT_VER at build time. If a build is ever made without that wiring, ESP-IDF falls back to a git describe string (e.g. be5a3d1e-dirty) instead of a clean version — which is a useful signal that an image was built from an uncommitted or inconsistent tree.

Partition sizing

The current DeMon board is a 16 MB flash (ESPTOOLPY_FLASHSIZE=16MB), now running the OTA A/B layout below — two app slots, no storage FAT (canonical file: partitions.csv). It replaced an earlier single-app factory layout: one 8 MB app slot plus a 7.88 MB storage FAT, the app slot itself having been rebalanced 4 MB → 8 MB when the real binary (~4.02 MB) overflowed the original 4 MB slot.

The internal FAT partition is unused, so OTA drops it. init.zip (the ROM-resident command set) is embedded in the app binary via EMBED_FILES — on the P4 the canonical ./drives/init.zip path resolves to that embedded blob, not a file read — and the DBFS database lives on the D: drive (the DBFS master), reached over USB-MSC. Nothing mounts the internal storage FAT. Dropping it costs nothing and frees the whole chip for two big app slots.

That removes the old tension entirely — the two slots no longer compete with a filesystem for space, so the flash just splits evenly in half:

16 MB board (for testing the OTA path on current hardware):

Partition Offset Size
nvs 0x11000 24 KB
phy_init 0x17000 4 KB
otadata 0x18000 8 KB
ota_0 0x20000 7.9375 MB
ota_1 0x810000 7.9375 MB

32 MB final hardware:

Partition Offset Size
nvs 0x11000 24 KB
phy_init 0x17000 4 KB
otadata 0x18000 8 KB
ota_0 0x20000 15.9375 MB
ota_1 0x1010000 15.9375 MB

Both end exactly at the top of flash. Even the 16 MB board gives each slot ~7.9 MB — essentially the same as today's single 8 MB app, with ~3.9 MB of headroom over the ~4.02 MB binary. The 32 MB layout gives ~15.9 MB per slot (~11.9 MB headroom) — enormous runway; you'd have to grow the binary roughly fourfold before a slot got tight. Without a FAT to work around, OTA fits comfortably on either board, so the 16 MB layout is a genuine test bed rather than a throwaway compromise — the mechanism can be proven on current hardware and carried straight to the 32 MB board with only the slot sizes changing.

The first move to an OTA layout needs a conventional flash of the new partition table (you can't OTA your way into an OTA layout). Because no user data lives in internal flash — init.zip rides inside the app image, DBFS is on the D: drive — switching layouts loses nothing that needs re-uploading. After that first setup, all updates are OTA.


FireStorm FPGA — firmware update and core loading

The FireStorm FPGA (GoWin GW5AT-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 OTA 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 (the QSPI register window plus the data-ready interrupt) 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 bootstrap & recovery — the ICP cartridge

OTA has one blind spot: it needs working firmware already running. A factory-blank or bricked P4 can't self-flash. The answer is to route the P4's JTAG/USB data pins to the cartridge port, so an ICP (in-circuit programming) cartridge can flash a blank or bricked board from nothing — bootloader, partition table, and app.

This closes the gap cleanly: the ICP cartridge is both the "flash a blank board" tool and the always-available recovery floor beneath OTA. It also absorbs the one awkward caveat above — the first OTA setup, or any future bootloader/partition change, becomes "plug in the ICP cartridge once" rather than opening the case and clipping onto pads.

The programming interface is USB-Serial-JTAG on cart pins 13/14 — ROM download mode is always available on a blank chip, so esptool-style flashing works from nothing, with no dedicated JTAG signals needed. The remaining cart-port hardware questions — whether the port also brings out EN + a boot strap (to force download mode on a board with working firmware), and the deliberate open-by-design security posture of an external flash/debug port — are settled with the cart-port hardware itself; see ICP — In-Circuit Programmer.

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