Fujinet Intellivision Mailbox Protocol - FujiNetWIFI/fujinet-firmware GitHub Wiki

Intellivision FujiNet Mailbox Protocol

This page documents the memory-mapped mailbox that lets Intellivision programs talk to FujiNet through the fujicard cartridge, a fork of Minty (itself descended from PiRTO II). It's implemented in pico/intellivision/, specifically pico/intellivision/firmware/, and exercised end-to-end by the fujitest IntyBASIC demo in ~/Workspace/fujinet-config/intv/fujitest.bas (outside this repo).

Status: this protocol was verified end-to-end on the prior PiRTO-II-based prototype. The Minty-based fujicard port described below builds cleanly but has not yet been run on real silicon.

Architecture

Intellivision --CP-1610 bus--> RP2350 (Minty fork, board "fujicard") --USB CDC--> ESP32-S3 fujiversal-rs232
   CONFIG           PEEK/POKE $9C00-$9F3F mailbox              device->host        FujiBus/SLIP
  • The Intellivision side only ever does PEEK/POKE into a RAM window at $9C00-$9F3F. There is no bus master/slave dance on the Intellivision side at all — it's just shared memory.
  • The RP2350 (pico/intellivision/firmware/src/fujinet.c, fuji_mailbox_service()) polls that RAM window every frame from inside RunGame()'s bus loop (src/cartridge.c, alongside tud_task()). When it sees a new request, it translates it into a FujiBus/SLIP packet (src/fujibus.c, a plain-C port of lib/bus/rs232/FujiBusPacket.cpp), sends it to the ESP32-S3 over USB CDC (src/fujibus_usb.c), waits for the reply, and writes the reply back into the mailbox.
  • The ESP32-S3 runs a normal fujiversal-rs232 build of fujinet-firmware and is the USB host side of the link (lib/hardware/ACMChannel.cpp); the RP2350 stays a USB device. As far as the ESP32-S3 is concerned it's just talking RS232/FujiBus to a host — it has no idea an Intellivision is on the other end.

Because the wire protocol between the RP2350 and the ESP32-S3 is byte-for-byte the same FujiBus/SLIP framing used elsewhere in fujinet-firmware, every device and command ID in this doc ($70 = Fuji, $71 = network device N1, FUJICMD_*, NETCMD_*, ...) is the same one used by every other FujiNet-attached platform. This page only documents the mailbox shim that gets an Intellivision program's request onto that bus.

Why the mailbox lives at $9C00, not $9800

Minty's own JLP (Jump Level Programming) emulation claims the whole $8000-$9FFF window as general-purpose RAM/flash-backed storage. To coexist with JLP, the FujiNet mailbox was moved to the top of that window and shrunk from 1536 bytes of payload to 832 words total, leaving $8000-$9BFF (7168 of 8192 words, ~87%) free for JLP when a game uses it.

The RP2350 arbitrates which mapping is active per cartridge in update_ram_window() (src/intellicart.c):

if (cart.JLPAccel || cart.JLPFlash) { ramLo = 0x8000; ramHi = 0x9FFF; ramWindow = true; }
else if (cart.FujiSupport)          { ramLo = FUJI_MB_ADDR_LO; ramHi = FUJI_MB_ADDR_HI; ramWindow = true; }
else                                 { ramWindow = false; }

The CONFIG program's own memory map (built by src/fujiboot.c, mirroring ~/Workspace/fujinet-config/intv/config.cfg) deliberately stops its RAM claim at $9BFF so it never overlaps the mailbox.

The mailbox

$9C00-$9F3F (832 words) is a plain RAM window — no bank switching, no I/O side-effects, just PEEK/POKE. Each cell holds one byte in its low 8 bits; Intellivision writes are truncated to 8 bits by the DWS handler in core1_main(). The RP2350 side treats it as cart.RAM[] starting at index FUJI_MB_BASE = 0x1C00 (cart.RAM[] is indexed by addr - 0x8000). The layout below is the single source of truth in pico/intellivision/firmware/include/fuji_mailbox.h.

