Hacker News 中文摘要

RSS订阅

为 ZX Spectrum 编写 Demoscene Intro -- Writing a Demoscene Intro for the ZX Spectrum

80年代后期,我花大量时间在辛克莱ZX频谱机(Sinclair ZX Spectrum)上照着杂志输入辛克莱BASIC(Sinclair BASIC)程序和简陋汇编例程,试图复刻在朋友家看到的阿米加(Amiga)破解演示。它们当然远远比不上,但几十年后我终于做到:亲手写出一个能在原版128Kb Spectrum上运行的复古demoscene开场演示,包含平滑滚动文本(带leet自定义字体)、三通道AY音效、跳动VU电平表和视差星空;目前代码为10287字节,完整磁带映像为19552字节。

正文

Intro

我的 +3 正在运行我写的 intro

80 年代末,我花了不少时间趴在我的 ZX Spectrum 上,敲着杂志上抄来的 Sinclair BASIC 程序和简陋汇编例程,试图重现我在朋友家看到的 Amiga crack intro。显然,当年从没做成哪怕一点点像样的东西——但几十年后的现在,我终于做到了!我现在是一个手工打造的复古 demoscene intro 的骄傲作者,它跑在一台原装 128Kb Spectrum 上。它具备所有 80 年代末的套路元素:平滑的滚动文字(带“leet”自定义字体!)、三通道 AY 音轨、跳动的 VU 表,以及必不可少的视差星空。它目前有 10287 字节代码,或 19552 字节的完整磁带镜像——考虑到我从去年 12 月 13 日就开始搞这个,意味着我平均每周只产出约 455 字节代码!

顺着我的实现过程,接下来会讲讲用到的工具、学到的几个酷技巧,以及一路上(重新)掌握的关于 Speccy 和 Z80 汇编的一切。它既是我最喜欢的业余项目之一,也是最常让我气得差点一脚踢穿显示器的项目。我会在最后反思这些,但现在如果你想直接看成品,下载按钮在文首,附带的 .tap 磁带镜像应该能在任何模拟器里加载。另外还有 YouTube 录像、pouet.net 上的作品页,也可以在我的 TNFS 网站门户里用浏览器运行——选第 4 项看最近上传。

倒带,请温柔对待

先短暂跳回 80 年代末。对一个用 8 位、靠磁带加载的电脑的人来说,第一次看到 Amiga 就像 encounter 了外星科技。和当时所有人一样,我被那些惊人的图形和声音,以及一堆开创性游戏震撼了。但更让我着迷的,是盗版游戏开头那些神秘(而且超酷)的“intro”。

它们由破解并发行软盘游戏的软件盗版组织制作,本身就是迷你艺术品——音乐、图形,通常还有炫技特效来展示组织实力。YouTube 上有一批这类 intro(或叫“cracktros”)的存档。

看几个那种视频,你会很快注意到几乎每个 cracktro 都有的“设计”套路:屏幕顶部一个冷酷、带刺的 logo(如果滴血或任何“金属”味就加分),某种特效上叠着吹嘘文字——通常是滚动的视差星空或旋转 3D 物体——底部还有一条滚动文字,满是 shout outs、给其他化名破解者的问候,以及大量非法 BBS 系统的广告。不用说,对 80/90 年代的小孩来说,这可能是史上最酷的东西。

我第一次看到这个时觉得它绝对炸了。正弦滚动、盗版 BBS 广告、带剑的酷 logo——当然还有星空。这儿什么都有。

这音乐刻进了我脑子里。考虑到 Lemmings 在 90 年代初的流行度,我觉得几乎所有同学都知道这个 intro。Skid Row 作为团体至今仍活跃,这大概是那个时期最典型的 Amiga cracktro。

Endless Piracy。带刺。又一个酷 logo、正弦波滚动、星空和吹嘘文字。经典永不过时。

所有必备元素的又一伟大组合。酷 logo,更酷的文字字体,滚动星空,以及底部跑着的滚动文字。复古玩意儿。

Logo。文字。滚动条。但没有星空——除此之外 Anthrox 在这儿干得挺扎实 :)

demoscene 就是从这些 intro 里长出来的。破解渐渐退居幕后,coder、“graphician”和音乐人一起贡献出完整长度的 production,展示能力、互相比拼、把机器推到极限。凭着巧思和纯粹的“代码即艺术”,scene 活到了今天,有团体做出惊人作品,比如这个和这个——记住,这两个都跑在一台 7Mhz 家用电脑、软驱、1Mb 内存和 1985 年声卡上。

但尽管我钦佩那种技巧和投入,让我一直着迷的始终是早期那些“单屏”滚动文字加星空的 cracktro。它们对我有种神秘感,暗示着我只能从外围体验的 scene 和文化——这正是我当年花那么多小时用 Sinclair BASIC 徒劳模仿、梦想拥有自己的 Amiga 的原因。

