An interactive program launcher for the Sinclair ZX81 fitted with an OpenSpand expansion.
Browse the SD card, walk into subfolders, and launch .p programs with a joystick
or the keyboard — with a live date/time from the OpenSpand real‑time clock and a
per‑game joystick configuration that's applied automatically when you launch.
A companion osos‑esp32 ESP32‑S2 bridge sits
on the OpenSpand serial header and gives the launcher WiFi: upload programs from a
browser, browse the web (B), download games from an online library (L), and
auto‑update OSOS itself from this repo's GitHub releases (U). A built‑in file
manager (F) moves, renames, and creates folders on the SD card.
OPENSPAND OS 2026-05-30 14:23:55
DIR:/GAMES
--------------------------------
.. CONFIG
>CIV.P C=SET KEYS
CHESS.P S=SERIAL IN
INVADERS.P U=UPDATE OS
--------------------------------
7UP 6DN 5,8PG 0RUN C=KEY Q=X
- Scrollable list of files and folders — more than one screen, up to 250 entries per directory.
- Page up / down for fast travel through long directories.
- Subfolder navigation: enter a folder, select the
..entry to go up. - Live date & time from the OpenSpand RTC.
- Atari‑style joystick (game port) and keyboard control, polled together.
- Per‑game joystick key mapping, saved on the SD card and applied (via the OpenSpand
CONFIGcommand) right before the game loads. - Serial program transfer — send a
.pstraight from a PC (or the osos‑esp32 WiFi bridge) over the serial port; each block is streamed straight to the SD card as it arrives, so any size up to the full ~16 KB transfers. Saved under its real filename (the bridge sends it; a barezxsvrserver falls back toINBOX.P), then launched like any other file. - Only
.pfiles launch — selecting a non‑.p(a MIDI file, a text file…) showsNOT A .P FILEinstead of loading garbage and resetting the machine. - Auto‑update over WiFi — with the osos‑esp32
bridge, press
Uto pull the latestmenu.pfrom this repo's GitHub releases and reboot into it. - Catalog reading, list rendering, and the entire navigation loop run in Z80 machine code — browsing is near‑instant despite BASIC being far too slow on a 3.25 MHz ZX81.
| Action | Key / Joystick |
|---|---|
| Move up / down | 7 / 6 (or stick up / down) |
| Page up / down | 5 / 8 (or stick left / right) |
| Run program / enter folder | 0 (or fire) |
| Up one level | select the .. entry |
| Configure the selected game's joystick keys | J |
Receive a .p over serial |
S |
| Update OSOS over WiFi (osos‑esp32 bridge) | U |
| Browse the web (osos‑esp32 bridge) | B |
| Search + download games from the online library (osos‑esp32 bridge) | L |
| File manager — move / rename / new folder | F |
| Quit launcher | Q |
Works with an Atari‑compatible joystick on the OpenSpand game port, or with the
keyboard. (When the joystick is in CONFIG‑J keyboard‑injection mode it can interfere
with real keyboard reads; the launcher reads the joystick raw, so the controller works
regardless.)
Different games expect different keys. Highlight a .p game and press J — the right
panel prompts you to press a key for each direction in turn (Up, Down, Left, Right,
Fire). Whatever key you press is recorded for that direction, and when all five are set the
mapping is saved as a hidden GAME.C file next to the game.
When you later launch that game, the launcher issues CONFIG "J=…" so the OpenSpand injects
your chosen keys for the joystick — no need to reconfigure the interface by hand. Games
without a .c file launch with whatever joystick config is already set. The .c files are
hidden from the listing.
Pull a .p straight from your PC over the OpenSpand's serial port — handy for testing a
program you just built without shuffling the SD card. OSOS is a zxsvr client
(ZXpand‑Vitamins/serial‑server),
so the PC server just waits and the ZX81 starts the transfer.
- On the PC, start the server pointed at your file (leave it running):
- macOS/Linux:
./zxserver.sh yourprog.p(a tiny Python re‑implementation of the protocol; edit the device path at the top —ls /dev/cu.*on macOS) - Windows: a stock
zxsvr.exeno longer works as‑is (see Length de‑glitch below) — it would need a one‑line patch to reply with the 3×‑redundant length.zxserver.shalready does.
- macOS/Linux:
- On the ZX81, press
S. The screen blanks for ~2 s while it pulls the file inFASTmode. - Each block is streamed straight to the SD card as it arrives (saved as
INBOX.P, or the real filename if the WiFi bridge supplies one), then the launcher re‑lists the folder. Select the file and launch it like any other program. PressSagain any time — the server keeps running for the next transfer.
Because blocks are written to SD as they come in (only a 256‑byte scratch in RAM, never the whole
file), any size .p transfers — up to the full ~16 KB a stock ZX81 can run.
Protocol (zxsvr, ZX81‑initiated pull, 38400 8N1): the ZX81 sends 'I' and the server
replies with the file length; then for each 256‑byte block the ZX81 sends 'T', blockNum, blockLen (blockLen 0 = 256) and the server returns that many data bytes plus a 2‑byte
checksum; finally the ZX81 sends 'X'. Because the data arrives back‑to‑back with no per‑byte
handshake, the receive runs in FAST mode so the full‑speed CPU keeps the OpenSpand's 32‑byte
serial FIFO drained.
Length de‑glitch (V2012): the OpenSpand serial receive side corrupts exactly one byte of a verb's reply (a hardware/timing quirk — always a single byte, never the last; the back‑to‑back
'T'data stream is unaffected). A 2‑byte length can't survive that —16250(0x3F7A) comes back as16374(0x3FF6) and overrunsRAMTOP. So the'I'reply is sent 3× redundant —lo,lo,lo,hi,hi,hi— and the ZX81 takes the majority of each triple; one glitched copy can never out‑vote the two good ones, wherever it lands.Because the glitch is on the ZX81 side, it hits any sender equally, so a stock 2‑byte
zxsvr.exereply is unreliable here and OSOS no longer accepts it (it waits for 6 length bytes). The bundledzxserver.shsends the 6; a realzxsvr.exejust needs its'I'reply changed tolo,lo,lo,hi,hi,hi.
For day‑to‑day use, the osos‑esp32 ESP32‑S2
bridge stays permanently on the OpenSpand serial header and runs the zxsvr server itself,
so no PC script is needed:
- Send a program — upload a
.pin the bridge's web UI, then pressSon the ZX81; it pulls it over WiFi and saves it under its original filename (e.g.CHESS.P). - Auto‑update OSOS — press
U. The ZX81 asks the bridge (zxsvr'U'verb, carrying its own version) whether a newermenu.pis published here on GitHub; if so it pulls it, saves it asMENU.P, andLOADs it — booting the new version. The bridge mirrors the latest release automatically.
The bench zxserver.sh / zxsvr.exe path above still works for a direct PC‑to‑ZX81 link
without the bridge. See the osos‑esp32 repo for
firmware, wiring, and the full serial‑protocol spec.
Any serial operation (S/U/B/L) that gets no reply — e.g. the bridge isn't
connected — aborts instead of hanging: press SPACE to bail immediately, or it times
out on its own and shows NO BRIDGE, returning to the menu.
Bump VER="V…" in build_menu.py, rebuild, commit the new menu.p, then push a matching
tag (git tag -a v1991 && git push origin v1991). The release workflow attaches menu.p +
a generated version.json to the GitHub Release; the bridge mirrors it and the U key
installs it.
A Lynx‑style text web browser: the osos‑esp32
bridge fetches a URL, renders the HTML to ZX81 text (uppercase, 32 columns, numbered (N)
link markers), and the ZX81 displays it. Press B in the launcher to enter it.
| In the browser | Key / Joystick |
|---|---|
| Scroll a page | stick up / down, or SPACE (down) |
| Follow a link | type its number, then ENTER |
| Back (previous page) | stick left |
| Clear the typed number | fire |
| Type a URL | U, then type it and ENTER |
| Exit to the launcher | Q |
The URL editor reads the keyboard matrix (not INKEY$, which the joystick‑injection
mode blocks). Most characters are direct; the URL symbols are shifted: / = SHIFT+V,
: = SHIFT+Z, - = SHIFT+J, ? = SHIFT+C, = = SHIFT+L, and DELETE = SHIFT+0.
You can also set the entry URL from the bridge's web UI. JavaScript‑rendered content can't be
shown (only what's in the served HTML), and non‑ASCII letters (æ ø å …) render as spaces.
Search the ZX81 TOSEC archive and download games straight to the SD card — entirely from
the ZX81, no PC. Press L in the launcher, type part of a title, and ENTER. The
osos‑esp32 bridge searches its catalog and returns
a numbered list; type a number and ENTER and the bridge fetches that game's zip from
archive.org, inflates the .p, and the ZX81 saves it under an 8‑char name and re‑lists the
directory so it's ready to launch.
| In the library | Key / Joystick |
|---|---|
| Type a search title | keyboard (matrix), then ENTER |
| Scroll the results | stick up / down |
| Download a result | type its number, then ENTER |
| New search | U |
| Exit to the launcher | Q |
It reuses the web browser's on‑screen editor and list display, so the same keyboard rules apply (see above). The catalog downloads to the bridge once on first use.
Press F on a selected entry to open the file menu — move, rename, or make a folder,
all on the SD card from the ZX81. A program's hidden per‑game joystick config (.c) is
carried along automatically.
| In the file menu | Key |
|---|---|
| Move the file | M |
| Rename the file | R |
| New folder (in the current directory) | N |
| Back to the launcher | Q |
- Move — pick up / drop: press
M, then navigate into the target folder as usual (0enters folders,..goes up) — the panel shows what you're carrying — and pressMagain to drop it there.Qcancels. - Rename — type a new base name and
ENTER; the original extension is kept (e.g.CHESS.P→CHESS2.P). - New folder — type a name and
ENTER.
These use the OpenSpand firmware's own file operations (f_rename / f_mkdir), so a move is
a single atomic rename — no copy, any file size. Names are letters and digits.
Copy menu.p to the root of your SD card. Rename it to MENU.P to have
OpenSpand auto‑boot it on power‑up.
By default OpenSpand still requires SHIFT to be held down at power‑up for the
auto‑boot to trigger (config byte bit 7). To make it boot straight into OSOS with no
key held down, enter CONFIG "C=7F" at the BASIC prompt — SHIFT+G types the
CONFIG keyword (OpenSpand's overlay ROM replaces the LLIST token with it). This
only applies live; to persist it across power cycles, open OpenSpand's physical config
menu (the config button on the board) and press S to save.
menu.p is generated by build_menu.py, which hand‑assembles the embedded Z80
machine code, resolves all addresses, and emits the complete ZX81 BASIC:
python3 build_menu.py # writes menu.bas
python3 build_menu.py --build # also converts menu.bas -> menu.p
# (macOS, via the VSCode zx81-bastop extension)Or convert menu.bas → menu.p yourself with the
maziac.zx81-bastop
VSCode extension (right‑click the file, or run the build task in .vscode/tasks.json).
The BASIC is written with labels rather than hard line numbers — build_menu.py runs a
small two‑pass pass that assigns line numbers and resolves GOTO/GOSUB targets (the same
trick the Z80 assembler in the file uses for jumps), so code can be reordered freely.
Tunables live at the top of build_menu.py: SW (max name length / list width), MAXE
(max entries per directory), V (visible rows), RPTN (hold‑to‑scroll repeat speed),
HKDIV (idle clock/refresh cadence), PCOL (config‑panel column).
The interesting parts were all dictated by ZX81/OpenSpand quirks:
- Catalog read via the low‑level
PRT3ports. The high‑levelZXPAND "OPE CAT"recurses forever (directory_stat) and hard‑resets the machine, so the launcher drives the directory ports directly from a small Z80 routine in line 1'sREM, one entry at a time. - List drawn straight to the display file (DFILE) in machine code.
PRINT ATcosts ~60 ms per row (≈1 s for a full redraw); writing characters directly to screen memory is near‑instant. - The navigation loop lives in machine code. A
NAVroutine polls the raw joystick and the keyboard matrix, moves the selection, scrolls, redraws, and handles auto‑repeat — all in Z80. BASIC's loop is a thin shell that only reacts to actions (launch / configure / quit), so interpreted‑BASIC overhead is kept off the hot path entirely. - Per‑game config uses plain
SAVE/LOAD+CONFIG. Config files are written/read as raw memory blocks (SAVE ">NAME.C;addr,len"/LOAD "NAME.C;addr"), and applied with theCONFIGcommand — which on OpenSpand is theLLISTtoken, not aZXPAND/LPRINTverb. No machine code needed for any of it. - Everything — Z80 and BASIC — is produced by
build_menu.py.
This project was vibe‑coded: built end‑to‑end through an iterative, conversational
collaboration with an AI assistant (Anthropic's Claude), driven by real hardware
testing and a lot of back‑and‑forth debugging. The OpenSpand command interface and
directory format were reverse‑engineered from the firmware sources and confirmed with
on‑hardware probes, and the ZX81 gotchas (the PRINT AT row limit, the directory_stat
crash, Z80 register‑opcode traps, single‑letter string‑variable names, the floating‑point
cost) were each discovered and worked around along the way.
Huge thanks to Adam Klotblixt (NollKollTroll), creator of OpenSpand — the open‑source, all‑in‑one ZX80/ZX81 expansion this launcher is built for and could not exist without. Project home: https://codeberg.org/NollKollTroll/OpenSpand
The CONFIG/ZXPAND command conventions follow ZXpand (Charlie Robson); the snappy
machine‑code navigation took inspiration from zxpand‑commander.
BASIC tokenizing courtesy of the ZX81 BASIC to P‑File Converter
(maziac.zx81-bastop).
| File | Purpose |
|---|---|
build_menu.py |
Generator — source of truth (Z80 assembler + BASIC emitter) |
menu.bas |
Generated ZX81 BASIC (zxtext2p text format) |
menu.p |
The runnable ZX81 program — copy this to the SD card |
zxserver.sh |
macOS/Linux zxsvr‑protocol server for serial transfer (./zxserver.sh file.p) |
.vscode/tasks.json |
VSCode build task (Build menu.p) |
LICENSE |
GNU GPL v3.0 |
Released under the GNU General Public License v3.0 — the same license as the
OpenSpand firmware — see LICENSE.
Copyright (C) 2026 Henrik Jensen
This program is free software: you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software Foundation,
either version 3 of the License, or (at your option) any later version. It is
distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; see the
GNU General Public License for more details.