Offset (from $9C00) Intellivision addr Name Direction Meaning
$00 $9C00 MAGIC0 RP2350 → Inty 'F' (0x46) once the mailbox is alive
$01 $9C01 MAGIC1 RP2350 → Inty 'N' (0x4E) once the mailbox is alive
$02 $9C02 PROTO_VER RP2350 → Inty Currently 1
$03 $9C03 SEQ Inty → RP2350 Bump (wrapping, skip 0) to start a transaction
$04 $9C04 ACKSEQ RP2350 → Inty Set == SEQ once the reply is ready
$05 $9C05 DEVICE Inty → RP2350 FujiBus device id, e.g. $70, $71, or $FF (DBC)
$06 $9C06 CMD Inty → RP2350 FujiBus command id
$07 $9C07 NPARAM Inty → RP2350 Param count, 0-8
$08-$09 $9C08-$9C09 TXLEN_LO/HI Inty → RP2350 Request payload length, little-endian
$0A $9C0A STATUS RP2350 → Inty Diagnostic only — poll ACKSEQ, not this
$0B $9C0B ERR RP2350 → Inty fb_status_t of the last transaction (link/timeout/framing errors)
$0C-$0D $9C0C-$9C0D RXLEN_LO/HI RP2350 → Inty Reply payload length, little-endian
$0E $9C0E REPLY_CMD RP2350 → Inty FUJICMD_ACK ($06) or FUJICMD_NAK ($15)
$0F $9C0F LINK RP2350 → Inty 1 if tud_cdc_connected() (ESP32-S3 enumerated over USB)
$10-$17 $9C10-$9C17 PARAM_SIZE[0..7] Inty → RP2350 One byte per param: size in bytes (1, 2, or 4)
$18 $9C18 BOOT_STATE RP2350 → Inty FUJI_BOOT_* — see ROM boot
$19 $9C19 BOOT_PCT RP2350 → Inty 0-100, clamped to 99 until the boot completes
$1A $9C1A BOOT_ERR RP2350 → Inty FUJI_BOOT_ERR_* on failure
$1B $9C1B BOOTSEL_DOORBELL Inty → RP2350 Poke $B5 to reboot into PICOBOOT — see below
$20-$3F $9C20-$9C3F PARAM_VAL[0..7] Inty → RP2350 8 slots × 4 bytes each, little-endian
$40-$13F $9C40-$9D3F TX Inty → RP2350 Request payload, up to 256 bytes (FUJI_MB_TX_MAX)
$140-$33F $9D40-$9F3F RX RP2350 → Inty Reply payload, up to 512 bytes (FUJI_MB_RX_MAX)

STATUS values: IDLE=0, BUSY=1, OK=2, ERR=3. It's written for debugging visibility only — the real interlock is SEQ/ACKSEQ, described next.

Note the offsets changed from an earlier PiRTO-II prototype that used a $9800-based, 1536-byte RX window and had no NPARAM/ERR/REPLY_CMD/LINK/boot/doorbell fields. If you're porting old client code, re-derive every CONST from fuji_mailbox.h rather than reusing old values.

Handshake

The mailbox is driven by a sequence-number interlock, not a status flag or a request/ack GPIO line:

  1. Wait for MAGIC0/MAGIC1 to read 'F'/'N' — the RP2350 mailbox is up. The RP2350 itself waits up to 3000 ms for the USB CDC link to come up before servicing its first transaction, since the Intellivision boots and can bump SEQ far faster than the ESP32-S3 enumerates.
  2. Fill in DEVICE, CMD, NPARAM, PARAM_SIZE[]/PARAM_VAL[] for however many params you're sending, TXLEN_LO/HI, and the TX payload bytes — in that order, SEQ last.
  3. Poke SEQ to peek(ACKSEQ) + 1 (wrapping, skipping 0). This must be derived from the RP2350's own persisted ACKSEQ, not a local counter — see the callout below.
  4. Poll ACKSEQ until it equals the SEQ you just sent (bounded). Normal transaction timeout on the RP2350 side is 5000 ms; it's extended to 60000 ms specifically for FUJI_DEVICEID_FUJINET
    • FUJICMD_MOUNT_IMAGE, since that command triggers a full ROM push (see below).
  5. Once ACKSEQ == SEQ, the reply is ready: check REPLY_CMD (FUJICMD_ACK vs FUJICMD_NAK), read RXLEN_LO/HI, and read RXLEN bytes out of RX. If ERR is nonzero the RP2350 never got a usable reply from the ESP32-S3 at all (no USB link, timeout, or bad SLIP frame) — REPLY_CMD/RX are stale/zeroed in that case.

