ZX Spectrum 的图形桌面
摘要
一个用于 ZX Spectrum 48K 的图形桌面环境,使用 Z80 汇编语言编写,具有重叠窗口、菜单和各种应用程序,且这些都适配于 48K 的 RAM。
查看缓存全文
缓存时间: 2026/09/19 15:27
mindbox77/zxdesk
来源:https://github.com/mindbox77/zxdesk
ZX Desk
一个用Z80汇编编写的ZX Spectrum 48K图形桌面环境。
它具备带z序和焦点的重叠窗口、下拉菜单、堆、事件队列、可更换后端的存储层、对话框、控件、记事本、时钟、日历、双窗格文件管理器,以及一个能真正改变设置的设置面板。这一切都装在1982年的机器上的48K内存里,并且可以在一个69,888 T状态的帧内拖动一个窗口。
它能在真实硬件上运行,而不仅仅是模拟器。
桌面环境
为什么
在八十年代,我想要一台Atari ST却买不起。 我真正想要的是 GEM(https://en.wikipedia.org/wiki/GEM_(desktop_environment)):那桌面、那窗口、那始终存在的菜单栏、那种机器是一个空间而非一个提示符的感觉。我有的是一台Spectrum,我花了很长时间琢磨能在它上面实现多少这样的功能。我开始写一些片段,但从未完成。
所以,这就是那个完成了的项目。它不是GEM的移植版,也不假装是。它是当这个想法被推到3.5 MHz Z80、48KB RAM、一个具有属性冲突的单色显示器和一个在绘制时窃取CPU周期的视频芯片上时,所变成的样子。许多答案最终比问题本身更有趣,而且几乎所有的答案都来自对机器的实测,而非理论推导。
这是一个充满乐趣的项目,也是一份热爱的结晶,之所以如此详细地记录下来,是因为这些测量结果才是有价值的部分。如果你正在这种硬件上构建东西,下面的数字花了我许多个夜晚。现在它们归你了。
它今天能做什么
| 窗口 | 重叠的、有z序、可移动、可调整大小,带标题栏、关闭框和拖动条。焦点就是z序的前端,因此提升和聚焦是一个动作。 |
| 菜单 | 一个带有下拉菜单、保存在后台(save under)和命中测试的永久菜单栏。 |
| 输入 | Kempston鼠标、Kempston操纵杆,以及解码到三个查找表中支持重复的全键盘矩阵。所有输入都作为事件到达。 |
| 事件 | 一个16槽的环形缓冲区。主循环不包含任何窗口特定代码。 |
| 存储 | 一个后端注册表,有六个向量。RAM、磁带(通过真实ROM加载器)以及128K的备用bank作为RAM磁盘。esxDOS(https://esxdos.org/)有一个保留的ID。 |
| 内存 | 一个带所有者字节的真实堆,8112字节,分配的窗口缓冲区大小与其窗口匹配。 |
| 应用程序 | 一个带有初始化、事件和绘制函数的描述符,加上可交换进换出的实例状态。记事本、时钟、日历、命令管理器、关于。 |
| 持久化 | 设置通过一个魔数和版本号写入存储,并在启动时读回。 |
截图:
| 两个窗口 | 记事本 |
| 两个窗口,z序排列 | 记事本,带有Sinclair风格的shift报告光标 |
| 命令管理器 | 时钟和日历 |
| 双窗格命令管理器,覆盖在设备而非目录上 | 时钟和日历 |
运行它
工具链是本地化的小工具:pasmo(https://pasmo.speccy.org/)0.5.5,从源代码构建到tools/目录。
./build.sh 将 src/zxdesk.asm 汇编到 build/zxdesk.tap
./run.sh 构建并加载到机器上
MACHINE=128 ./run.sh 在128K机器上执行相同操作,并恢复设置
esxDOS也可以在这里运行,在一个模拟的DivMMC上,使用64MB的卡镜像。tools/目录不在仓库中,因为pasmo、模拟器和esxDOS ROM不是我可以重新分发的,所以你需要自己从esxDOS发行版和DivMMC卡镜像构建镜像。一旦镜像存在,启动tools/esxdos/esxdos.szx,esxDOS就已常驻内存;Machine > NMI可以调出它的文件浏览器。
构建标志,全部通过--equ传递:
DEMO=1 ./build.sh 一个可自拖拽的构建版本,用于可重现的录制
NOWAIT=1 与DEMO一起使用,相同的拖拽但没有光束调度器
SCRIPT=1 ./build.sh 通过合成输入驱动桌面
MOUSETEST=1 ./build.sh 原始Kempston鼠标诊断
build.sh必须显式传递--name,因为pasmo完全按照输出路径获取磁带头名称,否则会将build/zxde放入磁带头。如果代码增长进入缓冲区,它也会拒绝构建磁带,因为那种溢出是静默的:第一个窗口抓取会覆盖程序,几秒后机器会进入BASIC并显示一个无关的错误。
在Fuse下拖动窗口需要空格键,而不是鼠标按钮。 macOS版的Fuse 1.9.2,在按住按钮时停止发送Kempston鼠标移动,所以指针在拖动开始的那一刻就冻结了,窗口永远不会跟随。指向标题栏,按住空格键,移动,释放。鼠标在其他方面工作正常,按钮本身也正确注册;只是移动停止了。
这是模拟器的问题,而不是桌面的问题,而MOUSETEST=1就是我证明这一点的方法:它在端口和屏幕之间没有任何东西的情况下,每次读取鼠标端口一次,即使在按钮被按下时计数器仍然保持不动。ReadInput中的RiBtn让空格键可以工作,它存在是为了让桌面在没有鼠标的机器上也能使用。真实的Kempston鼠标可以正常拖动。
SCRIPT=1是值得了解的一个选项。它通过合成输入事件列表驱动桌面,而不是鼠标,因此一个交互(打开菜单、选择项、将窗口拖到另一个窗口上、在字段中输入、保存)每次运行方式都相同,可以与上次运行进行比较,而不是需要人工观察。
它是如何构建的
自底向上。
设备层。 DevFillRect、DevFillDesk、DeskFillCol、AddrAt、BlitRect、RectGrab。其上的所有层都以字节列和像素行工作,从不直接接触屏幕的三分之一和交错布局。这是移植时替换的边界,我第一天就画出了这个边界,原因正在于此。
帧规则。 主循环在中断时暂停,在顶部边框内完成所有指针工作,如果窗口移动了则等待光束,然后重绘,最后在帧末尾读取输入并分派。输入放在最后是为了确保等待光束前的成本恒定,这正是调度器精确的原因。
事件队列。 一个16槽的环,每个事件4字节。EvPoll将原始输入转换为指针移动、按钮按下和按键;EvDispatch通过处理表排出它。
命中测试。 每个控件一行5字节,从z序的前到后排列,以$FF结束,每次搜索前从模型重新填充,因此不会有第二份窗口位置数据过时。一个关闭的菜单高度为零,永远不会匹配,所以HitTest没有为它设置特殊情况。
临时表面。 一个4层深的区域,每个表面最大16乘96,可压入弹出。使用两个栈而非一个,因为菜单下的像素是由一个根本没有面板记录的东西压入的。
窗口。 一个记录被交换到一个活动副本中,与面板记录相同的技巧,因为窗口位置在六个文件中被引用了93次。z序也是反向的绘制顺序和命中测试顺序。
存储。 一个后端注册表,每个是14字节的一行,包含ID、能力位和六个入口点。六个操作是手工布局的JP指令,其操作数在选择时被修补,因此分派成本是10个T状态且不破坏任何寄存器。
控件。 面板是一行行的栈,每个控件是一行,因此行的索引是三次移位而非一次搜索。四种类型,其中有用的是一个循环:复选框是极限为2的循环,单选组是极限为N的循环。设置面板、文件列表和保存框都是相同的代码配合不同的表。
内存映射
$6000-$727C 慢速区域:面板、日历、文件面板、桌面设置、命令管理器
$727D-$7FFF 空闲,3460字节,竞争内存
$8000-$B1B7 快速区域:其他所有东西
$B1B8-$BCFF 空闲,2889字节
$BD00 栈顶
$BDBD 中断处理程序
$BE00-$BEFF 中断向量表
$C000-$C63F RAM磁盘,其目录,以及磁带缓冲区
$C640-$C74F 活动的记事本状态
$C750-$DF4F 四个临时表面
$DF50-$FEFF 堆,8112字节
$FF00-$FFFF 故意未使用
这个映射中有两点值得解释。
慢速区域存在的原因是因为ULA在显示绘制期间会窃取$8000以下的周期,所以那里的代码运行速度可能慢三分之一。它的规则很简单:里面的内容都不能在帧内运行。那里的东西不在拖动路径、指针路径或中断上,而且在迁移过程中计时到T状态都保持不变。它从$6000开始而不是系统变量区的顶部,因为磁带加载器通过IY索引系统变量区,而BASIC加载器本身就在它上面,一个CODE块如果覆盖了正在加载的程序,那将是一种新颖的失败方式。
堆没有一直延伸到内存顶部的$FFFF,而是在那之前一页就停止了。每次遍历都计算下一个块为地址加头部加大小,一个在$10000结束的块会回绕到零并比较为低于堆基址。在$FF00停止,损失了256字节的8272字节,但消除了整类故障。
测量了什么
如果我在阅读别人的代码库,这部分是我最想看到的。下面的每个数据都是通过运行代码并对照机器自身的时钟计时得到的。没有一个来自计算指令,少数推导出的结论也说明了这一点。
计时,于机器上
可信赖的时钟
48K中断周期精确为69,888 T状态,程序无法改变它,因此它是机器上唯一可用的时钟。所以一次计时运行与HALT同步,可选延迟已知的T状态数以将例程放置在帧中的特定点,调用它,然后计算十六T循环的次数直到下一次中断。与空校准运行进行比较可以抵消所有固定开销:
cost = (K - K0) * (69888 - 118) - 16 * (C - C0) - delay
118 T是返回中断路径的精确成本,逐条指令计数。它是精确的而非估计的,因为处理程序、其变量、计数循环和栈都位于$8000以上,ULA从不窃取周期。只有被计时的例程接触竞争内存。
针对三个已知长度的延迟进行了盲测:
| 名义值 | 测量值 | 误差 |
|---|---|---|
| 2,599 T | 2,592 T | -7 T |
| 51,999 T | 52,000 T | +1 T |
| 103,999 T | 103,994 T | -5 T |
最大误差是104,000中的7 T,即0.007%。第三次跨越了帧边界,这确认了118 T这个数字。
竞争成本是14.7%,而不是50%
项目中的每个预算都始于对屏幕写入的50%惩罚这一悲观估计,这来自民间经验。在帧上扫描1024字节填充:
| 起始点 | 成本 |
|---|---|
| 顶部边框 | 11,744 T |
| 10,399 T | 12,913 T |
| 20,799 T | 13,441 T |
| 31,199 T | 13,473 T |
| 41,599 T | 13,441 T |
| 51,999 T | 12,369 T |
| 底部边框 | 11,745 T |
两个边框数据相差1 T,这是无竞争成本。显示区内的最坏情况是13,473 T。这是14.7%,或者每个竞争字节写入约1.7 T。基于50%数字的预算大约过于保守了三分之一,我的预算也是。
绘制一个窗口的一半成本来自三个短字符串
WinDraw拆分为四个阶段,分别计时。各阶段之和与整体相差330 T以内,这是四次调用和返回对加上16 T的计数粒度。
| 阶段 | 帧顶 | 显示中间 | 占比 |
|---|---|---|---|
| 文本 | 35,872 T | 35,969 T | 50% |
| 边缘 | 19,552 T | 19,841 T | 27% |
| 填充 | 14,688 T | 16,641 T | 21% |
| 关闭框 | 832 T | 849 T | 1% |
| 整体 | 71,274 T | 73,099 T |
标题和正文共27个字符,每个约1,330 T。填充,我原以为是问题所在,只占了五分之一。优化填充只能带来拖动帧几个百分点的提升,而我却要为此花上一周时间。
一个测量错误东西的基准测试
早期的笔记记录了单元感知的推送填充为每字节7.4 T。真实的例程成本是每字节11.5 T,高出54%。基准测试测量的是技术本身;例程承担着基准测试从未支付的每行地址算术。一行183 T的成本中,88 T是推送链实际写入像素,95 T是寄存器交换、地址步进、单元边界测试和循环。
在这台机器上,最快填充方法的一半以上成本不是在写入像素。这可以推广:在这种处理器上,每行开销是需要攻击的东西,而不是每字节吞吐量。
中断是被丢失,而非被推迟
这是最希望其他使用这种硬件的人了解的一点,因为它产生的故障看起来像任何其他东西,唯独不像它的原因。
填充在其整个运行期间保持DI,因为SP穿过屏幕内存后就不再是栈了。公认的观点是这会推迟中断。它不会。Spectrum仅断言INT 32个T状态,然后撤回它,所以一个覆盖这32个T的DI窗口会销毁中断,而不是推迟它。
在理解它之前就观察到了这一点。一个放置在帧中62,399 T处的填充,报告没有跨越帧边界,但它显然跨越了。将其重新计为丢失中断后,得到11,745 T,与在顶部边框中进行的相同填充的11,744 T相比,两者相差1 T,这证实了诊断。
然后它被正确测量了。在一个8x24填充的每条指令之后注入一个中断,在一个填充例程中458个指令边界中有458个拒绝了中断,在另一个例程中752个中有710个。
明显的修复方法无效。 在行之间重新启用中断听起来正确但失败了,因为中断不是待处理的,而是消失了。一个几T宽的EI窗口,每236 T一次,大约每三十行能捕捉到一次。使DI区域短并不等同于使其不存在,而只有不存在才是修复。
有效的方法是欠下最后一次推送。 填充现在全程启用中断运行。DI保护的是SP,所以修复方法是保证两个字节的返回地址总是落在即将被覆盖的地方。SP有两种取值:在矩形内部,其中push写入的内容正是链的下一次push将写入的内容;以及在一行最后一次推送之后的低点,链永远不会返回那里。所以链比行短一次推送,最左边的两个字节被欠下——在一个迭代后支付,那时SP已经移入下一行,再也无法触及它们。
之后:607和904个指令边界中0个销毁了中断,矩形每次字节对字节完全相同,其外部没有任何东西被触及,屏幕校验和在更改前后也没有变化。
| 之前 | 之后 | ||
|---|---|---|---|
| 16x96 矩形填充 | 17,414 T | 22,068 T | 每行 +48 T |
| 16x96 桌面填充 | 25,351 T | 30,125 T | 每行 +50 T |
这是一个真实成本,但值得。拖动路径主要是列填充,它通过HL写入,从未触及SP,也从未面临风险。
被它试图捕捉的东西所捕捉的看门狗
一个帧看门狗在处理程序中计数中断,在主循环中计数帧;差值就是丢弃的帧数。处理程序是棘手的一半,因为它在填充内部触发,而此时的栈不是栈。
第一个版本借用IX并用INC (IX+0)计数,上面的中断扫描在第一次运行时就失败了。那条指令设置标志,而填充在地址步进期间保持一个活动的进位。
相似文章
ZX Spectrum System Tour: Text Mode – Bumbershoot Software
This post explores the ZX Spectrum's text mode from a machine language perspective, covering system organization, display routines, and input handling, as part of a series on programming the classic 8-bit computer.
将我的3D点云渲染器移植到ZX Spectrum 48K上
一位开发者将3D点云渲染器移植到ZX Spectrum 48K上,通过Z80汇编优化达到每秒14帧,并创建了一个预计算版本,运行速度为每秒40帧。
微型模拟器
一个基于网页的经典8位计算机模拟器集合,涵盖Amstrad CPC、ZX Spectrum、Commodore和Robotron等机型,使用Visual 6502和Visual Z80 Remix构建。
Z80 REPL环境
一个用于与Z80微处理器交互的REPL环境,可能用于开发或仿真目的。
Boriel BASIC
Boriel BASIC 是一款现代开源的 BASIC 编译器 SDK,主要为 ZX Spectrum 设计,提供增强功能、整数类型以及内联汇编支持,适用于复古游戏开发。