回到过去

我后来确实升级到了各种 Amiga 系统,自己也加入了几个“scene”团体,投身各种活动,直到考试和青少年时代结束召唤我。那时我玩过 Z80 代码,也一度懂足够多的 68k 汇编能蒙混过关——部分来自 Amiga 鼎盛期,部分来自大学里几门“嵌入式系统”课,课上我得给一组交通灯或电梯控制器编程。不过因为日常 $DAYJOB 不太需要这些,它基本都从我记忆里褪色了。

TNFS 网站截图

我业余时间一直在做几个项目,其中最喜欢的是我的 TNFS 网站——有点像老式 BBS 系统或 Spectrum 的交互式“网站”,它源自一个看能把 Sinclair BASIC 推到多远的有趣实验。它依赖插在 Spectrum 上的现代网卡,因此我认识了另一位 TNFS 协议爱好者 “Desertkun”。他现在在做一款很棒的 Spectrum WiFi 网卡,还好心寄了几块原型给我测试。

他的硬件和底层编码能力大大激励了我。某次视频通话对比开发环境时,他问我为什么不自己写点 Z80 汇编。一句随口的话,但挑战就这么定下了!我没勇气用汇编写整个网站,但我想可以做个自包含的 intro 来宣传它,模仿早期 “BBStros” 风格,终于也能成为有自己 scene 作品的酷小孩了 :)

我心想,这能有多难?(哦——这是伏笔!)

开发环境

我的开发环境很简单,和开发 TNFS 网站用的是同一套:Makefile 驱动的构建系统,跑 Pasmo 汇编器、我 fork 的 zmakebas 构建 BASIC 文件、FUSE 模拟器做测试。最终在真实硬件上测试时,我把 .tap 文件上传到本地 TNFS 服务器,就能在我的 +3 上运行。

ASM 编辑器截图

还有几个工具值得一提,因为它们起了大作用。一个是查 Z80 指令时发现的超棒网页版 “asm-editor”。这网站不仅有很全的 Z80 指令参考,还带一个编辑器,能跑示例代码、看内存和寄存器怎么变状态。比如 djnz 指令,你可以构建它,然后单步执行示例循环结构。或者试试截图里我写的 add 指令例子,看一个位被移出寄存器时进位标志怎么工作。虽然它语法和 Pasmo 略有不同(所以我不能把代码直接丢进去),但手动单步调试时帮了大忙。

史上最好的调试助手

最后,可能是这个项目里最重要的调试工具——纸笔,以及我的两只狗(跟 Biscuit 和 Pebble 打个招呼……)我会在结尾再写点这个,但这对我来说确实是个极不寻常的项目,毕竟我有几十年高级语言经验。我的大部分 z80 代码都是把字节和位掩码画出来、记寄存器标志表、手画屏幕内存块之后才真正理解的。我发现晚上带两只狗散步正好是理想的“橡皮鸭”调试练习,虽然不确定它们怎么看待我长时间嘟囔 hl 寄存器对。不管怎样,强烈推荐“遛狗调试法”!

起点

好,开始吧。我先照着 futurice.com 上一个很棒的 “Hello, world!” 示例验证构建环境。之后第一件事是加载我打算用作 intro “舞台”的 logo 和背景图。那时我还不太确定要做什么,只想要 logo 在顶部,滚动文字在屏幕底部一行。因为我的 TNFS 网站页头看起来有点 demoscene 味,我快速改了一下,加了两条蓝色参考条,之后在里面放内容。

TNFS 网站截图

Spectrum 的 SCREEN$ 文件(通常当作加载画面)就是屏幕内存的转储,从字节 16384 开始,共 6912 字节。这种奇怪布局后面很关键,但显示它只需要把文件加载到那个内存地址。在复习了 Speccy 内部结构、看了各种指南几小时后,我有了一个非常简单的 demo 起点,成了之后一切的基础。

代码从 org $8000 开始,先把边框设为黑色:ld a,black 然后 out (ula),a。接着用 ld hl,header、ld de,screen、ld bc,scrlen 和 ldir 把 logo 图片拷进屏幕内存,最后用一个 halt 加 jr hold 的忙循环停在那里,并用 incbin 把 assets/header.scr 包含进来。

现在还没什么好看的,但这个简单起点让我认识了几个重要概念:通过 <code>md5-0aca05d5e77da4691097506dc7ea228f</code> 指令和 Spectrum 硬件打交道(先是 ULA 芯片,后来是 AY 音效芯片)、用 Pasmo 的 INCBIN 指令拉入二进制文件、用 Z80 的块拷贝 <code>md5-e6ad81a1982980698fced4e2e18605c3</code> 指令、用寄存器对做 16 位寻址,以及简单跳转到标签。

