GitHub - mindbox77/zxdesk: A GUI operating system for the 48K ZX Spectrum, in Z80 assembly
Pangram verdict · v3.3
We believe that this entire text is AI.
AI likelihood · overall
AIArticle text · 1,777 words · 1 segments analyzed
ZX Desk A graphical desktop for the ZX Spectrum 48K, written in Z80 assembly. Overlapping windows with a z order and focus, pull down menus, a heap, an event queue, a storage layer with swappable backends, dialogues, controls, a notepad, a clock, a calendar, a two pane file manager, and a settings panel that actually changes things. It all fits in 48K on a machine from 1982, and it can drag a window inside a single 69,888 T state frame. It runs on the real thing, not just an emulator. Why Back in the eighties I wanted an Atari ST and couldn't afford one. What I really wanted was GEM: the desktop, the windows, the menu bar that was always there, the feeling that the machine was a place rather than a prompt. I had a Spectrum instead, and I spent a long time wondering how much of that you could do on it. I started writing bits of it, and never finished. So this is that, finished. It isn't a port of GEM and doesn't pretend to be. It's what the idea turns into when you push it up against a 3.5 MHz Z80, 48K of RAM, a one bit display with attribute clash, and a video chip that steals cycles from the CPU while it paints. A lot of the answers turned out to be more interesting than the question, and nearly all of them came from measuring the machine rather than reasoning about it. It's a fun project and a labour of love, and the reason it's written up at this length is that the measurements are the useful bit. If you're building something on this hardware, the numbers below cost me a lot of evenings. They're yours. What it does today Windows Overlapping, z ordered, movable, resizable, with title bar, close box and grip. Focus is the front of the z order, so raising and focusing are one action. Menus A permanent menu bar with pull downs, save under, and hit testing. Input Kempston mouse, Kempston joystick, and the full keyboard matrix decoded across three tables with repeat. All of it arrives as events. Events A sixteen slot ring. The main loop contains no window specific code. Storage A registry of backends behind six vectors. RAM, tape (via the real ROM loader), and the 128K's spare banks as a RAM disk. esxDOS has a reserved id. Memory A real heap with an owner byte, 8,112 bytes, allocating window buffers sized to their windows. Applications A descriptor with init, event and paint, plus per instance state swapped in and out. Notepad, clock, calendar, commander, about. Persistence Settings written to storage with a magic byte and a version, and read back at boot. Screenshots: Two windows, z ordered The notepad, with the Sinclair style shift-reporting cursor Two pane commander, over devices rather than directories Clock and calendar Running it The toolchain is local and small: pasmo 0.5.5, built from source into tools/. ./build.sh assemble src/zxdesk.asm to build/zxdesk.tap ./run.sh build and load onto the machine MACHINE=128 ./run.sh the same on a 128K, and put the setting back esxDOS runs here too, on an emulated DivMMC with a 64MB card image. tools/ isn't in the repository because pasmo, the emulators and the esxDOS ROM aren't mine to redistribute, so you'll need to build the image yourself from an esxDOS release and a DivMMC card image. Once it exists, launch tools/esxdos/esxdos.szx and esxDOS is already resident; Machine > NMI gets you its file browser. Build flags, all passed through --equ: DEMO=1 ./build.sh a self dragging build, for reproducible captures NOWAIT=1 with DEMO, the same drag with no beam scheduler SCRIPT=1 ./build.sh drive the desktop from synthetic input MOUSETEST=1 ./build.sh the raw Kempston mouse diagnostic build.sh must pass --name explicitly, because pasmo takes the tape header name from the output path exactly as written and would otherwise put build/zxde in the header. It also refuses to build a tape if the code has grown into the buffer region, because that overrun is silent otherwise: the first window grab writes over the program and a few seconds later the machine drops into BASIC with an unrelated error. Dragging a window under Fuse needs the space bar, not the mouse button. Fuse for macOS, 1.9.2, stops delivering Kempston mouse movement while a button is held, so the pointer freezes at the moment a drag begins and the window never follows. Point at the title bar, hold SPACE, move, release. The mouse is fine for everything else and the buttons themselves register correctly; it is only movement that stops. This is the emulator, not the desktop, and MOUSETEST=1 is how I proved it: it reads the mouse ports once each with nothing between the port and the screen, and the counters still stand still while a button is down. RiBtn in ReadInput is what makes SPACE work, and it's there so the desktop is usable on a machine with no mouse at all. A real Kempston mouse drags normally. SCRIPT=1 is the one worth knowing about. It drives the desktop from a list of synthetic input events instead of the mouse, so an interaction (open a menu, pick an item, drag the window over another one, type into the field, save) runs the same way every time and can be compared against the last run rather than watched. How it is built From the bottom up. Device layer. DevFillRect, DevFillDesk, DeskFillCol, AddrAt, BlitRect, RectGrab. Everything above works in byte columns and pixel rows and never touches the screen's third and interleave layout directly. This is the boundary a port swaps out, and I drew it on day one for exactly that reason. Frame discipline. The main loop halts on the interrupt, does all pointer work in the top border, waits for the beam if the window moved, redraws, then reads input and dispatches at the end of the frame. Input goes last so that the cost before the beam wait is constant, which is what makes the scheduler exact. Event queue. A sixteen slot ring of four byte events. EvPoll turns raw input into pointer moves, button presses and keys; EvDispatch drains it through a handler table. Hit testing. A five byte row per control, front to back in z order, $FF terminated, refilled from the model before every search so there is no second copy of the window position to go stale. A closed menu gets a height of zero, which can never match, so HitTest has no special case for it. Transient surfaces. A four deep arena, each surface up to 16 by 96, pushed and popped. Two stacks rather than one, because the pixels under a menu are pushed by something that has no panel record at all. Windows. A record swapped into a live copy, the same trick as the panel record, because the window position is referenced ninety three times across six files. The z order is also the paint order reversed and the hit test order. Storage. A registry of backends, each a fourteen byte row of id, capability bits and six entry points. The six operations are hand laid JP instructions whose operands are patched on selection, so dispatch costs ten T states and clobbers no registers. Controls. A panel is a stack of rows and every control is a row, so a row's index is three shifts rather than a search. Four types, of which the useful one is a cycle: a checkbox is a cycle whose limit is two, and a radio group is a cycle whose limit is N. The settings panel, the file list and the save box are all the same code with different tables. The memory map $6000-$727C the slow region: panels, the calendar, the file panels, the desktop setup, the commander $727D-$7FFF free, 3,460 bytes, contended $8000-$B1B7 the fast region: everything else $B1B8-$BCFF free, 2,889 bytes $BD00 stack top $BDBD interrupt handler $BE00-$BEFF interrupt vector table $C000-$C63F the RAM disk, its directory, and the tape buffer $C640-$C74F the live notepad state $C750-$DF4F four transient surfaces $DF50-$FEFF the heap, 8,112 bytes $FF00-$FFFF deliberately unused Two things in that map are worth explaining. The slow region exists because the ULA steals cycles below $8000 while the display is being painted, so code there runs perhaps a third slower. The rule for it is one line: nothing in it may run inside a frame. Nothing there is on the drag path, the pointer path or in the interrupt, and the timings were unchanged to the T state across the move. It starts at $6000 rather than at the top of the system variables because the tape loader indexes the system variable area through IY and the BASIC loader itself lives just above it, and a CODE block that overwrote the program doing the loading would be a novel way to fail. The heap stops a page short of the top of memory rather than at $FFFF. Every walk computes the next block as address plus header plus size, and a block ending at $10000 would wrap to nought and compare as below the base of the heap. Stopping at $FF00 costs 256 bytes of 8,272 and removes the entire class of failure. What was measured This is the part I'd want if I were reading someone else's repo. Every figure below was measured by running the code and timing it against the machine's own clock. None of it comes from counting instructions, and the few claims that are derived say so. The clock you can trust The 48K interrupt period is exactly 69,888 T states and nothing a program does can move it, so it is the only usable clock on the machine. So a timing run syncs on HALT, optionally delays a known number of T states to place the routine at a chosen point in the frame, calls it, then counts turns of a sixteen T loop until the next interrupt. Comparing against an empty calibration run cancels every fixed overhead: cost = (K - K0) * (69888 - 118) - 16 * (C - C0) - delay 118 T is the exact cost of the returning interrupt path, counted instruction by instruction. It is exact rather than estimated because the handler, its variables, the counting loop and the stack all live above $8000, where the ULA never steals a cycle. Only the routine being timed touches contended memory. Measured blind against three delays of known length: nominal measured error 2,599 T 2,592 T −7 T 51,999 T 52,000 T +1 T 103,999 T 103,994 T −5 T Worst error is 7 T in 104,000, or 0.007%. The third