A graphical desktop for the ZX Spectrum
A graphical desktop for the ZX Spectrum
ZX Spectrum 的图形化桌面系统
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.
ZX Desk 是一个为 ZX Spectrum 48K 编写的图形化桌面系统,完全使用 Z80 汇编语言编写。它具备带有 Z 轴顺序(z-order)和焦点管理的重叠窗口、下拉菜单、堆内存管理、事件队列、支持可切换后端的存储层、对话框、控件、记事本、时钟、日历、双栏文件管理器以及真正可生效的设置面板。所有这些功能都塞进了 1982 年机器的 48K 内存中,并且可以在单个 69,888 T 状态的帧内完成窗口拖拽。它不仅能在模拟器上运行,还能在真实的硬件上运行。
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.
为什么要做这个?八十年代时,我想要一台 Atari ST 但买不起。我真正想要的是 GEM:那个桌面、窗口、常驻的菜单栏,以及那种“机器是一个空间而非仅仅是一个提示符”的感觉。但我当时只有一台 Spectrum,我花了很多时间思考在它上面能实现多少类似的功能。我开始编写其中的一部分,但从未完成。所以,这就是那个“完成版”。它不是 GEM 的移植,也不假装是。它是当你把这个想法强加给 3.5 MHz 的 Z80、48K 内存、带有属性冲突(attribute clash)的单色显示器以及在绘图时会窃取 CPU 周期的视频芯片时,所能演化出的样子。很多答案比问题本身更有趣,而且几乎所有答案都来自于对机器的实际测量,而非理论推导。这是一个充满乐趣的爱心项目,我之所以写下这么长的文档,是因为这些测量数据才是最有价值的部分。如果你正在这套硬件上开发东西,下面的这些数据是我耗费无数个夜晚换来的,现在它们属于你了。
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.
-
窗口:支持重叠、Z 轴排序、移动、缩放,带有标题栏、关闭按钮和拖拽手柄。焦点位于 Z 轴顺序的最前端,因此置顶和获取焦点是一个动作。
-
菜单:带有下拉功能的常驻菜单栏,支持“保存下方内容”(save under)和点击测试。
-
输入:支持 Kempston 鼠标、Kempston 摇杆,以及通过三张表解码并支持按键重复的全键盘矩阵。所有输入均以事件形式处理。
-
事件:一个 16 槽的环形队列。主循环不包含任何特定于窗口的代码。
-
存储:一个位于六个向量之后的后端注册表。支持 RAM、磁带(通过真实的 ROM 加载器)以及将 128K 的额外内存库作为 RAM 磁盘使用。esxDOS 拥有预留 ID。
-
内存:一个带有所有者字节的真实堆内存,共 8,112 字节,根据窗口大小分配缓冲区。
-
应用程序:包含初始化、事件处理和绘图功能的描述符,以及按实例交换的状态。包括记事本、时钟、日历、文件管理器、关于界面。
-
持久化:设置通过魔数(magic byte)和版本号写入存储,并在启动时读取。
Running it
运行方式
The toolchain is local and small: pasmo 0.5.5, built from source into tools/. 工具链简单且本地化:使用 pasmo 0.5.5,从源码构建到 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. 在 128K 机型上运行同样的操作,并恢复设置。esxDOS 也可以在模拟的 DivMMC 上运行,配合 64MB 的卡镜像。tools/ 目录不在仓库中,因为 pasmo、模拟器和 esxDOS ROM 不属于我,无法重新分发,因此你需要从 esxDOS 发行版和 DivMMC 卡镜像中自行构建镜像。
Once it exists, launch tools/esxdos/esxdos.szx and esxDOS is already resident; Machine > NMI gets you its file browser. 一旦镜像就绪,启动 tools/esxdos/esxdos.szx,esxDOS 就会驻留;通过 Machine > NMI 即可进入其文件浏览器。
Build flags, all passed through —equ:
构建标志(均通过 —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. -
DEMO=1 ./build.sh:自动拖拽构建,用于可复现的录屏。 -
NOWAIT=1:配合 DEMO 使用,进行无光束调度器的拖拽测试。 -
SCRIPT=1 ./build.sh:通过合成输入驱动桌面。 -
MOUSETEST=1 ./build.sh:原始 Kempston 鼠标诊断程序。
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. build.sh 必须显式传递 —name,因为 pasmo 会直接从输出路径获取磁带头名称,否则会将 build/zxde 写入头信息。如果代码增长到缓冲区区域,它也会拒绝构建磁带,因为这种溢出在其他情况下是静默的:第一次窗口抓取会覆盖程序,几秒钟后机器会跳回 BASIC 并报出一个不相关的错误。
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. 在 Fuse 模拟器下拖拽窗口需要使用空格键,而不是鼠标按钮。macOS 版 Fuse 1.9.2 在按住按钮时会停止发送 Kempston 鼠标移动信号,因此指针在拖拽开始时就会冻结,窗口也不会跟随。操作方法是:指向标题栏,按住空格键,移动,然后松开。鼠标在其他操作中表现正常,按钮也能正确识别;只有移动功能会停止。这是模拟器的问题而非桌面系统的问题,我通过 MOUSETEST=1 证明了这一点:它在端口和屏幕之间没有任何干扰的情况下读取鼠标端口,当按钮按下时,计数器依然保持不动。ReadInput 中的 RiBtn 是让空格键生效的原因,它的存在是为了让桌面系统在没有鼠标的机器上也能使用。真实的 Kempston 鼠标拖拽功能是正常的。
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. SCRIPT=1 是一个值得了解的选项。它通过一系列合成输入事件而非鼠标来驱动桌面,因此交互过程(打开菜单、选择项目、将窗口拖到另一个窗口上、在字段中输入、保存)每次运行的方式都完全相同,可以与上一次运行进行对比,而无需人工观察。
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. 自底向上构建。设备层包括 DevFillRect, DevFillDesk, DeskFillCol, AddrAt, BlitRect, RectGrab。上层所有功能均以字节列和像素行为单位工作,从不直接接触屏幕的三段式和交错布局。这是移植时需要替换的边界,我从第一天起就为了这个原因专门设计了它。
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. 事件队列。一个 16 槽的四字节事件环。EvPoll 将原始输入转换为指针移动、按钮按下和按键;EvDispatch 通过处理程序表清空队列。
Hit testing. A five byte row per control, front to back in z order, $FF terminated, refil 点击测试。每个控件占用一行五字节,按 Z 轴顺序从前到后排列,以 $FF 结尾。