The RP2350 services at most one transaction per SEQ bump, and publishes the result with a single final store, cart.RAM[FUJI_MB_ACKSEQ] = seq;, only once the whole reply is ready. Re-poking SEQ with the value it's already sitting at is a no-op — a timed-out Inty retry can never cause the RP2350 to replay a stale command, but it also means a genuinely new request always needs a genuinely new SEQ value.

Why SEQ must come from ACKSEQ, not a local variable. A console reset re-zeroes every IntyBASIC/CONFIG variable, but it does not reset the RP2350 — ACKSEQ survives across Intellivision resets. If you compute SEQ from a local counter starting at 0, every reset recomputes the exact same first value (0 + 1 = 1), which already matches whatever ACKSEQ was left at from the previous run. The RP2350 sees SEQ == ACKSEQ and does nothing — no request is ever sent again after the very first boot. Always compute seq = peek(FN_ACKSEQ) + 1 (and skip 0 on wraparound, since 0 would look pre-acked). This is exactly what ~/Workspace/fujinet-config/intv/fujinet.bas does.

Params vs. payload

Two different ways to pass data to the RP2350, matching the underlying FujiBus wire format exactly:

  • Params (NPARAM, PARAM_SIZE[], PARAM_VAL[]): small fixed-width values — access modes, lengths, offsets, status request types. Up to 8 of them, each 1/2/4 bytes, little-endian. Each PARAM_VAL slot is 4 bytes wide regardless of the param's declared size; only the low size bytes are read.
  • Payload (TXLEN_LO/HI + TX, RXLEN_LO/HI + RX): variable-length byte data — a URL to open, bytes to write to a socket. Up to 256 bytes on the way in (FUJI_MB_TX_MAX), up to 512 bytes on the way back (FUJI_MB_RX_MAX) — enough for one 256-byte host-slot block or a 240-byte AdapterConfigExtended struct.

Which commands need payload vs. just params is defined by each device's command handler on the ESP32-S3 side (e.g. lib/device/rs232/network.cpp), not by the mailbox itself — the mailbox is a generic transport.

Wire format (RP2350 ↔ ESP32-S3)

The RP2350's src/fujibus.c is a line-for-line plain-C port of lib/bus/rs232/FujiBusPacket.cpp, so the framing is identical to every other RS232/FujiBus link in fujinet-firmware:

  • SLIP framing: END = 0xC0, ESCAPE = 0xDB, ESC_END = 0xDC, ESC_ESC = 0xDD.
  • 6-byte header: device, command, len_lo, len_hi, checksum, first_field_descriptor. The checksum is an 8-bit sum with end-around carry fold, computed with the checksum byte itself zeroed.
  • Field descriptor tables (mirrored exactly from FujiBusPacket.cpp):
    static const uint8_t fb_field_size[8]  = {0, 1, 1, 1, 1, 2, 2, 4};
    static const uint8_t fb_field_count[8] = {0, 1, 2, 3, 4, 1, 2, 1};
    

fujibus_selftest() (invoked only from host_test/test_fujibus.c, not from the board firmware itself) has worked reference vectors, e.g.:

  • GET_ADAPTERCONFIG_EXTENDED: C0 70 C4 06 00 3B 00 C0
  • Bare ACK: C0 70 06 06 00 7C 00 C0; bare NAK: C0 70 15 06 00 8B 00 C0