你好,世界

Hello World 屏幕效果

下一步是在屏幕上打印点东西,并探索 Speccy 内置 ROM 例程,有点像现代操作系统的 syscall。网上有很多 Spectrum ROM 的反汇编和指南,最好、注释最全且链接丰富的一个在 skoolkid.github.io/rom/。照着这个参考和各种 “Hello, world!” 教程,我明白了这类东西是怎么搭起来的:定义 at 控制符(22)和行列坐标,调用 $203C 处的 pr_string 例程,把 DE 指向消息地址、BC 设为长度,然后 call prstring。消息用 db at,row,col 定位,再 db "Hello, world!",长度用 msglen EQU $-msg 算出来。

我最终在代码里基本没用 ROM 例程,因为它们会拖进一堆我不需要的东西,但理解它们怎么工作非常有用,因为它们让我认识了更多常用结构。比如 PR_STRING 例程展示了怎么逐字符循环。刚开始的话,我强烈建议你拿纸笔把那 8 行过一遍,推算寄存器内容、跟随循环结构。

我也在 asm-editor 里做了一个可试的版本。点进去会看到代码已载入网页编辑器,点 Build 再用 Step 单步,虽然不会真的显示文字(我跳过了调用 Spectrum ROM 重启向量的部分),但能看到寄存器状态,例如 BC 随着字符串每个字节处理而递减。

加点风格

问题是 Speccy 内置字体毫无 demoscene 气质。我想用点炫字体,结果超简单——搜到 Jim Blimey 的超棒字体合集,里面有现成汇编文件和说明,很快我就加了这些代码:覆盖 ROM 打印例程读取字体的地址 chars EQU $5C36,把 myfont-256 存进去,之后 ROM 就用它绘制了。附带的 include "textfont.asm" 引入了 jimblimey.com 的 “Cartoon” 字体。

更换字体后的屏幕效果

看吧!这样才有点 elite 味。抱歉,我是说 31337。坐稳了伙计们,我们要离开菜鸟城了……到这时,折腾了一个月左右,我开始对常用写法有了像样理解,觉得这项目也许真能成。问题出现在我开始实现实际 demo 特效时:很快、非常明显地暴露出我对这一切如何运作有多不了解。更别说在这个层面,你非常贴近物理硬件细节,包括硬件设计者在 80 年代初极其有限的规格下必须做的权衡。

该死的屏幕布局

Spectrum 的屏幕内存布局很反人类

第一个问题——大概占了我 90% 痛苦来源——是 Speccy 极其诡异的屏幕布局。不像更现代硬件那样标准位图显示,它分成 3 段,每段有一组交错行(所以如果你不断给屏幕地址加 1,最终不是到下一行,而是往下 8 行,依此类推,直到碰到段底部再绕回起始行下面!)后面跟着一块“属性”字节,决定每个 8x8 块的颜色。属性块导致每个 8x8 字符单元只能有两种颜色(PAPER 和 INK),造成了 Spectrum 游戏那俗气的“颜色冲突”和独特画风,画师尽量待在 8x8 块内。但真正让人脑筋打结的是位图部分的布局。

你可以在游戏从磁带读加载画面时看到这一点:能看到每段逐行交错建立,最后才是颜色等属性(比如闪烁)。Break Into Program 网站有很多细节。为设计者辩护,他们当时的硬件极其有限,而且我不认为 Speccy 当初被当成游戏机。当你开始搬字节时,会意识到这种方式其实很适合显示整齐排在 8x8 字符边界上的文本。比如用这种“打乱”的位序,如果屏幕地址在 hl 寄存器对里,只要增加“高”字节就能快速到达该地址的下一条像素线。还有一堆靠移位就能做的“便宜操作”:

| 操作 | 指令 | | --- | --- | | 右移 8 像素 | inc l | | 左移 8 像素 | dec l | | 下移 1 像素行 | inc h | | 上移 1 像素行 | dec h | | 下移 1 字符行 | l 加 32 |

对于一台大概会花很多时间显示 BASIC 程序列表的机器,这是合理选择——只有当你开始写 demo 或 sprite 例程时,才会觉得真的很怪。这也意味着你很快需要一个把 X,Y 地址转成对应内存地址的例程。L Break Into Program 又有一个有用例程,而且你反汇编几乎任何 Spectrum 游戏或 demo,都会在某个地方找到这过程的版本。是的,这些后面全都变得极其相关……

视频

平心而论,这个演示不算最有意思——但这是我第一次用汇编让东西在 Spectrum 屏幕上动起来!我骄傲地看了好多遍,多得不想承认 :)

