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
fujicardport 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/POKEinto 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 insideRunGame()'s bus loop (src/cartridge.c, alongsidetud_task()). When it sees a new request, it translates it into a FujiBus/SLIP packet (src/fujibus.c, a plain-C port oflib/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-rs232build 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-byteRXwindow and had noNPARAM/ERR/REPLY_CMD/LINK/boot/doorbell fields. If you're porting old client code, re-derive everyCONSTfromfuji_mailbox.hrather than reusing old values.
Handshake
The mailbox is driven by a sequence-number interlock, not a status flag or a request/ack GPIO line:
- Wait for
MAGIC0/MAGIC1to 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 bumpSEQfar faster than the ESP32-S3 enumerates. - Fill in
DEVICE,CMD,NPARAM,PARAM_SIZE[]/PARAM_VAL[]for however many params you're sending,TXLEN_LO/HI, and theTXpayload bytes — in that order,SEQlast. - Poke
SEQtopeek(ACKSEQ) + 1(wrapping, skipping0). This must be derived from the RP2350's own persistedACKSEQ, not a local counter — see the callout below. - Poll
ACKSEQuntil it equals theSEQyou just sent (bounded). Normal transaction timeout on the RP2350 side is 5000 ms; it's extended to 60000 ms specifically forFUJI_DEVICEID_FUJINETFUJICMD_MOUNT_IMAGE, since that command triggers a full ROM push (see below).
- Once
ACKSEQ == SEQ, the reply is ready: checkREPLY_CMD(FUJICMD_ACKvsFUJICMD_NAK), readRXLEN_LO/HI, and readRXLENbytes out ofRX. IfERRis nonzero the RP2350 never got a usable reply from the ESP32-S3 at all (no USB link, timeout, or bad SLIP frame) —REPLY_CMD/RXare 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
SEQmust come fromACKSEQ, not a local variable. A console reset re-zeroes every IntyBASIC/CONFIG variable, but it does not reset the RP2350 —ACKSEQsurvives across Intellivision resets. If you computeSEQfrom a local counter starting at 0, every reset recomputes the exact same first value (0 + 1 = 1), which already matches whateverACKSEQwas left at from the previous run. The RP2350 seesSEQ == ACKSEQand does nothing — no request is ever sent again after the very first boot. Always computeseq = peek(FN_ACKSEQ) + 1(and skip0on wraparound, since0would look pre-acked). This is exactly what~/Workspace/fujinet-config/intv/fujinet.basdoes.
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. EachPARAM_VALslot is 4 bytes wide regardless of the param's declared size; only the lowsizebytes 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-byteAdapterConfigExtendedstruct.
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.cfgsibling pushed first,0= the ROM itself).NETCMD_WRITEon stream 1 fills a.cfgtext buffer; on stream 0 each byte is fed to the ROM decoder directly intocart.ROM[](no staging buffer). Progress is published live viaBOOT_PCTas bytes arrive, clamped to 99 until the transfer is confirmed complete.NETCMD_CLOSEon 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 setsBOOT_STATE = FUJI_BOOT_FAILEDwith aBOOT_ERRcode.
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, andhdr[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.romimages are not handled. -
Flat image — sequential big-endian word packing, mapped by size when no header/
.cfgis present:Size Mapping 4096 / 8192 / 12288 / 16384 one segment → $500024576 $5000,$D00032768 $5000,$D000,$F000anything 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 leavesdata_len == 0, the selector the RP2350 sees is always0(the ROM stream) — no ESP32 code path in this tree ever sends stream1. JLP-over-network and.cfg-driven mappings can't currently be exercised end to end. - A NAK on
NETCMD_CLOSEisn't detected by the ESP32 side.sendCommand()returns aunique_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.romheader decoder is unreachable through this code path, since anything that gets that far is already routed as a flat image by size..romsegment CRC-16 is received but never validated.- Bank-switched
.romimages 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 fromlib/bus/rs232/FujiBusPacket.cpp.pico/intellivision/PROVENANCE.md— upstream Minty/PiRTO II/A8PicoCart lineage and licensing.include/fujiCommandID.h— everyFUJICMD_*/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.