Transport (src/fujibus_usb.c, fujibus_transact()) returns FB_ENOLINK immediately if tud_cdc_connected() is false, and drains the RX FIFO before each new transaction.

ROM boot over the mailbox

There is no separate "boot" command in the mailbox protocol — FUJICMD_MOUNT_IMAGE on device FUJI_DEVICEID_FUJINET ($70) is forwarded to the ESP32-S3 like any other Fuji command. While that MOUNT_IMAGE transaction is still outstanding, the ESP32-S3 pushes the ROM image to a second device id, FUJI_DEVICEID_DBC ($FF), over the same USB CDC link — see rs232Disk::mountROM() in lib/device/rs232/disk.cpp. Each pushed frame gets its own ACK/NAK (dbc_send_frame() in src/fujinet.c).

  • NETCMD_OPEN (payload byte 0 selects the stream: 1 = a .cfg sibling pushed first, 0 = the ROM itself).
  • NETCMD_WRITE on stream 1 fills a .cfg text buffer; on stream 0 each byte is fed to the ROM decoder directly into cart.ROM[] (no staging buffer). Progress is published live via BOOT_PCT as bytes arrive, clamped to 99 until the transfer is confirmed complete.
  • NETCMD_CLOSE on the ROM stream applies the mapping (see below), then ACKs, resets the cart bus, and restores the mailbox magic bytes. On failure it NAKs and sets BOOT_STATE = FUJI_BOOT_FAILED with a BOOT_ERR code.

BOOT_STATE values: IDLE=0, OPENING=1, XFER=2, MAPPING=3, FAILED=0x80. BOOT_ERR values: 1 header malformed, 2 stream truncated mid-segment, 3 no mapping found for the byte count, 4 a segment would overlap the mailbox window, 5 a .cfg arrived with no parseable mapping line.

Two ROM formats are auto-detected from the first bytes of the stream:

  • Intellicart/CC3 header — detected when hdr[0] ∈ {0xA8, 0x41, 0x61}, hdr[1] > 0, and hdr[1] ^ hdr[2] == 0xFF. Per-segment addressing is read from the header; each segment's trailing 2-byte CRC-16 is read but not validated. Bank-switched .rom images are not handled.

  • Flat image — sequential big-endian word packing, mapped by size when no header/.cfg is present:

    Size Mapping
    4096 / 8192 / 12288 / 16384 one segment → $5000
    24576 $5000, $D000
    32768 $5000, $D000, $F000
    anything else rejected — FUJI_BOOT_ERR_NOMAP

Mapping precedence: a self-describing .rom header wins over a pushed .cfg, which wins over the bare-size table above. Only the .cfg path can enable JLP. Every segment is validated against the mailbox window before being committed (FUJI_BOOT_ERR_MAILBOX on overlap).

BOOTSEL doorbell

Poking $B5 (FUJI_MB_BOOTSEL_MAGIC) into BOOTSEL_DOORBELL ($9C1B) makes the RP2350 call reset_usb_boot() and reboot straight into PICOBOOT over the same internal USB link, so it can be reflashed without a second USB port. This check runs on every call to fuji_mailbox_service() independent of the SEQ/ACKSEQ interlock — it doesn't need (and can't get) a reply, since the board reboots immediately.

Example: GET_ADAPTERCONFIG_EXTENDED (device $70, the Fuji device)

No params, no payload — the whole request is just device + command:

CONST FN_SEQ=$9C03
CONST FN_ACKSEQ=$9C04
CONST FN_DEVICE=$9C05
CONST FN_CMD=$9C06
CONST FN_NPARAM=$9C07
CONST FN_TXLEN_LO=$9C08
CONST FN_TXLEN_HI=$9C09
CONST FN_REPLY_CMD=$9C0E
CONST FN_ERR=$9C0B
CONST FN_RX=$9D40

