UnapiNet bridge protocol v2
Status: implemented — this is the reference. This document was agreed
as a proposal before it turned into code; what follows is the protocol
the device and the driver (unapinet.com, Z80) actually speak, and the
golden-byte tests pin every reply struct's layout to it. As promised while it was
under review, the v1 document it replaces is folded in below as a
historical appendix — still the byte-level reference for the
parameter-block layouts, which v2 keeps. Functional testing added one
clause the proposal had left unstated: UDP_OPEN falls back to an
ephemeral port when the host refuses the requested one (see UDP below).
The driver and this device ship from the same source project and are updated together. v1 has four users, all personally reachable; there is no installed base to migrate. v2 is therefore a clean break, with detection designed so that any old/new mismatch fails cleanly at installation time (analysis, and one caveat, under "Migration and failure analysis" below).
Design rules
v1's problems are catalogued at the end of the v1 appendix; they reduce to one sentence: every command grew its own conventions. v2 replaces them with four rules:
- Every reply begins with a status byte.
0x00= success. Any other value is the MSX-UNAPI TCP/IP error code the driver should hand to its caller, verbatim. The device speaks UNAPI error codes natively; the driver stops translating. - An error reply is exactly one byte — the status. No partial payloads: if the status is non-zero, there is nothing else to read.
- Parameter blocks are validated for exact size. Too short, too long,
or inconsistent with a declared payload length →
ERR_INV_PARAM, and the command has no effect. - No valid status byte is 0xFF. Reading the data port past the end
of a reply (or with no reply pending) returns
0xFF— the same value an absent device yields from the bus pull-ups, and never a valid status (success is0x00, and no UNAPI error code is0xFF). A driver reading a status position and seeing0xFFknows it is desynchronized or talking to nothing — never "OK". (v1 returned0x00there, which is indistinguishable from success — Wouter's observation.) Bytes inside a reply are length-delimited and unconstrained: an IP octet, a payload byte or the DNS failed-lookup marker may legitimately be0xFF.
Error codes on the wire
The status byte uses the MSX-UNAPI TCP/IP error space. The device emits this subset (the values match the error table the driver already defines; the device side is what v2 added):
| Code | Name | Emitted when |
|---|---|---|
| 0 | ERR_OK | success |
| 1 | ERR_NOT_IMP | unknown opcode; the ICMP commands on a host without working ICMP (caps bit4 clear) |
| 2 | ERR_NO_NETWORK | the host socket layer refused: socket()/bind()/listen() failure, a synchronous connect() failure, a failed or short sendto() (UDP) |
| 3 | ERR_NO_DATA | UDP_RECV / ICMP_RECV with an empty queue |
| 4 | ERR_INV_PARAM | malformed parameter block (rule 3), empty hostname, one longer than 253 bytes or one with an embedded NUL, a TCP_SEND length beyond MAX_TRANSFER, an undefined flag bit set |
| 5 | ERR_QUERY_EXISTS | DNS_QUERY while a lookup is already running |
| 9 | ERR_NO_FREE_CONN | TCP_OPEN / UDP_OPEN with all handles in use |
| 11 | ERR_NO_CONN | handle out of range (0 included, save the close-all forms of TCP_CLOSE / UDP_CLOSE / TCP_ABORT); or no open connection on it — except TCP_STATE / TCP_RECV, which answer for any in-range handle (see notes) |
| 12 | ERR_CONN_STATE | a connection exists but its state forbids the command — including a TCP send whose socket write fails, which resets the connection and answers 12 |
| 13 | ERR_BUFFER | TCP_SEND with the 64 KiB send queue full (recoverable: retry later; nothing is partially queued) |
Rule 3 applies to every opcode, parameterless ones included: a
malformed or stray parameter block answers {4} regardless of the
command, and the per-command "err" lists below do not repeat that. Where
a list names 4 explicitly, a well-formed block can still be
semantically invalid there. One check precedes rule 3: opcode dispatch —
an unknown opcode answers {1} whether or not stray parameter bytes
accompany it (there is no expected size to validate against). In both
cases the parameter buffer is cleared, as after every command.
Validation order is fixed: form first (rule 3, {4}, together with the
semantic {4}s — an invalid value inside a well-formed block); then
capability ({1}, the ICMP commands); then handle ({11}); then
connection state ({12}, and DNS's busy {5}); then the data check
({3}, the RECV queues). The host operation runs last, so its errors
({2}, {13} — plus the TCP write-failure {12}, the one
post-operation use of that code) imply every earlier check passed. In
the OPEN commands the free-slot check ({9}) precedes the host socket
work ({2}).
The status register is gone — the command port is write-only
The driver's only use of the v1 status register is "did my command produce
a result?" — with rule 1 that information is the reply's first byte, so
the register is redundant as a mechanism, and v2 removes it entirely
rather than keeping a mirror nobody needs (Wouter's suggestion). The
device registers the command port for writes only; reading it decodes to
the open bus (0xFF on a standard MSX), exactly as if no device were
present. The status byte lives in one place — the reply — so there is
nothing to poll, nothing that can disagree with it, and nothing extra to
document. The three v1 situations that raised STATUS_ERROR in the
register become ordinary single-byte error replies (unknown opcode → 1,
empty hostname → 4, DNS busy → 5).
What does not change — and two things that do
Unchanged from v1: ports and decoding (base 0x28: command, base+1:
parameter/result — the command port's read side is gone, see above), the
synchronous transaction model, parameter
accumulation, the parameter-buffer cap, opcode numbers (except 0x10,
retired — below), parameter block layouts, handles (1-based, 1..4, TCP
and UDP independent), handle 0 = close all transient in the CLOSE
commands (v2 extends the same form to TCP_ABORT — see the handles
notes), MAX_TRANSFER/MAX_RECV_BUF/MAX_SEND_BUF, the graceful-close
semantics, and the receive-side backpressure. Byte order stays as it is:
IPs as network-order octets, 16-bit quantities little-endian.
Two v1 statements are explicitly superseded — a reader must NOT carry them over from the v1 document:
- v1's transaction model says reading past the end returns
0x00and gates each command onstatus register == STATUS_DATA (2). In v2, past-end reads return0xFF(rule 4) and the register does not exist — the port reads as open bus (0xFF), so a v1-style== 2gate can never pass. - v1's DNS_QUERY takes a NUL-terminated name (terminator effectively
optional, trailing bytes ignored). v2 drops the terminator entirely:
the parameter block is the hostname. This is the only change at the
block-form level; no other layout moved. (One value check is also
new — TCP_OPEN's undefined flag bits answer
{4}where v1 ignored them. And one value gains meaning: v1 destroyed a datagram unread on UDP_RECV maxlen 0; v2 makes that TCPIP_UDP_RCV's deliberate DE=0 discard, reporting the source and size while copying nothing.)
The recovery rule is kept and completed: writing a parameter byte while a result is pending discards the unread result (as in v1), and writing a command opcode while a result is pending abandons it too — the new reply replaces it. A driver never has to drain a reply it has lost interest in.
DETECT (0x00) — replaces PING and QUERY_CAP
Wouter suggested merging the two, and the merge solves three things at once: the confusing "PING" name (it is not ICMP), the version byte nobody reads, and the magic bump v2 needs anyway.
DETECT, opcode 0x00, no parameters. Success reply, 5 bytes:
| Offset | Value |
|---|---|
| 0 | status 0x00 |
| 1 | magic 0x55 (was 0xAB in v1) |
| 2 | protocol version: 2 |
| 3 | capabilities: bit0 DNS, bit1 TCP active, bit2 TCP passive, bit3 UDP, bit4 ICMP, rest reserved 0 |
| 4 | reserved 0 |
The driver accepts the device if and only if bytes 0-2 are exactly
00 55 02. Bytes 3-4 are data, not part of the acceptance rule: a driver
must tolerate unknown capability bits (and any value in byte 4), or it
will reject future devices for advertising more. Byte 2 is the upgrade
path: a hypothetical v3 reports 3 there, a v2 driver rejects it cleanly,
and a v3 driver may accept both if v3 is a superset.
Capabilities are honest and enforced: bit4 (ICMP) is set only when the
host can actually ping — platform support compiled in and the ICMP
channel opening successfully at device start (today that means: Windows,
and IcmpCreateFile() succeeded); it is latched once at start. If bit4
is clear, the ICMP commands answer ERR_NOT_IMP. Bits 0-3 are always set
on this device today (byte 3 reads 0x0F or 0x1F); they are informational,
and no command is gated on them.
One corner is worth a recipe: a crashed predecessor may have left stray
parameter bytes buffered, and a strict DETECT would then answer {4} —
yet DETECT is also the stream-resynchronization command, so it must work
from any state. Wouter's resolution: keep DETECT strict, and have the
driver issue it twice unconditionally, reading only the second
reply. The first either succeeds and is abandoned (issuing a command
discards a pending result — the recovery rule) or answers {4} and
clears the stray block; either way the second DETECT runs on a clean
buffer. No conditional on either side.
Opcode 0x10 (QUERY_CAP) is retired and answers ERR_NOT_IMP like any
other unknown opcode.
Command reference (v2 replies)
Parameter layouts are unchanged from v1 (see the v1 appendix for byte
detail); this table gives the v2 reply, status byte included. Where a row
shows {0, Struct}, the v2 wire struct is the v1 struct with the status
byte prepended as its new first member — sizes grow by one, and the
count shown already includes it — except ICMP_RECV, where the status
replaces v1's hasData marker byte (still 12). "err:" lists the
codes each command can emit besides 0 and besides the universal {4} of
rule 3.
| Op | Name | Success reply (bytes) | err |
|---|---|---|---|
| 0x00 | DETECT | {0, 0x55, 2, caps, 0} (5) | — |
| 0x01 | DNS_QUERY | {0, 0} async started, or {0, 1, ip4} (6) resolved immediately | 4, 5 |
| 0x02 | DNS_STATUS | {0, state, ...} — see DNS below | — |
| 0x03 | TCP_OPEN | {0, handle} (2) | 2, 4, 9 |
| 0x04 | TCP_SEND | {0} (1) | 4, 11, 12, 13 |
| 0x05 | TCP_RECV | {0, len.lo, len.hi, payload...} (3+len) | 11 |
| 0x06 | TCP_CLOSE | {0} (1) | 11 |
| 0x07 | TCP_STATE | {0, TcpStateResult} (13) | 11 |
| 0x08 | TCP_ABORT | {0} (1) | 11 |
| 0x09 | UDP_OPEN | {0, handle} (2) | 2, 9 |
| 0x0A | UDP_CLOSE | {0} (1) | 11 |
| 0x0B | UDP_STATE | {0, size.lo, size.hi} (3) | 11 |
| 0x0C | UDP_SEND | {0} (1) | 2, 11 |
| 0x0D | GET_LOCALIP | {0, ip4} (5) | — |
| 0x0E | NET_STATE | {0, 2} (2) | — |
| 0x0F | UDP_RECV | {0, srcip4, port.2, len.2, payload} (9 + min(len, maxlen)) | 3, 11 |
| 0x11 | ICMP_SEND | {0} (1) | 1 |
| 0x12 | ICMP_RECV | {0, srcip4, ttl, id.2, seq.2, len.2} (12) | 1, 3 |
Handles, slots, and what survives a close
- Handles are 1..4. Handle 0 is out of range (
{11}) everywhere except TCP_CLOSE, UDP_CLOSE and TCP_ABORT, where it means "close (or abort) all transient" — and succeeds ({0}) even when nothing is open. The ABORT form is new in v2: UNAPI defines it (TCPIP_TCP_ABORT, B = 0), but v1 silently no-opped it — the device rejected handle 0 and the driver never looked at the reply, so the caller got ERR_OK with nothing aborted. ERR_NO_CONN(11) means the handle is out of range — or, for the commands that act on a live connection (SEND, CLOSE, ABORT, and all of UDP), that no open connection sits on it. TCP_STATE and TCP_RECV answer for any in-range handle, open or closed: a closed connection keeps its final state, its close reason and its undrained receive data until TCP_OPEN reuses the slot. That is deliberate v1 behavior, kept on purpose — the tail of a connection that died stays readable (TCP_ABORT included), and the driver can still see why it died. What 11 removes is v1's ambiguity: a zeroed struct or a zero length no longer doubles as "bad handle".- A never-used in-range slot answers TCP_STATE with closeReason 1 (NeverUsed) and every other field zero, and TCP_RECV with length 0 — same frame as v1, minus the bad-handle overload.
- Slot allocation: lowest-numbered free slot first. A closed slot holding
a sticky tail is free — it does not count toward
{9}, and reusing it destroys the tail. A slot consumed by a failed TCP_OPEN ({2}) is released, not left in ConnectFailed (that state belongs to the asynchronous connect path only).
TCP
- TCP_SEND may send in Established and CloseWait; any other existing
connection state answers
{12}; no connection at all answers{11}. A length beyondMAX_TRANSFER(4096) answers{4}; length 0 is a valid no-op ({0}).{13}is all-or-nothing: nothing was queued, the same command may be retried verbatim later. A socket write failure resets the connection (closeReason 4, ConnectionReset) and answers{12}. - TCP_RECV:
maxlenis a ceiling request — a value beyond 4096 is clamped, not an error (contrast TCP_SEND, where length states a fact about the payload that follows and must be exact).maxlen0 answers{0, 0, 0}and consumes nothing. Length 0 with a live connection is normal stream behavior; poll TCP_STATE to tell "live but idle" from "finished". - TCP_CLOSE is graceful: FinWait1, FIN once everything queued has
been sent, handle freed when the peer closes too — or after 30 s.
Buffered received data stays readable throughout (and after, per the
sticky-slot rule). Closing a Listen handle is valid and frees it. A
second CLOSE while the close is in progress is idempotent (
{0}). TCP_ABORT drops immediately (closeReason 3); handle 0 aborts every transient connection, mirroring UNAPI's TCPIP_TCP_ABORT. - TCP_OPEN: the
timeoutfield stays in the layout, is accepted with any value, and is ignored (reserved). Undefined flag bits (2-7) must be 0; a set one answers{4}. Remote IP / port are not semantically validated — an active open to 0.0.0.0 or port 0 is handed to the host and fails there ({2}or, asynchronously, ConnectFailed). Passive open: a non-zero remote IP filters the peer; a non-matching peer is accepted and immediately dropped, and the listener keeps listening; on a match the handle becomes the connection (Listen → Established — the listener is gone, one connection per passive open).
UDP
- Every UDP socket is transient. The UNAPI residency concept has no wire representation in UdpOpenParams, so UDP_CLOSE 0 closes every open UDP socket. (The device keeps an internal resident flag for future use; nothing can set it today.)
- UDP_CLOSE discards the socket's queued datagrams — unlike TCP, nothing UDP stays readable after close.
- UDP_SEND: an oversized datagram is not a form error — the host
refuses it and the reply is
{2}(as is a short kernel write). A failed send leaves the socket open and usable. Length 0 sends an empty datagram ({0}). - UDP_RECV: the head datagram is consumed whole.
lenin the reply is the datagram's size as received, and the payload that follows is the firstmin(len, maxlen)bytes — the excess, if any, is discarded with the datagram. This is exactly TCPIP_UDP_RCV's contract: its BC output is "the datagram data size as it was received, which may be larger than the number of bytes actually retrieved", and its DE=0 ("no data will be copied at all") is a deliberate discard — somaxlen0 is valid: header only, datagram gone. Contrast TCP_RECV, wheremaxlen0 consumes nothing — a stream has no unit to destroy, a datagram is all-or-nothing. An empty datagram (0 data bytes) is a normal datagram: UDP makes it visible to the receiver, so it queues and is delivered withlen0. Datagrams were already truncated to 2 KiB at receive time and queue at most 16 deep (v1 behavior, unchanged). - UDP_STATE answers the head datagram's size, which is 0 both for
an empty queue and for an empty datagram at its head — "UDP_RECV until
{3}" is the idiom that tells them apart, and it is TCPIP_UDP_STATE's own advice (its datagram count must not be relied upon). - UDP_OPEN: local port 0xFFFF requests an ephemeral port; local port
0 reaches the host
bind()where it also yields an ephemeral port — the two coincide by different routes. A requested port the host refuses to bind falls back to an ephemeral one too, silently: the host OS itself may own the very port the MSX asks for — Windows' time service holds UDP 123, which is exactly what an SNTP client requests — a collision that cannot exist on the real-hardware UNAPI stacks this bridge stands in for, and a client's local port carries no meaning for the peer. (The first v2 build answered{2}there, in the name of honesty; functional testing vetoed it — SNTP broke on every Windows host whose time service was running — so v1's silent fallback is restored, and this time written down.){2}remains the answer when the ephemeral bind fails as well.
ICMP
- With caps bit4 clear, ICMP_SEND and ICMP_RECV answer
{1}— v1 accepted the request, dropped it silently, and let ICMP_RECV report "no data" forever; the capability bit becomes enforced, not decorative. - The echo-size field keeps v1's clamp to 512. On a capable host a
well-formed ICMP_SEND cannot fail:
{0}acknowledges queueing to the worker, not delivery — an unreachable destination simply never yields a reply. - Replies queue FIFO, at most 16 deep; when the queue is full the oldest reply is dropped — a reply nobody polled for is worth less than the fresh one the current program is waiting for (v1 dropped the newest; that was code, not design). Each successful ICMP_RECV consumes exactly one. The device does not correlate replies with requests — matching identifier/sequence is the driver's job.
Miscellaneous
- GET_LOCALIP answers 0.0.0.0 when the host lookup fails (still a success reply — v1 parity). On a multi-homed host, which address is reported is implementation-defined.
- NET_STATE keeps its constant answer (
{0, 2}); it exists because the driver calls it, and inventing semantics for it is not this redesign's job. - In
UnapiNetWire.hh, every result struct gainsuint8_t status = 0;as its first member — the hardcoding Wouter suggested: the correct value is baked into the type,T{}produces a valid success frame, and the golden-bytes tests verify it like any other field.
DNS
DNS_QUERY — parameters: the hostname itself. The whole parameter
block is the name; there is no terminator (the block length already
delimits it — v1's NUL was redundant, as Wouter noted). The
device applies no syntax or charset rules to the hostname — beyond the
dotted-quad fast path below, the bytes go to the host resolver as-is.
The two bounds are transport, not syntax: a block longer than
253 bytes (the DNS presentation-form name limit) is "too long" under
rule 3 → {4}, and a block with an embedded NUL is malformed →
{4} — the host resolver API speaks C strings and cannot carry the
name past the NUL, so no lookup could ever see it; rejecting the block
beats resolving a silently truncated prefix. A block that
parses as a strict dotted-quad (a.b.c.d, four decimal octets 0-255)
resolves immediately → {0, 1, ip4}, and arms the sticky Complete state
exactly as an asynchronous success does. Anything else starts the
resolver thread → {0, 0}. An empty hostname (an empty block), one
beyond the length cap, or one with an embedded NUL answers {4}; a
lookup already running answers {5}; all of these leave the DNS state
and any running lookup untouched.
DNS_STATUS (no parameters) reports the lookup, and its reply is data,
not a command error — a well-formed DNS_STATUS always succeeds:
| Reply | Meaning |
|---|---|
| {0, 0} | idle — no lookup since reset |
| {0, 1} | in progress |
| {0, 2, ip4} (6) | complete |
| {0, 0xFF, sub} (3) | lookup failed — a success reply carrying bad news; sub = UNAPI DNS_S sub-error (today only 3, "no such host") |
The completed/failed state is sticky until the next DNS_QUERY or a
device reset, so the driver may re-read it freely. Keeping the failure
inside a successful reply preserves rule 2 (command errors are
single-byte) while still carrying the sub-code the driver's callers
display to users.
Reset
A device reset (power-on, MSX reset) restores the ground state: every TCP and UDP connection is closed and freed — resident ones included — all receive buffers, send queues, datagram queues and ICMP replies are discarded, any pending reply and accumulated parameter bytes are dropped, and the DNS state returns to idle. The ICMP capability stays as latched at device start; reset does not re-probe it.
Migration and failure analysis
Both halves ship together; the four v1 users get the new unapinet.com
with the new openMSX build. The interesting cases are the mismatches:
- v1 driver, v2 device. The driver sends opcode 0x00 (its PING) and
expects the register to read
STATUS_DATA(2) and the data port to yield0xAB. On the v2 device the register read hits an unregistered port and returns0xFF, and the data port yields0x00 0x55 .... Both checks fail on the first byte → the driver prints "extension not found" and exits. Nothing misbehaves quietly. - v2 driver, v1 device. DETECT goes out as opcode 0x00, which v1
treats as PING and answers
0xAB. The v2 driver requires the first byte to be0x00—0xABfails immediately. Same clean exit. - No device. Every read returns
0xFFfrom the pull-ups, which v2 defines as never-valid at a status position. (This is why past-end reads also return0xFF: "absent device" and "desynchronized driver" become the same, safe, diagnosis.)
One caveat for completeness: the analysis above covers detection, i.e.
installation time — the only supported pairing path. A resident v1 TSR
carried into a v2 device by other means (a savestate taken on an old
openMSX and restored on a new one) is not served, but it fails safe:
v1's per-call gate is status register == 2, and on a v2 device that
read returns 0xFF from the unregistered port, so every call reports
failure instead of half-working. Re-running the driver's installer is
the answer; the spec simply does not promise more.
How the open points resolved
The proposal closed with three points for review; for the record:
- The magic value (0x55) and the capability bit assignments stood unchallenged, and are final.
- NET_STATE keeps its constant answer. Real semantics — or retirement — remain a question for after v2, deliberately not bundled into this redesign.
- The err column and the validation order became the contract the TSR was written against, as intended, and the golden-byte tests pin the layout of every reply struct (the two-byte replies DNS and NET_STATE assemble inline are covered by the spec table alone). The one place where reality pushed back is recorded above: UDP_OPEN's ephemeral fallback.
Appendix — the v1 protocol (historical)
The v1 document follows — verbatim below its old title, headings demoted one level — as it stood the day v2 replaced it. It stays authoritative for the parameter-block layouts, which v2 did not move (DNS_QUERY's dropped terminator is the one exception), and its closing catalogue of v1 inconsistencies is the list this redesign set out to fix. Where it contradicts v2 — past-end reads, the status register, the detection handshake, the error conventions — everything above supersedes it. Its code references describe the v1-era sources.
This document describes the private wire protocol between the Z80 MSX-UNAPI
TCP/IP driver (unapinet.com) and the UnapiNet device in openMSX. It is
not the MSX-UNAPI specification: UNAPI defines what MSX software sees when
it calls the driver; everything below is how the driver talks to the bridge
behind the scenes. The driver and the device are developed and released
together - the compatibility notes below rely on that - but only the device
lives in the openMSX tree.
The C++ implementation is src/network/UnapiNet.cc; the byte-exact layouts
of every parameter and result block are the structs in
src/network/UnapiNetWire.hh. Where a struct name appears below, that
struct is the layout.
Protocol version described here: v1 (bridge version byte 4, as reported
by QUERY_CAP). Known inconsistencies of v1, and the plan to fix them in
v2, are collected at the end.
I/O ports
The device claims two consecutive I/O ports, base 0x28 (share/extensions/ unapinet.xml, <io base="0x28" num="2"/>). Decoding is on bit 0 of the
port number:
| Port | Write | Read |
|---|---|---|
| 0x28 | command (opcode) | status register |
| 0x29 | parameter byte | result byte (consuming) |
Transaction model
Every operation is one synchronous transaction:
- The MSX writes the parameter block, one byte at a time, to port 0x29.
- The MSX writes the command opcode to port 0x28. The device processes the command inside that I/O write: by the time the OUT instruction completes, the result is ready. The MSX never has to wait or poll for command completion. (Operations that take real time - DNS lookups, connection establishment, ICMP echo - return immediately and are polled with their own dedicated commands; see "Asynchronous operations".)
- The MSX reads the status register (port 0x28).
0x02(STATUS_DATA) means the result is ready to be read. - The MSX reads the result, one byte per IN, from port 0x29. Each read consumes one byte; reading past the end returns 0x00.
Bookkeeping rules, all enforced by the device:
- The parameter buffer is always cleared after a command executes, whether it succeeded or not.
- Writing a parameter byte while a result is still pending discards the unread result (a new transaction implicitly abandons the old one). This is the recovery path if the MSX program is interrupted mid-read: the next transaction starts clean.
- The parameter buffer is capped (64 KiB + 16). Excess bytes are dropped; the command's own size check then reports an error. A runaway MSX program cannot exhaust host memory.
Status register values
| Value | Name | Meaning |
|---|---|---|
| 0x00 | STATUS_OK | idle; no result pending |
| 0x01 | STATUS_ERROR | protocol-level error (see below) |
| 0x02 | STATUS_DATA | a result is pending on the data port |
STATUS_ERROR is raised in exactly three situations: unknown opcode,
DNS_QUERY with an empty host name, and DNS_QUERY while a lookup is
already running. Every other error is reported inside a normal result
block. In practice the driver only ever performs one check on this
register: "did the command I just issued produce a result?"
(status == STATUS_DATA).
Detection handshake
The driver detects the device at installation time:
PING(0x00, no parameters) must answerSTATUS_DATAwith the single magic byte 0xAB. Anything else means the extension is not present.QUERY_CAP(0x10, no parameters) answers two bytes: a capability summary (0x0F = PING + DNS + TCP + UDP) and the bridge version (4).
v1 quirk: the current driver only performs step 1; nothing reads the version byte today.
Data types
- IPv4 address - 4 bytes, the octets of
a.b.c.din order (network/big-endian;Endian::UA_B32in the structs). - port / length / id / seq - 2 bytes, low byte first (little-endian,
Z80-natural;
Endian::UA_L16). - handle - 1 byte. TCP and UDP handles are independent ranges, both
1-based on the wire: 1..4 (
MAX_TCP=MAX_UDP= 4). In results, handle 0 means "could not open".
Command reference
| Op | Name | Parameters (struct) | Result (struct) |
|---|---|---|---|
| 0x00 | PING | none | 1 byte: 0xAB |
| 0x01 | DNS_QUERY | hostname bytes, NUL-terminated | see "Asynchronous operations" |
| 0x02 | DNS_STATUS | none | see "Asynchronous operations" |
| 0x03 | TCP_OPEN | TcpOpenParams (11) | 1 byte: handle, 0 = failed |
| 0x04 | TCP_SEND | TcpSendParamHeader (3) + payload | 1 byte: 0 = accepted, 1 = error, 2 = send buffer full |
| 0x05 | TCP_RECV | TcpRecvParams (3) | TcpRecvResultHeader (2) + payload |
| 0x06 | TCP_CLOSE | 1 byte: handle (0 = all transient) | 1 byte: 0 = OK, 1 = error |
| 0x07 | TCP_STATE | 1 byte: handle | TcpStateResult (12) |
| 0x08 | TCP_ABORT | 1 byte: handle | 1 byte: 0 = OK, 1 = error |
| 0x09 | UDP_OPEN | UdpOpenParams (2) | 1 byte: handle, 0 = failed |
| 0x0A | UDP_CLOSE | 1 byte: handle (0 = all transient) | 1 byte: 0 = OK, 1 = error |
| 0x0B | UDP_STATE | 1 byte: handle | UdpStateResult (2) |
| 0x0C | UDP_SEND | UdpSendParamHeader (9) + payload | 1 byte: 0 = OK, 1 = error |
| 0x0D | GET_LOCALIP | none | 4 bytes: host IPv4 |
| 0x0E | NET_STATE | none | 1 byte: always 2 ("open") |
| 0x0F | UDP_RECV | UdpRecvParams (3) | UdpRecvResultHeader (8) + payload |
| 0x10 | QUERY_CAP | none | 2 bytes: caps (0x0F), version (4) |
| 0x11 | ICMP_SEND | IcmpSendParams (11) | 1 byte: 0 = queued, 1 = error |
| 0x12 | ICMP_RECV | none | 1 byte: 0 = none, or IcmpRecvResult (12) |
Any other opcode raises STATUS_ERROR.
TCP
TCP_OPEN - TcpOpenParams: remote IP (4), remote port (2), local
port (2), timeout (2, reserved: never read), flags (1; bit 0 = passive
open, bit 1 = resident). An active open starts a non-blocking connect()
and returns the handle immediately; the driver polls TCP_STATE until the
state leaves SynSent. A passive open (bit 0) listens on the local port;
remote IP, if non-zero, filters which peer may connect.
TCP_SEND - header {handle (1), length (2)} followed by exactly length
payload bytes. The driver keeps to 4096 bytes (MAX_TRANSFER) per command
by convention, but the bridge does not enforce that bound on the send side
(a v1 quirk; only TCP_RECV clamps to it). Result 2 ("send buffer full") is
a recoverable condition: the bridge queues up to 64 KiB (MAX_SEND_BUF)
that the kernel has not yet accepted, and the driver should retry later.
Result 1 is a real failure (bad handle/state/length, or a connection error
while queueing).
TCP_RECV - {handle (1), maxlen (2)}. Answers {actualLen (2)} followed
by actualLen bytes taken from the connection's receive buffer, at most
4096 per command. actualLen 0 means "nothing buffered right now" - poll
TCP_STATE to distinguish an empty buffer on a live connection from a
finished one. v1 quirk: a bad handle also answers actualLen 0.
TCP_STATE - answers TcpStateResult: state (1), bytes available to
read (2), close reason (1), remote IP (4), remote port (2), local
port (2). An invalid or missing handle answers all-zero fields with
closeReason = 1 (NeverUsed).
State values (TCP-standard names): 0 Closed, 1 Listen, 2 SynSent, 3 SynRecv, 4 Established, 5 FinWait1, 6 FinWait2, 7 CloseWait, 8 Closing, 9 LastAck, 10 TimeWait.
Close reasons: 0 None, 1 NeverUsed, 2 ClosedByUser, 3 Aborted, 4 ConnectionReset, 6 ConnectFailed.
TCP_CLOSE vs TCP_ABORT - CLOSE is graceful: the connection enters FinWait1, the FIN goes out once everything the MSX queued has actually been sent, and the handle is freed when the peer closes too - or after a 30 s timeout, so a silent peer cannot pin a handle forever. Received data still buffered stays readable meanwhile. ABORT drops the connection immediately. Handle 0 in TCP_CLOSE (and UDP_CLOSE) closes every open non-resident connection at once - the driver uses it on program exit; resident connections (TCP_OPEN flags bit 1) survive it.
UDP
UDP_OPEN - local port (2), 0xFFFF = ephemeral. UDP_STATE answers
the size of the first queued datagram (2 bytes; 0 = queue empty, or bad
handle - same v1 quirk as TCP_RECV). UDP_SEND - header {handle (1),
destination IP (4), destination port (2), length (2)} + payload; result 1
also covers a short kernel write. UDP_RECV - {handle (1), maxlen (2)};
answers {source IP (4), source port (2), actualLen (2)} + payload.
Datagram semantics apply: if the first queued datagram is larger than
maxlen, the excess of that datagram is discarded.
ICMP
ICMP_SEND - {destination IP (4), TTL (1), identifier (2), sequence (2), size (2, clamped to 512)}. No payload follows: the bridge fabricates the echo data itself. The request is queued to a worker thread; the result byte only acknowledges the queueing. ICMP_RECV polls for replies: 1 byte 0 = none yet, otherwise {1, source IP (4), TTL (1), identifier (2), sequence (2), dataLen (2)} - header only, the echo payload is not returned. Current limitation: the ICMP worker is implemented with the Windows ICMP API; on other host platforms ICMP_RECV never reports data.
Asynchronous operations
DNS. DNS_QUERY takes the hostname as its parameter block,
NUL-terminated (the terminator and anything after it are ignored). If the
name parses as a dotted-quad IP it answers immediately with 5 bytes
{1, ip}. Otherwise it starts a resolver thread and answers a single byte 0
("started"); the driver then polls DNS_STATUS, which answers one byte
0/1 (idle / in progress), 5 bytes {2, ip} on success, or 2 bytes
{0xFF, 3} on failure (the only failure reported is "no such host", UNAPI
error 3). The completed status is sticky: it stays readable until the next
DNS_QUERY or a device reset. Issuing DNS_QUERY while a lookup is
running raises STATUS_ERROR without disturbing the running lookup.
TCP connect is polled via TCP_STATE (SynSent → Established or
Closed + ConnectFailed).
Receive path. A background thread moves incoming TCP data into a
per-connection buffer of up to 64 KiB (MAX_RECV_BUF), from which
TCP_RECV reads. When the buffer is full the bridge stops reading from
the socket, the kernel buffer fills, and TCP flow control throttles the
peer - no TCP data is ever dropped. UDP is best-effort, as datagrams are:
they are read into a 2 KiB buffer (larger ones are truncated at receive
time) and queued per socket up to 16 deep; beyond that, new arrivals are
read and discarded.
Known v1 inconsistencies, and the v2 plan
These grew one command at a time and are preserved here so the next reader does not have to rediscover them:
- Three error conventions coexist. OPEN commands answer a handle
where 0 = failure; most other commands answer 0 = OK / 1 = error (and
TCP_SEND adds 2 = buffer full);
STATUS_ERRORcovers only the three protocol-level cases listed above. - "No data" and "bad handle" are indistinguishable in TCP_RECV, UDP_STATE and UDP_RECV.
- Parameter blocks are validated as "at least this long", not exactly; trailing garbage is silently accepted.
QUERY_CAPis implemented but never issued by the driver, so the version byte is dead on the wire.NET_STATEanswers a constant.TcpOpenParams.timeoutis never read.MAX_TRANSFERis enforced on TCP_RECV but not on TCP_SEND - the 4096-byte bound on sends is a driver-side convention the bridge silently trusts.
The planned v2, coordinated with the driver (both halves ship from the
same repository): every reply starts with a status byte (0 = OK, otherwise
the UNAPI error code the driver should return, ERR_BUFFER included);
handles stop doubling as error codes; parameter blocks are validated for
exact size; the PING magic is bumped so an old driver fails detection
cleanly instead of misbehaving quietly; and the driver reads QUERY_CAP
for real. With a status byte on every reply the status register becomes
redundant (the driver's only use of it is the == STATUS_DATA check after
each command); whether to repurpose or retire it is an open design point.