我有炫 logo 和 “Hello World” 文字了,但这算不上很有意思的 intro!我想要的第一个特效是滚动文字——没有它就不算真正的 intro。Amiga 有各种硬件 sprite、blitter、定制芯片,但 Spectrum 什么都没有。我只能硬来,“直接敲金属”,字面意思靠手动在屏幕内存里挪位,让 CPU 干所有活。

这就是理解 Speccy 诡异屏幕和字符单元布局如此重要的地方。只要待在同一字符行内,就有个巧妙办法:Z80 有 RL 指令,把字节循环左移。它把进位标志插到最右位,被移出的位又回到进位位。这意味着把一堆 rl 串起来,就能把一组像素移过屏幕——一旦它们“掉出”一个字符单元,就会进入进位标志,然后下一条 rl 指令把它们插到左边下一个字符单元的开头。重复 32 次,你就把一组像素移过了一整行屏幕:先把点形状 %00000001 存到行尾,然后在 frame 循环里 halt 等帧,设 hl 为行尾、b 为 32,用 or a 清进位,接着 rl (hl) 循环左移、dec hl 左移一个字节、djnz 循环 32 次。如果进位被设置说明点刚离开屏幕,跳回开头重新开始,否则继续 jr frame。

外面再包一层每次递增 h 的循环(记住——这能让我们往下走一条扫描线),就能移动整个字符单元。这里能看到它移动我 “Hello world” 消息的整行。但要做出像样的滚动文字,让每个字母平滑出现,还有很多工作。我一开始只是在屏幕右边缘一次打印整个字符——能行,但很难看,因为你会看到每个新字符一下出现在右边,然后滚 8 像素,下一个字符又闪出来。我想要每个字符逐像素出现,这需要更多位操作。我最终的例程直接用字体数据,逐行、逐像素把每个字符“流”进来,而不是调用 ROM 打印字符例程。

它通过查字符的 ASCII 码,取对应字体表示到一个 8 字节缓冲(每行数据 1 字节)。字体数据从空格字符(编码 32)开始,所以查表就是 字体基址 + (ascii码 - 32) * 8 拿到该字母的位。

字体字母 H

排成 8x8 网格,字体数据里字母 H 的位长这样。对每一行位,我用一个巧妙办法左移该行:把字节载入 a 寄存器,然后 add a,a 把值左移(二进制里乘 2 就是所有位左移一位),这样我想画的像素就掉进进位标志。这是 Z80 里惯用做法,比 sla 指令更快更小。每帧重画 256 字节时,每个周期都很宝贵!

字体字母 H(顶行已左移)

字符缓冲看起来像这样,顶行已左移。现在 hl 寄存器对指向屏幕正确位置,我就能用上面讨论的 rl 技巧。它把屏幕底部字符行的一条扫描线左移一像素,并通过从进位标志“移入”把我的像素画到最右位置。循环 32 次就把整行移了 1 像素,新像素插在右边。

字体字母 H(整列左移)

然后我用 inc h 下移一条扫描线,重复这步,直到画完整列字符。此时字符缓冲长这样,底部屏幕行的全部 8 条线都通过遍历 32 个字符列左移了一位。然后整体再重复 7 次,直到整个字符平滑滚上屏幕。

所以我想再强调一遍,因为这点值得强调——仅仅为了把消息滚过屏幕底部,我们每帧都要循环并重画 256 个像素!这显示了汇编比 Spectrum ROM 里的 BASIC 解释器快多少。看到代码终于跑起来、想象那些位飞来飞去、寄存器翻飞,我觉得太神奇了。这也让我真正体会到当年做我玩的那些游戏付出了多少努力。

音乐

视频:我转换的 Amiga 原版模块

我转换的原版 Amiga 模块。一首正宗的 80 年代洗脑曲!

当然,任何好 intro 都需要音乐。在 Amiga 上标准是 “tracker” 模块格式,有漫长复杂的历史,各种分支、版本、调整。一首歌由一系列 pattern 构成,每个 pattern 由多行指令组成。每条指令通常包含音符/音高信息、播哪个采样,以及音量、效果等参数——或者某些指令是跳转到歌曲哪个位置。很多方面,它几乎像一种写音乐的程序语言。以 ProTracker 为例,一个典型音符单元 4 字节,屏幕上显示类似 C-2 01 C20。分三个字段:音符和八度(C-2)、采样号(01)、效果及参数(C20——这里是“把音量设为 $20”,即最大值 $40 的一半)。

ProTracker 音符特写

想看 tracker 能强到什么程度、以及音符数据在屏幕上什么样,可以看看 h0ffman 的作品,特别是他在 Revision 2014 比赛里夺冠的 “Professional Tracker” 参赛作品。他是追踪(tracking)这门科学(或艺术!)的大师,他的 “Secrets of Protracker” 演讲很好概览了可能性。

