WiFi Card
BitMagic emulates the TL16C2550 UART that the X16’s serial port is built on. There is no emulated modem behind it: the card’s own firmware, ZiModem, is compiled and run as a library, and bytes moving through the UART’s data register are handed to it and back. The modem control and status side of the UART, the part that would carry DTR, RTS, CTS, DSR, DCD and RI, is not emulated.
This currently ships only in the develop release. Turn on bitMagic.debugger.developRelease in Settings to get it.

What it is
- A TL16C2550 UART, mapped at
$9fe0to$9fe7, with 16-byte transmit and receive FIFOs. - Behind that UART, the actual ZiModem firmware, compiled as a library and run on a background thread. It is the same code the real WiFi modem runs, not a reimplementation.
- Because it is the real firmware, the AT command set, dialling, telnet and raw TCP, TLS, the phonebook, and the xmodem, ymodem, zmodem, kermit and punter transfer protocols all work the way they do on hardware.
- Network access uses your host machine’s connection through the firmware’s host layer.
What it isn’t
- It doesn’t emulate the WiFi radio itself. ZiModem’s networking goes straight to your host’s network layer instead, so there’s no access point scan and no signal strength.
- It isn’t both channels. The TL16C2550 is a dual UART: one channel drives ZiModem, the other is wired to the card’s serial port. BitMagic emulates only the ZiModem channel; the serial channel is not implemented.
Only one card can be enabled, even though real hardware can have several. The modem firmware is the real ESP32 sketch, and that code assumes it is the only thing running on the chip: its serial port, GPIO pins, filesystem and logging are all process-global. BitMagic runs it as a library, but the global state is still global, so a second instance would trample the first. Creating the modem is therefore a one-per-process operation.
The UART registers
Register names and bit definitions are from the TL16C2550 datasheet. Several addresses read as one register and write as another, and $9fe0 / $9fe1 change meaning with the divisor latch access bit (DLAB, LCR bit 7).
| Address | DLAB | Read | Write |
|---|---|---|---|
$9fe0 |
0 | RBR, receiver buffer | THR, transmit holding |
$9fe0 |
1 | DLL, divisor latch low | DLL, divisor latch low |
$9fe1 |
0 | IER, interrupt enable | IER, interrupt enable |
$9fe1 |
1 | DLM, divisor latch high | DLM, divisor latch high |
$9fe2 |
x | IIR, interrupt identification | FCR, FIFO control |
$9fe3 |
x | LCR, line control | LCR, line control |
$9fe4 |
x | MCR, modem control | MCR, modem control |
$9fe5 |
x | LSR, line status | ignored (read only) |
$9fe6 |
x | MSR, modem status | ignored (read only) |
$9fe7 |
x | SCR, scratch | SCR, scratch |
RBR / THR ($9fe0, DLAB = 0)
Read pulls the next byte from the 16-byte receive FIFO; write queues a byte in the 16-byte transmit FIFO. Both emulated.
DLL / DLM ($9fe0 and $9fe1, DLAB = 1)
The 16-bit baud divisor, low byte then high byte. Writing either recalculates the byte timing (see Baud rate and throughput). DLL is effectively write-only: a read of $9fe0 returns the receive FIFO, not the divisor. DLM reads back the value written. BitMagic uses divisor = 921600 / baud; the chip’s own formula is XIN / (baud * 16).
IER ($9fe1, DLAB = 0)
| Bit | Function | Emulated |
|---|---|---|
| 0 | Received-data-available interrupt enable | Yes |
| 1 | Transmit-holding-register-empty interrupt enable | Yes |
| 2 | Receiver-line-status interrupt enable | No |
| 3 | Modem-status interrupt enable | No |
| 4 to 7 | Unused | Yes, forced to 0 |
IIR, read ($9fe2)
BitMagic does not implement the datasheet’s encoded identification field. IIR here is a plain bitfield: bit 0 is set while a received-data-available condition is pending, bit 1 while a transmit-holding-register-empty condition is pending, and every other bit stays 0. There is no priority encoding, the datasheet’s active-low “interrupt pending” bit 0 is not honoured, the FIFO-enabled bits 6 and 7 are never set, and the codes for the three unemulated sources do not exist. A driver that reads IIR expecting the standard TL16C2550 layout will misread it.
| IIR here | Condition | Datasheet code |
|---|---|---|
| bit 0 set | Received data available, trigger level reached | 0100 |
| bit 1 set | Transmit holding register empty | 0010 |
| both clear | Nothing pending | 0001 |
FCR, write ($9fe2)
| Bit | Function | Emulated |
|---|---|---|
| 0 | Enable the FIFOs | Yes |
| 1 | Clear the receive FIFO | Yes |
| 2 | Clear the transmit FIFO | Yes |
| 3 | DMA mode select | No |
| 4 to 5 | Reserved | n/a |
| 6 to 7 | Receive FIFO trigger level (1, 4, 8, 14) | Yes |
LCR ($9fe3)
The written byte is stored and reads back, but only bit 7 has any effect.
| Bit | Function | Emulated |
|---|---|---|
| 0 to 1 | Word length, 5 to 8 bits | No |
| 2 | Stop bits | No |
| 3 | Parity enable | No |
| 4 | Even parity select | No |
| 5 | Stick parity | No |
| 6 | Break control | No |
| 7 | Divisor latch access (DLAB) | Yes |
MCR ($9fe4)
Writes are stored with bit 2 forced to 0, but nothing acts on them. Reads return the stored value.
| Bit | Function | Emulated |
|---|---|---|
| 0 | DTR output | No |
| 1 | RTS output | No |
| 2 | OUT1 | No |
| 3 | OUT2, gates the interrupt output | No |
| 4 | Local loopback | No |
| 5 | Auto-flow-control enable | No |
| 6 to 7 | Unused | n/a |
LSR, read ($9fe5)
There is no transmitter shift register, so bit 6 always equals bit 5. On real hardware TEMT lags THRE by up to one character time while the last byte clocks out of the shift register; here they set and clear together.
| Bit | Function | Emulated |
|---|---|---|
| 0 | Data ready | Yes |
| 1 | Overrun error | No |
| 2 | Parity error | No |
| 3 | Framing error | No |
| 4 | Break interrupt | No |
| 5 | Transmit holding register empty (THRE) | Yes |
| 6 | Transmitter empty (TEMT) | Tracks bit 5 |
| 7 | An error is in the receive FIFO | No, always 0 |
MSR, read ($9fe6)
CTS and DSR are held asserted, RI and DCD held clear, regardless of the modem’s real state. The change bits (0 to 3) are cleared on every read, as the datasheet describes, but nothing ever sets them, so MSR reads as $30 at all times.
| Bit | Function | Emulated |
|---|---|---|
| 0 | Change in CTS | No |
| 1 | Change in DSR | No |
| 2 | Trailing edge of RI | No |
| 3 | Change in DCD | No |
| 4 | CTS | Fixed asserted |
| 5 | DSR | Fixed asserted |
| 6 | RI | Fixed clear |
| 7 | DCD | Fixed clear |
SCR ($9fe7)
Scratch byte with no effect on anything else. Holds whatever is written and returns it. Emulated.
Talking to it
The FIFO data path is the part you use day to day. A minimal poll loop:
.const UART_RBR $9fe0
.const UART_THR $9fe0
.const UART_LSR $9fe5
send_byte:
lda UART_LSR
; THRE, transmit holding register empty
and #%0010_0000
beq send_byte
lda #'A'
; queue a byte for the modem
sta UART_THR
read_byte:
lda UART_LSR
; DR, a byte is waiting
and #%0000_0001
beq read_byte
; A holds $41 if the modem echoed the 'A' back
lda UART_RBR
Interrupts
The TL16C2550 has four prioritised interrupt levels, and priority 2 covers two separate conditions. Two of the five are emulated.
| Priority | Source | Shown in IIR as | IER bit | Emulated |
|---|---|---|---|---|
| 1 | Receiver line status (overrun, parity, framing, break) | nothing | 2 | No |
| 2 | Received data available | bit 0 | 0 | Yes |
| 2 | Character time-out | nothing | 0 | No |
| 3 | Transmit holding register empty | bit 1 | 1 | Yes |
| 4 | Modem status (change in CTS, DSR, RI, DCD) | nothing | 3 | No |
IIR is a bitfield, not the datasheet’s encoded identification field: bit 0 for received data available, bit 1 for transmit holding register empty, and nothing else. The three unemulated sources have no representation in it, and there is no priority encoding, no active-low pending bit, and no FIFO-enabled bits. See IIR. IER bits 2 and 3 are stored and read back, but setting them has no effect.
Received data available
Level triggered. It follows the FIFO count against the trigger level set in FCR, so it re-asserts for as long as the FIFO is at or above the trigger, and clears once a read drops the count below it.
Transmit holding register empty
Edge triggered. It fires once when the transmit FIFO drains to empty, or immediately if you enable it while the FIFO is already empty. Acknowledge it by reading IIR or by writing THR.
The IRQ line
IER only gates whether a pending source pulls the CPU’s IRQ line. The matching condition in IIR is set regardless of IER, so IIR can be polled with interrupts disabled.
Baud rate and throughput
The divisor latch is reached through LCR bit 7, with the low byte at $9fe0 and the high byte at $9fe1. The divisor sets the UART’s baud rate. Throughput is that rate divided by the number of bits per word: one start bit, the data bits, an optional parity bit, and the stop bits. The emulator uses that bit count to time how often a byte moves through the FIFO, so a slower rate or a wider word delivers bytes less often.
BitMagic derives the divisor as 921600 / baud. ZiModem starts at 115200, so to match it the UART needs a divisor of 921600 / 115200 = 8:
.const UART_LCR $9fe3
.const UART_DLL $9fe0
.const UART_DLM $9fe1
; LCR: set DLAB, divisor latch now at $9fe0/$9fe1
lda #%1000_0000
sta UART_LCR
; divisor low = 921600 / 115200 = 8
lda #$08
sta UART_DLL
; divisor high = 0
stz UART_DLM
; LCR: clear DLAB, back to normal registers
lda #%0000_0011
sta UART_LCR
Slower rates need a larger divisor: 9600 baud is 921600 / 9600 = 96 ($60, $00), and 1200 baud is 768 ($00, $03).
Two things are not wired up yet: the word format is taken as 8N1 rather than read from LCR, and the rate is never checked against what ZiModem is running at. See Not emulated.
Not emulated
ZiModem only uses the FIFO data path, and that part is complete, so nothing here stops the modem working. The gaps are around it. The register and interrupt tables mark each bit and source; this is what the missing pieces mean in practice.
- Line config drift. The UART’s rate and word format, and the rate ZiModem is running at, are tracked separately and never reconciled. The word format is not read from LCR, so framing is always timed as 8N1. The rates are never compared, so a mismatch that would corrupt data on real hardware passes through clean here. The trap: an AT command can move the modem’s rate to a value your code does not know, and there is no way to resynchronise from the X16 side short of resetting the card. This one should be fixed.
- No modem control or status lines. The whole DTR, RTS, CTS, DSR, DCD and RI plane is absent. MCR writes do nothing; MSR holds CTS and DSR asserted and RI and DCD clear. So dropping DTR to hang up (
AT&Dn) has no effect, DCD never shows whether a call is up, and there is no ring indication. - No hardware flow control. RTS and CTS are not wired to the modem. BitMagic never overruns its own receive FIFO, because it only pulls a byte when there is room, so nothing is lost in normal use. If your code puts the modem into RTS/CTS flow control, the firmware’s send path can stall waiting for a CTS that is never driven.
- No line-status, time-out or modem-status interrupts. Only received-data-available and transmit-holding-empty fire. An IRQ handler that relies on line errors, the receive time-out, or a modem-line change will never run; poll instead.
- Non-standard IIR. IIR is a simple bitfield, bit 0 for received data and bit 1 for transmit empty, not the encoded identification field the datasheet describes. A stock UART driver’s interrupt dispatch, which decodes the priority field and treats bit 0 as active-low, will not work against it.
- No line errors. Overrun, parity, framing and break are never reported in LSR. Inbound overrun cannot happen by design; outbound overrun silently drops the extra byte.
- No transmitter shift register. LSR bit 6 (TEMT) just mirrors bit 5 (THRE). Code that waits on TEMT to know the last byte has left the wire is told so up to one character time early.
- No break signalling. LCR bit 6 does not force a TX break, and a received break is not flagged.
- No loopback. MCR bit 4 does nothing, so a driver’s local self-test will not pass.