Skip to main content

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;
IDConstantValue
0SPECTRANEXT_STRING_SERIALLocal canonical serial, 16 hexadecimal digits
1SPECTRANEXT_STRING_VERSIONFirmware build version; same value as the existing version getter
2SPECTRANEXT_STRING_MATCH_NAMESaved matchmaking name, or canonical serial if unset
3SPECTRANEXT_STRING_MATCH_PEER_SERIALEstablished match's peer ID; canonical 16-digit serial for Spectranext/FuseX
4SPECTRANEXT_STRING_MATCH_PEER_NAMEPeer's advertised name, falling back to its ID
5SPECTRANEXT_STRING_MATCH_INITIATOR_SERIALID 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.

  1. Resolve the service hostname with gethostbyname(). Resolution remembers the service and returns the synthetic 198.18.0.1 address. Only one pending service and one active match are supported.
  2. Open socket(AF_INET, SOCK_STREAM, 0) (or SOCK_DGRAM for application UDP). Do not call connect(): that would open the system menu.
  3. Ensure Spectranet is paged in for direct memory access. For a host-RAM custom menu that keeps it mapped, link libsocket_np and libspectranet_np and call pagein() before using their ROM calls; page out on exit. The ordinary paged wrappers can page out after a call, so do not assume an earlier pagein() still applies when inspecting cartridge memory. Translate the file descriptor into the hardware socket index, 0–3.
  4. Issue MATCHMAKING_MATCH with that index and action 0. Repeat to discover peers and progress connection setup. Keep servicing your own input between calls.
  5. Fetch peer records, draw your menu, and let the user choose a peer. Either endpoint can initiate selection; the other can continue polling.
  6. When the snapshot reports MATCH_CONNECTED, use send/recv on the original descriptor. The controller has already connected it and consumed CON; do not issue another ROM CONNECT. UDP uses the ordinary datagram calls.
  7. 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:

ItemPage A mappingPage 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:

  1. Copy the 29-byte request from private RAM to workspace.
  2. Write $FF to status, then write the command number to command.
  3. Restore the previous mapping and continue your application loop.
  4. Remap to check status. $FF means still working; return to your loop.
  5. Status 0 means copy the 220-byte snapshot to private RAM before issuing any other command. Status 1 is a controller-level failure.
  6. 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​

OffsetFieldSize
0socketu8 hardware index
1actionu8, command-specific
2indexu8 peer slot
3revisionu16 peer-table revision
5textchar[24] name or joining code

Snapshot: 220 bytes​

OffsetFieldSize
0stageu8
1countu8, highest occupied slot plus one
2revisionu16
4namechar[24], your effective menu name
28codechar[5], your generated code or empty
33servicechar[65]
98errorchar[32], empty on no reported error
130peer.devicechar[65], empty for an unused slot
195peer.namechar[24]
219peer.requires_codeu8 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​

NumberConstantRequest / effect
16SPECTRANEXT_CMD_MATCHMAKING_MATCHaction 0 starts or polls; 1 cancels; 2 leaves/releases
17SPECTRANEXT_CMD_MATCHMAKING_GET_PEERRead index from the table identified by revision
18SPECTRANEXT_CMD_MATCHMAKING_CONNECTChoose index at revision; text contains its joining code, or empty
19SPECTRANEXT_CMD_MATCHMAKING_SET_NAMESave text as your matchmaking name, at most 23 printable ASCII characters
20SPECTRANEXT_CMD_MATCHMAKING_CODEaction 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.