Amiga 的音频硬件远超当时任何其他机器,ZX Spectrum 相比之下很寒酸。原始 48K 机型只有一个简陋 “beeper”,但后来的 128K 机型包含 3 通道 AY-3-8912 音效芯片。这在当时 8-bit 微机上非常流行,Atari ST 也用了几乎相同的变种。虽然它只提供基础波形和声音处理,但能实现多通道“芯片音乐”,因此成了 ZX Spectrum demoscene 的主力。它也催生了 Spectrum 上各种 SoundTracker 克隆,不过如今大多数音乐人更喜欢用 Vortex Tracker II 这类现代工具,它在 Windows 下原生运行。

转换

视频:Pex 回顾他的 demo 并播放我的翻版

Pex 回顾他的 demo——视频从他展示我的信件并播放一点我的翻奏版开始!

我的 intro 想移植一个 Amiga 模块——而我心里有完美人选。我最喜欢的 Amiga 音乐盘之一是 1989 年瑞典双人组 “Mahoney & Kaktus” 发行的 “Sounds Of Gnome”。Mahoney 还写了 NoiseTracker,Amiga 上最早被广泛使用的 tracker 之一,也是我青少年时用来做电脑音乐的程序。2018 年我曾给这盘里另一首曲子做了摇滚翻唱,并写信给 Mahoney(真名 “Pex”)聊这事。他后来在一个 demo 大会上放了我的“音乐视频”,我永远感激!所以好像很合适,我把这合集里另一首歌也拿来 Spectrum 化……抱歉啦各位!我选了 “Milkman”,由 Mahoney 的伙伴 “Glue Master” 创作,他有几首曲子收在这合辑里。

这是个理想的转换曲目:很短(总共只有 7 个 pattern),大多数 pattern 共用相同和弦进行,而且看起来把原来 4 通道压进 Spectrum 的 3 通道相当容易。另外它极其抓耳,我熟得很——听了 35 年以上,常在吉他或贝斯上跟着 jam。转换开始很简单:我基本只要把 Amiga mod 里的 pattern 逐个音符抄过来,我在 Windows tracker OpenMPT 里打开它。下面是两边 tracker 打开同一 pattern 的截图——左边是 Amiga 原版 mod,右边是我的 3 通道 Spectrum 转换版。

注意 Spectrum 版本第 3 通道的 <code>md5-d9e14fda36790ed0c8950692751dbe17</code>。这是 Vortex Tracker 语法里的“下滑音”效果,把音高降约四分之一音。它在下一行重复,所以整体效果是从引导到下一个贝斯音的 <code>md5-d5a2ab63319d6baaba869744ca5094ba</code> 滑下来。Amiga 原版 mod 用自身语法做同样的事——就是第 4 轨底部那些相同音符上的 <code>md5-76647bc55fbaa852f08836c821454d13</code>。

只用单一 “beep” 声音就足以检查一切是否正常,折腾几周、晚上重排之后,我有个基础版本,作为初稿听起来出奇地不错:

音频:beep 版 Milkman

我必须特别提一下 chipmusic.org 论坛上这篇帖子,它对开始帮助巨大。这可能是我找到最好的 Vortex Tracker 入门介绍——非常感谢原作者花时间写下来!

Vortex Tracker 采样编辑界面

真正的挑战是让它听起来尽可能接近原版。这意味着为不同声部建立更好听的采样库,并大量使用 Vortex Tracker 的“ornaments”。Vortex Tracker 自带很棒的声音选择,而且幸好我是个喜欢在工作时后台听 chiptunes 的怪人!所以每当我发现特别迷人的声音,就存下来留着以后“借鉴”/启发。Vortex Tracker 的采样不是 Amiga 意义上录制声音的“采样”——它是定义一个音符怎么响,每帧一行。每行可以混入音调或白噪声发生器(对军鼓类效果有用),以及各种其他参数。