CONST FUJI_DEVICEID_FUJINET=$70
CONST FUJICMD_GET_ADAPTERCONFIG_EXTENDED=$C4
CONST FUJICMD_ACK=$06

poke (FN_DEVICE),FUJI_DEVICEID_FUJINET
poke (FN_CMD),FUJICMD_GET_ADAPTERCONFIG_EXTENDED
poke (FN_NPARAM),0
poke (FN_TXLEN_LO),0
poke (FN_TXLEN_HI),0

seq=peek(FN_ACKSEQ)+1
if seq=0 then seq=1
poke (FN_SEQ),seq

#t=0
while (peek(FN_ACKSEQ)<>seq) and (#t<900)
    #t=#t+1
    wait
wend
if #t>=900 then
    print at 60,"TIMEOUT - NO FUJINET"
    goto halt
end if

if peek(FN_REPLY_CMD)<>FUJICMD_ACK then
    print at 60,"FUJINET ERROR "
    print at 74,<1>peek(FN_ERR)
    goto halt
end if

' Reply is a 240-byte AdapterConfigExtended struct at FN_RX. E.g. SSID
' starts at offset 0, firmware version at offset 125, local IP at offset
' 140 -- see lib/device/fujiDevice.h in the main tree for the full layout.

For a fuller worked example covering NETCMD_OPEN/WRITE/READ/CLOSE on device $71 (N1), see the CONFIG source in ~/Workspace/fujinet-config/intv/ — the offsets there follow the $9C00-based table above; only the addresses moved relative to the older $9800-based examples that used to live on this page.

Known gaps

These are current, real gaps between the intended protocol and what the code actually does — worth knowing before relying on them:

  • The .cfg/stream-1 push is a dead path today. rs232Disk::mountROM() passes the OPEN stream selector as a FujiBus parameter ((uint16_t) 0), but the RP2350 side reads it from the OPEN payload (req->data[0]). Since the parameter path leaves data_len == 0, the selector the RP2350 sees is always 0 (the ROM stream) — no ESP32 code path in this tree ever sends stream 1. JLP-over-network and .cfg-driven mappings can't currently be exercised end to end.
  • A NAK on NETCMD_CLOSE isn't detected by the ESP32 side. sendCommand() returns a unique_ptr<FujiBusPacket>; the caller only null-checks it. A NAK reply is still a valid, non-null packet, so a failed boot-mapping apply on the RP2350 doesn't currently surface as a failure on the ESP32-S3.
  • mountROM() only fires for 8192/16384/32768-byte images (lib/device/rs232/disk.cpp). The RP2350's own bare-size table also understands 4096/12288/24576, and its Intellicart .rom header decoder is unreachable through this code path, since anything that gets that far is already routed as a flat image by size.
  • .rom segment CRC-16 is received but never validated.
  • Bank-switched .rom images are not supported by the Intellicart-header decoder.

If you see references elsewhere to lib/media/rs232/diskTypeROM.cpp or a push_stream() function for the ROM push — those don't exist in this tree; the real implementation is rs232Disk::mountROM() in lib/device/rs232/disk.cpp (also mirrored for DriveWire in lib/media/drivewire/mediaTypeROM.cpp).

See also

  • FujiCard Hardware Notes — the board this runs on.
  • pico/intellivision/firmware/include/fuji_mailbox.h — canonical mailbox layout.
  • pico/intellivision/firmware/src/fujibus.c/include/fujibus.h — the SLIP/FujiBus codec running on the RP2350, ported line-for-line from lib/bus/rs232/FujiBusPacket.cpp.
  • pico/intellivision/PROVENANCE.md — upstream Minty/PiRTO II/A8PicoCart lineage and licensing.
  • include/fujiCommandID.h — every FUJICMD_*/NETCMD_*/DISKCMD_* id in the codebase.
  • lib/device/rs232/network.cpp — the network device command handlers referenced above.
  • lib/device/rs232/disk.cpp (rs232Disk::mountROM()) — the ROM push implementation.
  • pico/intellivision/README.md — build instructions and architecture notes.