Controller interaction and custom menus
Most applications should resolve your-app@match.spx and call connect().
Use this lower-level interface only when your application supplies its own menu.
It uses dedicated controller commands, not spectranext_enginecall() and
not HTTP requests from the Z80. Firmware 1.2 supplies matchmaking; use current
1.2 firmware/FuseX for the string metadata extension described here.
The SDK's <spectranext.h> exposes the commands and packed match_request,
match_peer, and match_snapshot types. There are no C wrappers for individual
matchmaking operations. The only additional C wrapper is the generic string getter.
Read identity and connection strings
int8_t spectranext_get_string(uint8_t string_id, char *out,
uint8_t capacity) __z88dk_callee;
| ID | Constant | Value |
|---|---|---|
| 0 | SPECTRANEXT_STRING_SERIAL | Local canonical serial, 16 hexadecimal digits |
| 1 | SPECTRANEXT_STRING_VERSION | Firmware build version; same value as the existing version getter |
| 2 | SPECTRANEXT_STRING_MATCH_NAME | Saved matchmaking name, or canonical serial if unset |
| 3 | SPECTRANEXT_STRING_MATCH_PEER_SERIAL | Established match's peer ID; canonical 16-digit serial for Spectranext/FuseX |
| 4 | SPECTRANEXT_STRING_MATCH_PEER_NAME | Peer's advertised name, falling back to its ID |
| 5 | SPECTRANEXT_STRING_MATCH_INITIATOR_SERIAL | ID of the device whose selection created the match |
capacity includes the terminating NUL and ranges from 1 to 255. Return is
0 on success or -1 for unknown/unavailable strings or insufficient capacity.
There is no truncation. On failure with a valid nonzero-capacity buffer, the
output is empty. Capacity zero fails without touching output. Local strings work
without matchmaking or networking. Peer and initiator strings require the sole
established match and become unavailable on local close, cancellation, or observed
transport failure. A UDP match remains established until explicitly closed.
Remote disconnection can only invalidate metadata after the device detects it.
Use 17 bytes for a cartridge/FuseX serial, 24 for a saved local name and 32 for version. Compatible API clients can advertise IDs up to 64 characters, and an advertised fallback name may contain separators; 65 bytes safely holds all current peer/initiator strings. FuseX's serial identifies an emulator instance, not hardware.
The match initiator is unrelated to the inner TCP initiator. Both devices receive the same initiator ID, so each can compare it with its own serial. An older API or pre-upgrade session can omit the initiator; handle this value being unavailable. Names are for display and must not be used for identity or role selection. The system menu formats an unset local serial with separators; the string getter's local fallback is canonical, without separators.
ROM and raw controller ABI
Call the public SPECTRANEXT ROM gateway with A=21, C=string_id,
B=capacity, HL=out. Carry clear / A=0 means success; carry set means failure.
The SDK wrapper preserves IX and translates this to 0/-1.
For raw mailbox command 21, write string_id at workspace offset 0 and capacity
at offset 1. Success replaces the workspace with the NUL-terminated value;
status 1 indicates failure and workspace byte 0 is zero. VERSION (ID 1) is
served by the Z80 gateway from its ROM build string, just like GET_VERSION;
it is not available by issuing command 21 directly to the controller mailbox.
Custom-menu lifecycle
Controller commands do not save the screen, border, sound state, or application workspace. A custom menu owns those presentation concerns and restores any state it changes. The built-in ROM module supplies that behavior for ordinary CONNECT.
- Resolve the service hostname with
gethostbyname(). Resolution remembers the service and returns the synthetic198.18.0.1address. Only one pending service and one active match are supported. - Open
socket(AF_INET, SOCK_STREAM, 0)(orSOCK_DGRAMfor application UDP). Do not callconnect(): that would open the system menu. - Ensure Spectranet is paged in for direct memory access. For a host-RAM custom
menu that keeps it mapped, link
libsocket_npandlibspectranet_npand callpagein()before using their ROM calls; page out on exit. The ordinary paged wrappers can page out after a call, so do not assume an earlierpagein()still applies when inspecting cartridge memory. Translate the file descriptor into the hardware socket index, 0–3. - Issue
MATCHMAKING_MATCHwith that index and action 0. Repeat to discover peers and progress connection setup. Keep servicing your own input between calls. - Fetch peer records, draw your menu, and let the user choose a peer. Either endpoint can initiate selection; the other can continue polling.
- When the snapshot reports
MATCH_CONNECTED, usesend/recvon the original descriptor. The controller has already connected it and consumed CON; do not issue another ROM CONNECT. UDP uses the ordinary datagram calls. - Close the descriptor normally when finished. On cancellation or setup error, leave/cancel through MATCH, then close the application's descriptor too.
File descriptor versus hardware index
match_request.socket is not the descriptor returned by socket().
With Spectranet paged in, that descriptor is the low byte of its entry in the
fixed FD map (page $3F). For a descriptor returned by SOCKET:
uint8_t mapped = *(const volatile uint8_t *)(0x3f00u | (uint8_t)fd);
/* Reject closed/virtual entries: ordinary socket banks are $14..$17. */
if (mapped < 0x14 || mapped > 0x17) {
/* Not a live hardware socket. */
}
uint8_t socket_index = mapped - 0x14;
Do not derive the hardware index by subtracting from the descriptor: allocation of virtual or other sockets can make those numbers differ. Keep the descriptor for application I/O and close; use the index only for controller matchmaking.
Mailbox access
Controller page $48 has 2 KiB of executable ROM followed by workspace and two
registers. It can be mapped into page A or B:
| Item | Page A mapping | Page B mapping |
|---|---|---|
| Workspace | $1800..$1FFD | $2800..$2FFD |
| Command | $1FFE | $2FFE |
| Status | $1FFF | $2FFF |
Map page A using the public PUSHPAGEA / POPPAGEA entries ($3E81 / $3E84)
when preserving a module running in page B. Alternatively, host-RAM code can
save v_pgb at $3F07, use setpageb(0x48), then restore the saved page.
The built-in menu uses page A and restores it immediately after each access.
For every command:
- Copy the 29-byte request from private RAM to workspace.
- Write
$FFto status, then write the command number to command. - Restore the previous mapping and continue your application loop.
- Remap to check status.
$FFmeans still working; return to your loop. - Status 0 means copy the 220-byte snapshot to private RAM before issuing any other command. Status 1 is a controller-level failure.
- Restore the mapping on every path.
One command may be outstanding on a mailbox channel. Input and output overlap;
never edit the request in workspace or issue another command before collecting
its reply. Do not issue other SDK/controller operations on the same channel while
it is busy. In particular, read strings before starting a command or after its
reply is collected. The menu uses the normal $48 channel; the optional alternate
controller channel is not part of this matchmaking interface.
Mailbox status 0 means the command executed, not that a match succeeded.
Inspect snapshot stage and error. HTTP and STUN may take time; asynchronous
polling keeps input responsive. MATCH already rate-limits server refreshes; polling
its status does not itself send an HTTP request every frame.
Requests and snapshots
All multi-byte fields are little endian. Structures are packed, with no padding. Strings are bounded and NUL-terminated; zero-initialize requests.
Request: 29 bytes
| Offset | Field | Size |
|---|---|---|
| 0 | socket | u8 hardware index |
| 1 | action | u8, command-specific |
| 2 | index | u8 peer slot |
| 3 | revision | u16 peer-table revision |
| 5 | text | char[24] name or joining code |
Snapshot: 220 bytes
| Offset | Field | Size |
|---|---|---|
| 0 | stage | u8 |
| 1 | count | u8, highest occupied slot plus one |
| 2 | revision | u16 |
| 4 | name | char[24], your effective menu name |
| 28 | code | char[5], your generated code or empty |
| 33 | service | char[65] |
| 98 | error | char[32], empty on no reported error |
| 130 | peer.device | char[65], empty for an unused slot |
| 195 | peer.name | char[24] |
| 219 | peer.requires_code | u8 boolean |
Stages: 0 MATCH_PREPARING, 1 MATCH_WAITING, 2 MATCH_CONNECTING,
3 MATCH_CONNECTED, 4 MATCH_CANCELLED, 5 MATCH_ERROR.
count is not a count of densely packed peers: slots can be empty. Fetch slots
0 through count-1, skip empty peer.device, and show at most nine peers.
peer normally describes the selected peer; GET_PEER substitutes the requested row.
Commands
| Number | Constant | Request / effect |
|---|---|---|
| 16 | SPECTRANEXT_CMD_MATCHMAKING_MATCH | action 0 starts or polls; 1 cancels; 2 leaves/releases |
| 17 | SPECTRANEXT_CMD_MATCHMAKING_GET_PEER | Read index from the table identified by revision |
| 18 | SPECTRANEXT_CMD_MATCHMAKING_CONNECT | Choose index at revision; text contains its joining code, or empty |
| 19 | SPECTRANEXT_CMD_MATCHMAKING_SET_NAME | Save text as your matchmaking name, at most 23 printable ASCII characters |
| 20 | SPECTRANEXT_CMD_MATCHMAKING_CODE | action 1 enables a generated code; action 0 disables it |
Begin with MATCH before SET_NAME/CODE; both settings operations apply while
preparing/waiting. Names are trimmed; blank restores the serial fallback and is
persisted. The code is controller-generated, shown in snapshot code, and lasts
only for that invocation. You cannot set an arbitrary local code.
Keep each displayed table's revision with its rows. GET_PEER and CONNECT reject
stale revisions with Peer list changed; refresh the table instead of guessing
which device now occupies the slot. Wrong joining codes report Wrong code and
allow another attempt. Preserve a private snapshot while an input editor is open.
Continue MATCH while connecting. The controller owns STUN, HTTPS and transport readiness; do not perform those operations yourself. Once connected, stop polling matchmaking and use the socket. MATCH cancellation/leave releases the transport, including a connected one: it is not a way to dismiss a successful custom menu. Simply stop drawing your menu and return to the application on MATCH_CONNECTED.
ROM buffers and SOCKET protocol selection
Calls which map cartridge page B cannot read/write an application buffer in
$2000..$2FFF: the mapping replaces it. Keep complete buffers, strings and
structures in host RAM ($4000 and above is the simplest choice), including
buffers supplied by overlays. Code and stack must also survive any page switching.
The new string getter rejects page-B overlap and address wrap before writing;
capacity zero does not dereference its pointer.
Direct ROM SOCKET takes C=socket type and DE=protocol. For streams,
D=$FF activates the backend selector in E. Pass DE=0 for ordinary TCP;
$FFF0 selects TLS, $FFF1 SSH and $FFF2 GRE/TCP. Never leave DE holding a
previous pointer: a stack buffer at $FFxx accidentally activates the selector.
The SDK's socket(domain, type, protocol) wrappers already load DE from the
protocol argument, so socket(AF_INET, SOCK_STREAM, 0) is safe. This is separate
from matchmaking: normal TCP sockets are upgraded automatically after pairing.
For same-router peers, prefer your application's DIRECT mode with a LAN IP. Public matchmaking currently has no LAN candidate discovery or relay fallback.