Ornaments 则让你额外控制音符播放的音高——这实现了一些真正有用的技巧,比如“琶音和弦”。例如 0,4 的 ornament 让通道在播放的音符和高 4 个半音的音符之间快速切换(每秒 25 次)。快到听起来像两个音符同时响,意味着你只用两个通道就能构建饱满的三和弦。以我完整转换版开头和弦 Cm 为例:第一通道弹 C,第二通道弹小三度(D#),然后我给第二通道加个 ornament 把它升高 4 个半音到五度(G)。这样就有了和弦全部三个音,同时第三通道空出来做其他声部。

除了更高效利用有限的 3 通道,这也给了 Spectrum 音乐那种特有的“颤动”声,我觉得复古味很迷人!靠这类技巧,加上把音调和白噪声混合的采样(比如做出“贝斯+军鼓”合声),我把整首曲子压进了 3 通道。许多个月后,我得说我对最终结果非常满意。

播放

Vortex Tracker 原本的官网、文档和资源目前似乎离线了。我从 archive.org 抓到了需要的一切——你会想要 PTxTools.7z 文件里的播放例程。

更新——Hacker News 上有用户补充了有用信息:“Sergey Bulba 的网站还在,相关资料现在可以在 https://ay.strangled.net/progr_e.htm 找到”。谢谢,JdeBP!

播放理论上简单:正如这篇概览指出,你基本只要把播放例程拉进来,包含你的模块文件,然后在主循环里每帧调用一次播放例程。坑在于,压缩包里提供的源码版本没一个能在我的 Pasmo 汇编器上跑。幸好源码旁边有个预编译二进制对象(PTxTools/PT3Play 目录里的 VTII10bG)。它必须加载在地址 $C000——不能改,因为它里面烤进了很多绝对地址。

我的模块播放代码现在大概这样:定义 player EQU $C000、plinit EQU player+3、plplay EQU player+5。先把 hl 指向 moduleimg,调用 plinit 初始化。主循环 hold 里 halt,call plplay,call scroll,然后其它特效,再 jr hold。模块用 incbin "assets/music.pt3" 包含。

星空

这就到了主菜。没个星空,自尊心强的 cracktro 都不完整!

我最初对经典星空特效的天真做法是:把随机星星撒在屏幕上,然后对每条扫描线应用和滚动文字一样的 rl 技巧。我想通过每行移不同量来做视差:第一行 1 像素,第二行 2,第三行 3,再回到 1,一直往下,制造几层不同速度“星场”的印象。

结果这做法有两个大问题。首先,移动整行会把上面原本有的东西连同星星一起拖花——这我预料到了,记下来留给未来的我。更大的问题是速度:等我搞完前三行,已经做了几百次移位操作,根本不够快到能塞进一帧。所有东西都爬过屏幕,音乐播放慢到你能真实听到琶音 ornament 一个个音符颤出来。

显然我得单独追踪每颗星星。我找到这个例程,它基本就是我想的,并让我开始用一个结构保存每颗星状态。它还用了和 “Break Into Program” 类似的屏幕地址查找。

这让东西动起来了,但坐标查找和追踪被证明很贵(每颗星每帧都要跑,还要追踪旧位置来擦除),而且我一直忽略了一个更大问题:怎么在星空上面显示东西。我原本想法是画完星星再每帧把文字盖上去,这跟我的第一次尝试一样彻底爆帧预算。

如果我想要比空星空更有趣的东西,就得追踪哪些单元被文字占据,并避免一开始就在那里画星星,而不是每次覆盖重画。在笔记本上大量涂画、把位行可视化之后,我最终靠相当自由地“作弊”解决了这两个挑战。

所以现在,我根本不生成随机星空。相反,我建了一张预先算好的 Y 和 X 坐标对表,比如用 defb 109, 25, 88, 182 这样的方式定义 32 颗星。这省了折腾 RNG,而且一旦我有看起来不错的布局,就知道它永远好看。我还能“糊弄”一下,确保没有两颗星共享同一 y 坐标,这就绕开了一整类问题,比如星星共享扫描线、以及处理它们互相超越。

“随机”布局由一个简单 Ruby 脚本生成:脚本在 Y 范围 40..175(避开 logo 和 bars)、X 范围 0..255 内挑 32 个不重复的 Y,配随机 X,然后按每行 4 对输出 defb 数据。

初始化时,我把那些坐标对转成追踪每颗星状态的表。所以不是每帧存位置再翻译屏幕地址,我的每颗星记录现在长这样(从基址偏移):STSCR(星星所在字节的屏幕地址)、STOCC(指向“占用检查”表单元的指针)、STMASK(星星的位模式)、STSTEP(速度,每帧走的像素数)。关键是根本没有任何 X/Y 坐标。对每颗星,我存它所在的屏幕字节和该字节里的位掩码,所以它已经是屏幕写入需要的格式。这意味着每帧不用地址转换,只是简单的位移和旋转。

移动一颗星超级简单。我用 rlca 把它的掩码左移,当位移出第 7 位时,它回到第 0 位且进位标志被设置。这就是我需要把星星左移一个字符单元的信号,也就是前面提到的“便宜操作”之一。

因为我追踪每颗星在哪个单元,碰撞检查几乎免费。这就是第二个指针的用途:STOCC(Star Occupancy)和 STSCR 同步移动,穿过一张每字符单元一字节的表。“打字机”例程给每个含文本字符或光标的单元设成 1,所以检查星星是否藏在文字后面只要一条 or 指令:先取 STOCC 指针,把单元值读进 a,or a 测试,如果被占(jr nz,stmove)就跳过屏幕操作。

视差仍然靠每颗星每帧移动不同像素数实现,但这次成本低得多,因为我只碰星星实际占的单元,而不是每行每个字节。

视频:打字机效果与星空

这才像样!打字机例程只是从内存拷字体数据,很便宜,因为能用“便宜操作”快速取 8x8 块。

我也跳过画任何空格字符,所以留着“占用单元”不标记。这意味着星星继续穿过文字间隙——你能看到偶尔有星从词之间穿过。最终版我切换成全大写书写,让效果更好,因为字体很方块,你基本不会注意到星星跳过字形内部空格。

杂项

几个不值得单独开一节的零碎……

VU 表

某种随音频播放跳动的图形显示,也是早期 demo 常见套路。我原本考虑过实时图形均衡器,但那太复杂,多半每帧富余周期不够,而且以我此刻有限脑力绝对搞不定!所以我退而求其次,做了一组简单跳动的电平条。

值得一提主要是因为它让我有借口链接超赞的 chibiakumas 网站!我太爱那上面的 90 年代末网页设计了——可以说可读性和无障碍性受了损,但遇到一个还长这样的网站就是很有趣。它显然是爱之作,而且内容也很丰富,如果你的眼睛受得了的话。

他们关于 “Making Sound with the AY-3-8910” 的指南给我展示了怎么拿到画电平条需要的数据。例子把寄存器号写到 $FFFD 来选寄存器,我意识到一旦选了寄存器,就能用 in 指令把值读回来,而不是写新值。如页面所说,R8、R9、R10 保存通道振幅,正好能喂给画条的高度参数。拿通道 A 的振幅就这么简单:ld a,8 选通道 A 寄存器,调用 ayread;ayread 里 ld bc,$FFFD,out (c),a,in a,(c),然后 ret。

一闪一闪小星星

这是个让页头 logo 更有趣的简单特效。不确定最初哪来的点子——肯定在几部 80 年代末/90 年代初 demo 里见过类似东西。我只想在 logo 背景里挑随机星点并做动画。

ZXPaintbrush 中显示字符网格的页头图

这是页头图在 ZXPaintbrush 里显示字符单元网格的样子。巧的是,有四颗星各自整齐坐在单个字符单元内、周围有足够空间,所以可以用来做动画“闪烁”效果:从中心点向外画十字再收回。

这些点映射到以下坐标,目标是每颗完全长成星星的顶扫描线(中心往上两像素):用 defb 定义 4 个位置数据,例如 defb 1,5、defb 1,20、defb 26,7、defb 25,22 等,各自带字体行列和 ink 等信息。

闪烁例程本身很简单:在两条轴上各加一个像素让星长大,等几帧,再缩回去。有意思的是我怎么让闪烁顺序伪随机。记住我的星空完全是从 Ruby 脚本生成的数据表预计算的,这意味着我根本不需要汇编里的 RNG。从 Jonathan Cauldwell 的 “How To Write Spectrum Games” 电子书里,我找到另一个很棒的“黑招”,它只是遍历 Spectrum ROM 开头,挑看起来随机的字节。如他所说,他在大多数游戏里用它,而且做法极其简单——只要你待在 ROM 里 8Kb 后那段结构化重复数据之下。例程 twkrand 把种子取到 hl,用 and 31 把高字节限制在 ROM 前 8k,读出“随机”字节,递增种子后返回。

空间不够了

写到一半,我撞上一个相当可预料的问题:内存用完了。为了把所有东西自包含在一块代码里,我把所有资源(屏幕背景、播放二进制、音乐文件等)都拉进程序本身,然后用 ldir 块拷贝把每个移到正确位置。因为这意味着每块实际存了两份,而播放例程硬编码起始地址 $C000,我很快没地方了,代码开始撞进播放例程。

最简单的办法是把东西拆成独立代码块,各自直接加载到它需要住的地址,然后用多阶段 BASIC 磁带加载器包起来:10 CLEAR 32767,20 BORDER 0: PAPER 0: INK 0: CLS,然后三次 LOAD "" CODE,最后 RANDOMIZE USR 32768。这先加载主程序,然后把播放例程直接加载进 $C000,最后把背景屏直接加载进屏幕内存。这意味着背景会在 demo 完全加载前就开始出现,但我想大多数人会在模拟器或我的 TNFS 网站上看它跑,所以真没关系。另外,此时我正面临消失进更多兔子洞的危险(我仍然真的很想搞明白怎么做多色边框特效!)而且我永远别想把这东西发布了。

Z80 的禅意

笔记本和笔

整个项目回头看真的很怪。一方面,我实现了童年抱负,有了非常自豪的东西。如今也挺罕见能有个二进制,你几乎能说出每个字节在干什么、以及它怎么来的故事。但另一方面——我现在有了为啥不写汇编的答案:因为我珍惜(剩下的)理智!问题是,要写这种代码你得开始像机器一样思考。你得可视化寄存器和状态标志翻飞,图形是塞进奇怪顺序的位行,循环和条件是二进制逻辑等等。离硬件这么近确实让我对童年的硬件有了更强感情,也很容易理解为啥汇编程序员和 sceners 对它如此充满激情。话虽如此,我可不着急把我的作品重写成汇编!

我真正享受的项目,是那些给我借口纯粹为好玩学点东西、并从现代工作世界不断加速和要求中慢下来的项目。回到 Z80 代码大概是我能走的极端了,仅次于去搞硬件 hacking。我发现它有时是个迷人谜题,常是个让人沮丧的练习,好几次差点让我放弃。这项目从任何功能的“想法”阶段到任何有意义产出,花的时间肯定最长。

诚然,过去一年我不是每天都在搞——实际编码时间大概只有每周几个晚上、每次几小时。但我肯定大多数日子都在想它、琢磨它(我的狗可以作证)。最大突破和“啊哈!”时刻通常不是来自键盘前;它们来自我笔记本上的涂鸦,以及做别的事时思考处理器状态。

不过这让我对那些做到并把这事变得看起来轻松的程序员充满巨大欣赏和尊敬。对那些当年在没有这么多资源的 80 年代搞定这一切的孩子们和卧室编码巨星更是加倍。我能走到这一步只是因为无数书籍、在线教程、带示例代码的 GitHub 仓库、YouTube 视频……我无法想象,如果我唯一的资源只有我的 Spectrum、一台磁带录音机和一本笔记本,这会花我多长时间。

所以,当我给这项目暂时画上句号时,我举杯,用 h0ffman 的话说:“向黑客、画师和 protrackers 致敬。”

成品结束画面

评论

讨论整体以欣赏和怀旧为主,多数人借这篇关于 ZX Spectrum 演示作品的文章称赞其巧思,并兴奋地证明 Demoscene 至今仍在举办活动、仍有作品在传播,也有人重温 1980 年代编程聚会;分歧并不尖锐,少数人由 Demoscene 的极致优化反衬现代程序员“懒惰”,也有少数人提醒这些小众作品依赖社区保存,原站与源码早已面临腐烂风险。

认为 Demoscene 至今仍有生命力,线下活动与作品分享仍在延续

  • 不只一个人指出 Demoscene 没有消亡:近期墨尔本和匹兹堡都有演示聚会,校园里还能现场把玩大量 8 位作品;还有人顺带推荐另一部值得看的作品,说明小众社区仍在持续运作和输出。(较多人)
  • > Demoscene 至今仍然存在,几周后墨尔本就会有一场活动。(@eggsome)

怀念 1980 年代 Demoscene 与朋友间编程聚会的纯粹快乐

  • 有评论者把文章和这些演示作品与高中、Speccy、Amiga、朋友家写代码聚会联系在一起,称赞作品里藏着许多巧思,并把观看过程形容为温暖的时间旅行。(高赞)
  • > 这让我想起 1980 年代和朋友在家写代码聚会的温暖时光。(@pjmlp)

借 Demoscene 批评现代软件开发不再重视优化

  • 一位评论者说自己高中时亲历 80 年代 Demoscene,此后一直觉得后来的程序员(包括自己)因为硬件越来越强而懒得优化代码。(高赞)
  • > 经历过 80 年代 Demoscene 后,我看后来很多程序员都显得很懒,硬件越好越不优化代码,我自己也一样。(@SteveGerencser)

强调 Demoscene 的小众传播是一种跨越时间的偶遇

  • 有评论者指出,Demoscene 社区虽小却有影响力,认知主要靠口碑而不是大众传播;人们常常在作品发布多年后才偶然接触到局部片段,2026 年仍会有人喜欢 1992 年的作品,也会有人做出看起来像 1992 年的新演示作品,这种跨越时间鸿沟的相遇比流行文化更动人。(高赞)
  • > Demoscene 很小众却有影响力,人们常在多年后才偶然碰到它,2026 年仍会有人喜欢 1992 年的作品,也会有新作品看起来像 1992 年的产物。(@grehbies)

提醒小众数字文化遗产面临链接腐烂与保存危机

  • 有人指出文章中后段不得不改用互联网档案馆里的预编译二进制,因为原始网站已经链接腐烂、源码也无法汇编;虽然相关资源在另一个仍在线的站点上还能找到,但若社区不主动保存,这类依赖早期网络的资料仍会逐渐消失,不能把它们的持续存在视为理所当然。(高赞)
  • > 原网站已经链接腐烂,源码也无法汇编,只能退而使用存档里的预编译二进制,这类东西不做保存就会烂掉。(@JdeBP)

有价值的补充

  • ZX Spectrum 128k 并非最初的 48k 基础型号,这是对文中硬件说法的一处细节订正。(@manytimesaway)