为 ZX Spectrum 编写一个 Demoscene 开场

制作复古风格 crack intro 的工具、技巧与挑战。

中文
复制
昏暗桌面上的 ZX Spectrum +3 实机:显示器里正运行刚做好的 intro,顶部是变色像素 logo,下面是 GREETINGS 名单和滚动文字,桌前摆着 Spectrum 128K 与键盘

立即下载 · 在 pouet.net 上查看 · 在 Hacker News 上讨论

简介

我的 +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 就像撞见了外星造物。和当年所有见过它的人一样,我被那画面和声音震住了,更别提那一堆开创性的游戏。但真正让我着迷的,是盗版游戏开头那些神秘(而且酷得不行)的“intro”。

这些开场画面由软件盗版组织制作——正是他们破解了游戏的复制保护,把游戏拷进软盘分发出去。这些作品本身就是小小的艺术品:音乐、图像,通常还有某个看起来很酷的效果,用来炫耀组织的实力。YouTube 上存档了一批很全的这类开场(也叫“cracktro”)。

随便看几段,你很快就会发现几乎所有 cracktro 共有的“设计”套路:屏幕顶部一个酷炫带感的 logo(要是还滴着血,或者带点“金属”味,那就更到位了),一段自吹自擂的文字压在某种效果之上——通常是视差滚动的星空,或者旋转的 3D 物体——再往下是一大段滚动字幕,满是致意、对同为化名破解者的问候,以及铺天盖地的非法 BBS 广告。不用说,对 80 年代到 90 年代初的孩子来说,这大概是有史以来最酷的东西。

Horizon cracktro 截图

第一次看到这个的时候,我觉得它简直绝了。正弦滚动、海盗风格的 BBS 广告、带剑的酷炫 logo——当然,还有星空背景。每个人都能在这里找到自己喜欢的东西。

Lemmings cracktro 截图

这段音乐已经刻进我脑子里了。90 年代初《Lemmings》那么火,我同学里几乎没人不熟悉这段开场。Skid Row 这个小组至今仍活跃,而这大概就是那个时期最典型的 Amiga cracktro。

Piracy cracktro 截图

Endless Piracy。前卫。又一个酷炫的 logo、正弦波滚动字幕、星空背景和自夸文案。经典永不过时。

Spirit cracktro 截图

又一件把关键元素都凑齐的佳作:酷炫的 logo、更酷的字体、滚动的星空,底部还有一行滚动字幕,满满的复古味。

Anthrox cracktro 截图

Logo。文字。滚动条。但没有星空背景——除此之外,Anthrox 这次做得相当扎实 :)

demoscene 正是从这些 intro 里长出来的。破解逐渐退居幕后,程序员、“graphician”和音乐人共同投入,做出片长级的作品,展示各自的本事,与其他小组同台竞争,把机器逼到极限。凭借惊人的巧思和纯粹的“代码即艺术”,这个圈子至今仍然活着,各小组不断推出像这个和这个一样令人屏息的作品——别忘了,这两者都跑在一台 7MHz 的家用电脑上,配着软驱、1Mb 内存和一颗 1985 年的声音芯片。

但尽管我钦佩这背后纯粹的技艺与投入,真正让我着迷的始终是早期那种“单屏”的滚动文字加星空 cracktro。它们对我而言带上了一种神秘色彩,暗示着一个我只能从外部去体验的圈子和文化——这也正是我花那么多小时泡在 Sinclair BASIC 里、徒劳地试图复刻它们、并梦想着有朝一日拥有自己的 Amiga 的原因。

回到过去

后来我确实一路升级,换过好几台 Amiga,自己也加入过几个“scene”小组,一头扎进各种各样的活动,直到考试和少年时代的终结把我叫走。那阵子我摸过 Z80 代码,还一度对 68k 汇编熟到能蒙混过关——一部分来自 Amiga 的黄金年代,一部分来自大学里几门“嵌入式系统”课程,课上我得给一组红绿灯或电梯控制器编程。不过在我 $DAYJOB 那儿,这类东西没什么用武之地,所以它基本上已经从我的记忆里淡去了。

TNFS 站点截图

我一直在业余时间做一堆项目,其中最中意的一个是 TNFS 站点——算是给 Spectrum 做的一套老派 BBS 系统,或者说交互式“网站”。它最初源于一个好玩的实验,想看看 Sinclair BASIC 到底能被推到什么程度。它依赖插在 Spectrum 上的一块现代网卡,我也因此结识了“Desertkun”,另一位 TNFS 协议爱好者。他现在正在为 Speccy 做一块很棒的 WiFi 网卡,还大方地寄了几块原型给我帮他测试。

他的硬件功底和底层编码能力让我大受触动。有一次视频通话,我们互相展示各自的开发环境,他问我为什么不自己写点 Z80 汇编。随口一句,挑战就这么立下了。整站用汇编重写我实在下不去手,但我想我可以做一个自包含的开场画面来宣传它,风格仿照早期的“BBStros”,也终于能当一回酷小孩,拥有自己的 scene 作品 :)

能有多难呢?(哦——这是伏笔!)

开发环境

整个过程中我的开发环境相当简单,和我开发 TNFS 站点用的是同一套。构建系统由 Makefile 驱动,代码交给 Pasmo 汇编器,BASIC 文件用我 fork 的 zmakebas 生成,测试则用 FUSE 模拟器。最后要在真机上测试,我把 .tap 文件上传到本地的 TNFS 服务器,就能在我的 +3 上运行了。

ASM 编辑器截图

还有几个工具我得单独提一下,因为它们最终带来了很大的差别。其中之一是我在查 Z80 指令时发现的网页版“asm-editor”,非常好用。这个网站不仅有完整的 Z80 指令参考,还带一个编辑器,可以运行示例代码,观察内存和/或各个寄存器如何变化。比如 djnz 指令,你可以把它汇编出来,然后一条一条地单步执行那个循环结构。或者看看我截图里那个 add 指令的例子,观察进位标志在一位被移出寄存器时如何变化。它的语法和 Pasmo 略有不同(所以我的代码没法直接放进去),但手动单步调试时它帮了大忙。

有史以来最好的调试代理。

最后,大概是我在这个项目里用到的最重要的调试工具——一个记事本、一支笔,还有我的两条狗(跟 Biscuit 和 Pebble 打个招呼吧……)关于这一点,我会在收尾里再多写一些,但对我来说,这确实是一个极不寻常的项目,毕竟我几十年来用的都是抽象层级高得多的语言和构造。我的大部分 z80 代码,只有在把字节和位掩码画出来、维护一张寄存器标志表、再手工把屏幕内存一块块画出来之后,才真正看得懂。我发现傍晚带着两条狗散步,正好是理想的小黄鸭练习,虽然我不确定它们俩对我没完没了地念叨 hl 寄存器对作何感想。不管怎样,遛狗式调试强烈推荐!

起点

那么,开始吧。我照着 futurice.com 上一个很棒的“Hello, world!”示例验证了构建环境。除此之外,我第一件想做的事,是把 logo 和背景图加载进来,作为后续整个片头计划的“舞台”。当时我其实还没想好具体要做什么,只知道自己想把 logo 放在顶部,再让一行滚动文字沿着屏幕底行滚过。我的 TNFS 站点页头本来就有点 demoscene 的味道,于是我快速改了一版,在里面放了两条蓝色参考条,之后的内容就放进这两条之间。

ZX Spectrum 内存映射

内存映射图由 breakintoprogram.co.uk 提供

Spectrum 的 SCREEN$ 文件(通常就是加载画面)不过是屏幕内存的一份转储,从第 16384 字节开始,共 6912 字节。这种古怪的布局在后面会变得很关键,但要把它显示出来,你只需要把文件加载到那段内存地址即可。花了几个小时翻 Speccy 的内部资料、照着各种指南摸索之后,我的 demo 有了一个非常简单的起点,后面的一切都是在这个基础上做出来的。

org $8000

screen  EQU $4000 ; address of the first byte of screen memory, 16384 in dec
scrlen  EQU 6912  ; total length of the screen memory inc. attributes
ula     EQU $FE   ; I/O port for the ULA to control the border
black   EQU 0     ; Spectrum colour number, the exact same ones BASIC uses

start:
  ld a,black      ; Set the border colour to black using the ULA
  out (ula),a

  ; Copy my logo header picture to screen memory
  ld hl,header    ; HL = location of my screen file in this code
  ld de,screen    ; DE = start of screen memory
  ld bc,scrlen    ; BC = 6912 bytes to copy
  ldir            ; block copy, see https://jnz.dk/z80/ldir.html

; busy loop - rest of intro goes here :P                    
hold:
  halt            ; sleep until the next interrupt (1/50 second)
  jr hold         ; then jump back and sleep again, for ever

header:
  incbin "assets/header.scr"  ; my logo header/background

end start
ZX Spectrum 屏幕布局演示

现在看着不起眼,但这个简单的起点让我接触到了几个重要概念:通过 out 指令与 Spectrum 的硬件打交道(先是从 ULA 芯片入手,后来是 AY 音效芯片)、用 Pasmo 的 INCBIN 指令引入二进制文件、使用 Z80 的块复制指令 ldir、用寄存器对做 16 位寻址,以及跳转到标签的简单分支。

Hello World

Hello World 程序输出

下一步是在屏幕上打印点东西,并摸索 Speccy 内置的一些 ROM 例程——它们大致相当于现代操作系统上的系统调用。网上有大量 Spectrum ROM 的反汇编和指南,其中注释和链接做得最好的一个在 https://skoolkid.github.io/rom/。拿它当参考,再配合各种“Hello, world!”教程,我弄明白了这类东西是怎么一步步搭起来的:

  at      EQU 22 ; "at" control character used to position the cursor
  row     EQU 11 ; coordinates where to print the message
  col     EQU 9

  prstring EQU $203C ; where the pr_string routine lives in ROM

  ld de,msg        ; DE = address of the first character
  ld bc,msglen     ; BC = how many characters to print
  call prstring    ; print the lot

; busy loop is the same...

msg:
  db at,row,col            ; equivalent of "PRINT AT 11,9" in BASIC
  db "Hello, world!"
msglen  EQU $-msg          ; 16 bytes in all, pasmo shortcut, $=program counter

最后我在代码里基本没用这些 ROM 例程,因为它们会牵进一大堆我用不上的东西;但搞懂它们如何工作还是大有帮助的,因为它们让我认识了更多常用写法。比如这里定义的 PR_STRING 例程,就展示了如何对每个字符构建一个循环。如果你刚入门,我强烈建议你拿上纸笔,把那八行例程过一遍,推算出各寄存器的内容,跟着循环结构走一遍。

在 asm-editor 中的打印例程

我也在 asm-editor 里做了一个版本,你可以试试。点开链接,代码已经载入网页编辑器。点“Build”,再用“Step”按钮单步执行——虽然屏幕上不会真的显示文字(我跳过了对 Spectrum ROM“restart”向量的调用,显示文字靠的就是它),但你能看到各个寄存器的状态,比如 BC 寄存器随着字符串的每个字节被处理而递减。

加点风格

问题是,Speccy 内置的字体实在谈不上有什么 demoscene 气质。我想用些有风格的字体,结果发现这事特别简单——随手一搜就找到了 Jim Blimey 那套很棒的字体合集,里面附有现成可用的汇编文件和使用说明,照着说明,我很快就在代码里加上了这些内容:

; CHARS is the address the ROM's printing routine reads to find the font. 
; Picking our own font is simply a matter of over-writing this
chars   EQU $5C36 ; from Jim's docs - POKE 23607=5C36 in hex

  ld hl,myfont-256 ; HL = font address - 256 as per docs
  ld (chars),hl    ; from now on the ROM draws with it!

; rest of code is the same

myfont:
  include "textfont.asm" ; "Cartoon" font from jimblimey.com
Spectrum 上的自定义字体

来了!这下看着像样多了。抱歉,我是说31337。坐稳了各位,咱们要离开菜鸟城了……到这一步,我瞎折腾了大概一个月,对常见写法已经有了还算靠谱的理解,也开始觉得这项目说不定真能做出点名堂。问题出在我转去实现真正的 demo 效果的时候:我很快就清楚地意识到,自己对这套东西的运行原理有多么 不 了解。更别提在这个层面上,你会非常直接地接触到硬件的种种细节,包括硬件设计者在 1980 年代初那些极其有限的规格下不得不做出的设计取舍。

要命的屏幕布局

Spectrum 的屏幕内存布局

第一个问题——大概也是我九成痛苦的来源——是 Speccy 极其怪异的屏幕布局。它不像你在更现代的硬件上期待的那种标准位图显示,而是分成 3 个区段,每个区段由一组交错的行组成(所以如果你不断给屏幕地址加 1,最终不会到达下一行,而是落到往下 8 行的位置,如此反复,直到抵达该区段的底部,然后绕回你起始位置下方的那一行!),后面跟着一块“属性”字节,决定每个 8x8 块的颜色。属性块正是每个 8x8 字符单元只能有两种颜色(一个 PAPER 和一个 INK 属性)的原因,这造就了 Spectrum 游戏那种刺眼的“颜色冲突”和独特的风格——美术师会尽量把图形控制在 8x8 块内。但真正让人绞尽脑汁的,是位图部分的布局。

Spectrum 加载画面视频(mp4)

游戏从磁带上读取加载画面时,你能看到这个过程。屏幕上逐行交错地堆出每一段内容,最后才补上颜色和其他属性(比如闪烁)。Break Into Program 这个站点上讲得非常细。替设计者说句公道话,他们当时手上的硬件极其有限,而且我觉得 Speccy 从来就没被当成游戏机来设计。一旦你开始搬动字节,就会意识到这套方案其实是为显示文本而优化的——文本整齐地落在 8x8 字符边界上。比如,在这种“打乱”的位序下,如果屏幕地址放在 hl 寄存器对里,只要把“高”字节加一,就能快速跳到某个地址的下一像素行。还有一堆别的“省事操作”,只需挪动几个位就能做到:

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

对一台大部分时间大概都在显示 BASIC 程序清单的机器来说,这是个合理的选择——只有当你开始写 demo 或精灵例程时,才会觉得它真的很别扭。这也意味着你很快就需要一个把 X、Y 坐标换算成对应内存地址的例程。Break Into Program 上同样有一个现成可用的例程;你反汇编几乎任何一个 Spectrum 游戏或 demo,都能在某处找到这个过程的某个版本。没错,这些后来全都变得极其要命……

滚动字幕

一个移动的点诚然,这不是最有趣的演示——但这是我第一次用汇编语言在 Spectrum 屏幕上让东西动起来!我怀着自豪的心情看了这个演示很多遍,多到我不愿承认 :)

我有了一个花哨的 logo 和“Hello World”文字,但这还算不上有趣的 intro!我想要的第一个效果是滚动字幕——没有它,就称不上真正的 intro。Amiga 有各种硬件精灵、blitter 操作和定制芯片,而 Spectrum 什么都没有。我只能硬着头皮想办法——“直接操作硬件”,也就是手动在屏幕内存里逐位移动数据,让 CPU 承担所有工作。

正因如此,理解 Speccy 那古怪的屏幕和字符单元布局才如此重要。只要我待在一个字符行内,就有一个巧妙的技巧——有一条 RL 指令,它接收一个字节并将其向左“循环移位”。它把进位标志里的内容插入最右边的位,而被移出的内容又进入进位标志。这意味着,如果把一串这样的指令连起来,就能让一组像素在屏幕上移动——当它们从某个字符单元“掉出去”时,会进入进位标志,随后下一条 rl 指令会把它们插入左边下一个字符单元的开头。重复 32 次,你就把一像素(或一组像素)移过了屏幕的一行:

linestart EQU $4880         ; leftmost byte of the line 12 rows down
lineend   EQU linestart+31  ; rightmost byte column ($489F) 
linelen   EQU 32            ; one whole line is 32 bytes (character cells) across
dot       EQU %00000001     ; the dot "shape", just one pixel lit at the far right end

newdot:
  ld a,dot                 ; load the dot shape (only rightmost pixel lit) into a
  ld (lineend),a           ; store it in the rightmost byte of the line, pixel is now lit

frame:
  halt                     ; wait for the next frame, otherwise it whizzes across too fast to see
  ld hl,lineend            ; hl = last (rightmost) byte of the line
  ld b,linelen             ; loop counter, 32 bytes to work through
  or a                     ; trick to clear the carry flag, or against itself leaves a register 
                           ; unchanged but clears various bits including the carry flag
                           ; see https://jnz.dk/z80/or_r.html
shift:
  rl (hl)                  ; rotate this byte left (00000001 -> 00000010)
  dec hl                   ; point hl at the next byte to the left
  djnz shift               ; loop construct: b=b-1 and then jump back to shift label until b=0
  jr c,newdot              ; now we've shifted the whole line, so if the carry flag is set now it means
                           ; the dot has just left the screen, so we jump back to the start
  jr frame                 ; otherwise it is still somewhere on the screen, so keep shifting!
滚动一行太对了。现在这几乎算得上一段像样的滚动字幕了!

把这段逻辑再套进一层循环,每次给 h 加一(记住,这样就能沿一条扫描线往下走),整个字符单元就动起来了。这里可以看到它正一行行移动我的“Hello world”消息。不过,要做出一个像样的滚动文字、让每个字母平滑出现,还需要多得多的工作。我一开始只是在屏幕右边缘一次打印一整个字符——能用,但很难看:你会看到新字符一下子出现在屏幕右侧,滚动 8 个像素之后下一个字符又闪出来。我想要的是每个字符逐像素出现,这就要多摆弄一些位操作。最终写出来的例程直接把字体数据逐行、逐像素地流式送入,而不是调用 ROM 的打印字符例程。

具体做法是:查出某个字符的 ASCII 码,再把对应的字形取到一个 8 字节的缓冲区里,每行数据一个字节。字体数据从空格字符(字符码 32)开始,所以查表时只要 font code base address + (ascii code - 32) * 8 就能定位到那个字母的位。

字体字母 H,逐行排列

字体数据里字母 H 的位排成 8x8 的网格,看起来是这样。对每一行位,我用一个小技巧把这一行左移。字节先载入 a 寄存器,然后让它自己加自己——add a,a——就把值左移了(二进制里乘 2 等于整体挪一位),于是我想画的像素正好落进进位标志。这在 Z80 代码里是惯用写法,比 sla 指令更快也更省空间。而每帧都要重画 256 字节,省下的周期很快就积少成多了!

字体字母 H 向左偏移

此时字符缓冲区变成这样,顶行已经移位。现在,hl 寄存器对指向屏幕上的正确位置,我就可以用上面讨论过的 rl 技巧。它把屏幕最底字符行的扫描线左移一个像素,并通过从进位标志移入的方式,把我的像素画在最右侧。循环 32 次,整行就移动了 1 个像素,而我的新像素插入在右侧。

字体字母 H 列偏移

接着我再用 inc h 重复这一步,向下移动一条扫描线,直到整个字符列绘制完毕。此时字符缓冲区是这样的,而底部屏幕行的全部 8 条线,已经通过对全部 32 个字符列循环,整体左移了一位。之后整个过程再重复 7 次,直到整个字符平滑地滚动进入屏幕。

这里值得再强调一遍:仅仅是为了让一条消息在屏幕底部滚动,我们每帧就得循环重绘 256 个像素!这也说明汇编比 Spectrum ROM 里内置的 BASIC 解释器快了多少。看到自己的代码终于跑起来,想象着那些比特四处飞窜、寄存器来回翻腾,我觉得很神奇。也让我真正体会到,当年我玩过的那些游戏,背后倾注了多少心血。

音乐

我转换的原始 Amiga 模块。一首地道的 80 年代洗脑神曲!该段落从 1:00:25 开始。

当然,好的开场少不了音乐。在 Amiga 上,标准格式是“tracker”模块格式,它有着一段漫长而曲折的历史,衍生出众多分支、版本和改动。一首曲子由一系列 pattern 搭建而成,每个 pattern 由若干行指令组成。每条指令通常包含音符或音高信息、要播放的采样,以及音量、效果等各类参数——某些指令还会指定跳转到曲中的哪个位置。从很多方面看,这几乎就是一门为音乐而生的编程语言。以 ProTracker 为例,一个典型的音符单元占 4 字节,在屏幕上显示为类似 C-2 01 C20 的样子。它拆分为三个字段:音符与八度(C-2)、采样编号(01),以及效果及其参数(C20——在这里是“将音量设为 $20”,即最大值 $40 的一半)。

Protracker 截图

Protracker 正在播放一首曲子,图为特写。注意那些 VU 表——又一个我后来会顺手借用的经典 demo 效果!

如果想看 tracker 能做到什么程度的惊艳演示,也想看看音符数据在屏幕上长什么样,可以去看 h0ffman 的作品,尤其是他在 Revision 2014 比赛中获奖的“Professional Tracker”参赛作品。在 tracking 这门科学(或者说艺术!)上,他是公认的大师,他的“Secrets of Protracker”演讲很好地概述了能做到哪些事情。

Amiga 的声音硬件远远盖过当时市场上的其他一切,相比之下 ZX Spectrum 就寒酸得多。最初的 48K 机型只提供一个简陋的“beeper”,后来的 128K 机型才加入了 3 通道的 AY-3-8912 声音芯片。这颗芯片在当时的 8 位微型计算机上非常流行,Atari ST 用的也是几乎一模一样的变体。它只提供基本的波形和声音处理,但足以实现多通道的“芯片音乐”,因此成了 ZX Spectrum demoscene 的中流砥柱。它还在 Spectrum 本机上催生了一批 SoundTracker 克隆,不过如今大多数音乐人更愿意用 Vortex Tracker II 这类现代工具,它可以在 Windows 下原生运行。

转换

Pex 回顾他的 demo——这段从 21:55 开始,正是他展示我做的字母 H 并播放了一点我的翻唱版本的地方!

作为开场,我想移植一首 Amiga 模块音乐——而且我心里早有了完美之选。我最喜欢的 Amiga 音乐磁盘之一,是瑞典双人组合 Mahoney & Kaktus 于 1989 年发行的《Sounds Of Gnome》。Mahoney 还写了 NoiseTracker,那是 Amiga 上最早真正被广泛使用的 tracker 之一,也是我十几岁时用来入门自己动手做电脑音乐的软件。2018 年,我曾为那张磁盘上的另一首曲子做过一版摇滚翻奏,还就此写信给 Mahoney(本名“Pex”)。后来他竟在一场 demo 聚会上播放了我的“音乐视频”,我对此永远心怀感激!所以,把那张合辑里的另一首歌拿来改成 Spectrum 版本,似乎再合适不过了……对不住了,二位!我选的是《Milkman》,作者是“Glue Master”,他是 Mahoney 的熟人,有几首曲子被收录进了那张合辑。

这首歌非常适合拿来转换:它很短(总共只有 7 个 pattern),其中大多数 pattern 共用同一段和弦进行,而且把原来的 4 个声道压缩进 Spectrum 的 3 个声道,看起来也相当容易。再加上它旋律极其抓耳,我又非常熟悉——我已经听了 35 年以上,还经常用吉他或贝斯跟着即兴。转换一开始相当顺利:我真正要做的,只是把 Amiga mod 里的 pattern 一个音符一个音符地照抄过来,当时我在 Windows 上用的 tracker 是 OpenMPT,mod 就开在里面。下图是一次会话,两个 tracker 都打开在同一个 pattern 上——左边是原始的 Amiga mod,右边是我做的 3 声道 Spectrum 版本。

Vortex Tracker 截图

注意 Spectrum 版本第 3 通道里的 11.7。这是 Vortex Tracker 语法中的“音高下滑”效果,会把音高降低约四分之一音。下一行又重复了一次,所以整体效果就是从 A# 往下滑,接入下一个低音音符。原版 Amiga mod 用自己的语法做了同样的事——也就是你在第 4 轨底部、同样这些音符上看到的那些 202。

所有声部都只用同一个“哔”声,就足以验证一切正常。之后几周我利用晚上时间折腾、重新编排,做出了一个基础版本,作为初稿听起来出奇地好:

下载哔声版(MP3)

特别要提一下这篇论坛帖子,它对我起步帮助极大。这大概是我找到的最好的 Vortex Tracker 入门材料——非常感谢原作者花时间把它完整写出来!

Vortex Tracker 示例

真正的挑战在于让它尽可能贴近原曲。这意味着要为各个声部积累一套音色更好的采样库,并大量使用 Vortex Tracker 的“ornaments”。Vortex Tracker 自带一批很不错的音色,而幸运的是,我恰好是个怪人,工作时喜欢在后台听 chiptunes!所以每当听到特别抓耳的东西,我就把它记下来,留着以后偷灵感。Vortex Tracker 里的 sample 并不是 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 即可——这个地址不能改,因为里面写死了大量绝对地址。

我处理模块播放的代码现在大致如下,二进制模块已加载到位:

; Define the start address of the player routine and functions we need
player EQU $C000      ; hard-coded org address of binary player
plinit EQU player+3   ; init function in docs, module address in hl
plplay EQU player+5   ; play and move on one frame

; from the docs - this "analyzes PT3 module header and prepares 
; corresponding note and volume tables"
ld hl,moduleimg
call plinit

; main loop
hold:
  halt
  call plplay
  call scroll
  ; other effects / routines go here...
  jr hold

moduleimg:
  incbin "assets/music.pt3"

星空

慢速星空嗯,没能如愿。TNFStro——陆龟版。

接下来是重头戏。没有星空的 cracktro 是不完整的!

我最初对经典星空效果的朴素做法,是在屏幕上随机撒下星星,然后逐条扫描线向下移动,套用我在滚动文字上用过的同一个 rl 技巧。我打算通过让每一行偏移不同的量来制造视差:第一行移一个像素,第二行两个,第三行三个,然后再回到一个,一路向下扫过整个屏幕,营造出几片“星场”以不同速度移动的感觉。

结果这个做法有两个大问题。首先,整行平移会把行上的其他内容连同星星一起拖花——这一点我预料到了,记下来留给未来的自己解决。更大的问题是速度:等我处理完前三行,已经执行了数百次移位操作,根本快不到能塞进一帧里。所有东西都在屏幕上爬,音乐播放慢得离谱,你甚至能听到琶音装饰音一个个音符地颤出来。

显然我得改为单独跟踪每一颗星星。我找到了这个例程,大致就是我想做的,也让我有了用一个结构体保存每颗星星状态的想法。它还用了屏幕地址查找,和“Break Into Program”里的那个很像。

慢速文本该死。又带来一首葬礼挽歌式的翻唱 :(

这下有进展了,但坐标查找和跟踪的开销很大(每颗星、每一帧都要跑一遍,还得记住旧位置以便擦除),而且我一直在回避那个更大的问题:怎么在星空之上显示东西。我最初的想法很简单:每帧先画星星,再把文字盖上去,结果和第一次尝试一样,把帧预算彻底撑爆了。

如果想要的不只是一个空荡荡的星空,我就得记录哪些格子被文字占用,从一开始就不往里面画星星,而不是每次都覆盖重画。在笔记本上涂涂画画、在脑子里过了一遍又一遍位行之后,我终于靠相当肆无忌惮地“作弊”把两个难题都解决了。

所以现在,我根本不生成随机星空。取而代之的是一张预先算好的 Y、X 坐标对表:

stdata:
  defb 109, 25,  88,182,  42, 96, 155,226
  defb  59,254, 137,169, 160, 84, 125,193
  ; ... and so on, for 32 stars ...

这样就不用跟 RNG 纠缠,而且一旦布局看着顺眼,我就知道它会一直顺眼。我还可以“动手脚”,保证没有两颗星共用同一个 y 坐标,从而绕开一整类问题,比如星星落在同一条扫描线上、还得处理它们互相超越。

这份“随机”布局由一个简单的 Ruby 脚本生成:

#!/usr/bin/env ruby

PAIRS    = 32
PER_LINE = 4          # pairs per line
Y_RANGE  = 40..175    # exclude rows taken up by my logo & bars
X_RANGE  = 0..255

raise "y range too small" if Y_RANGE.size < PAIRS

# no duplicate y values
ys    = Y_RANGE.to_a.sample(PAIRS)
pairs = ys.map { |y| [y, rand(X_RANGE)] }

pairs.each_slice(PER_LINE) do |row|
  body = row.map { |y, x| format('%3d,%3d,', y, x) }.join(' ')
  puts "defb #{body.chomp(',')}"
end

例程初始化时,我把这些坐标对转换成一张记录每颗星状态的表。于是不再每帧存储位置再换算成屏幕地址,我的每条星星记录现在长这样(相对基地址的偏移):

STSCR   EQU 0  ; screen address of the byte the star is in, e.g. $4123 
STOCC   EQU 2  ; a pointer to the "occupancy check" table cell
STMASK  EQU 4  ; the star's bit pattern, e.g. %00010000 
STSTEP  EQU 5  ; "speed" - num. of pixels this star travels per frame

关键在于,里面根本没有 X/Y 坐标。每颗星我存的是它所在的屏幕字节和该字节内的位掩码,这已经是屏幕写入所需的格式。也就是说,每帧不用做地址转换,只需简单的位移和循环。

移动一颗星也就变得极其简单。我用 rlca 把它的掩码左移,当某一位从第 7 位移出时,它会带着进位标志落回第 0 位。这正是把星星左移一个字符格所需的信号,也就是前面说的“廉价移动”之一。

因为我会记录每颗星星位于哪个单元格,碰撞检测几乎不花什么代价。第二个指针就是干这个用的:STOCC(Star Occupancy)与 STSCR 同步移动,走过一张每个字符单元格占一个字节的表。“打字机”例程会把每个含文本字符或光标的单元格对应的字节置为 1,因此判断某颗星星是否被文字挡住,只需一条 or 指令。如果单元格已被占用,我就跳过这颗星星的绘制,但继续让它旋转、前进,直到抵达一个能显示它的单元格:

ld l,(ix+STOCC)
ld h,(ix+STOCC+1)
ld a,(hl)                ; A = the occupancy check for this cell
or a                     ; test A against zero
jr nz,stmove             ; it's occupied, so leave the screen alone

视差仍然靠让每颗星星每帧移动不同的像素数来实现,但这次的代价低得多,因为我只改动星星实际占据的单元格,而不是每一行的每一个字节。

打字机文本这才像样多了!打字机例程只是简单地从内存中复制字体数据,而且开销非常低,因为我们可以用“廉价移动”快速获取 8x8 块。我还会跳过绘制任何空格字符,这样它就不会标记“占用单元”。这意味着星星会继续在文字的间隙中漂移——你可以在这里看到偶尔有星星从单词之间穿过。最终版本我改用了全大写书写,这进一步增强了效果,因为字体相当粗块化,所以你根本不会注意到星星跳过了字形内部的空隙。

杂项笔记

几个零碎的小东西,不值得单独开一节……

VU 表

让某种图形随音频播放跳动,是早期 demo 里另一个常见的套路。我最初确实考虑过做实时频谱均衡器,但那样太复杂了——按每帧剩余周期算大概扛不住,按我此刻有限的脑力算则绝对扛不住!所以最后退而求其次,用一组简单的跳动电平条了事。

值得一提,主要是因为它给了我一个理由去链那个超棒的 chibiakumas 网站!我就是喜欢它那股 90 年代末的网页设计味儿——你可以说可读性和无障碍性打了折扣,但碰到一个至今还长这样的网站实在太有意思了。这明显是倾注心血的产物,内容也极其丰富,前提是你的眼睛受得了。

他们那篇 Making Sound with the AY-3-8910 指南教会了我怎么拿到画电平条所需的数据。示例里往 $FFFD 写入寄存器编号来选中寄存器,我意识到一旦选中了某个寄存器,就可以用 in 指令把它的值读回来,而不必再写一个新值。正如那页所说,R8、R9、R10 保存着各通道的幅度,正好可以直接喂给画条时用的高度参数。于是取通道 A 的幅度就简单到:

  ld a,8        ; Channel A register
  call ayread

; ... rest of code here ...

ayread:
  ld bc,$FFFD   ; Register select port
  out (c),a
  in a,(c)
  ret

一闪一闪小星星

这是个简单的小效果,让页头 logo 稍微有趣一点。我不太记得最初是从哪儿得来的灵感——不过我肯定在几个 80 年代末、90 年代初的 demo 里见过类似的东西。我想要的只是在 logo 背景里随机挑一些星点,让它们动起来。

闪烁的页眉图形

这是加载到 ZXPaintbrush 中的头图,字符单元格网格已经显示出来。运气不错,有四颗星星各自正好落在一个字符单元格内,周围空间也够,于是可以从中心点向外画一个十字再收回来,做出闪烁的动画效果。

这些点对应下面的坐标,目标是每颗完全长成的星星最上面那条扫描线(比中心高两个像素):

twkdata:
  defb 1,5    ; char row 0, col 5,  centre y=3  ink 5
  defb 1,20   ; char row 0, col 20, centre y=3  ink 1 bright
  defb 26,7   ; char row 3, col 7,  centre y=4  ink 4
  defb 25,22  ; char row 3, col 22, centre y=3  ink 6 bright

“闪烁”例程本身很简单:沿两个轴各加一个像素让星星变大,等几帧,再缩回去。有意思的是我怎么让闪烁顺序看起来是随机的。要知道,我的星空完全是由 Ruby 脚本生成的数据表预先算好的,所以我在汇编里从来没用过随机数生成器。从 Jonathan Cauldwell 的电子书“How To Write Spectrum Games”里,我找到了另一个好用的“hack”:从 Spectrum 的 ROM 开头一路往下走,随手挑出看起来随机的字节。按他的说法,他大多数游戏里都用这一招,而且做法极其简单——只要别越过 ROM 里 8Kb 处那块结构化的重复数据就行。

twkrand:
  push hl
  ld hl,(twkseed)
  ld a,h
  and 31                   ; keep it within the first 8k of ROM
  ld h,a
  ld a,(hl)                ; the "random" byte
  inc hl
  ld (twkseed),hl
  pop hl
  ret

空间不够了

写开场动画写到一半,我撞上一个挺可预料的问题:内存用完了。为了让所有东西都自包含在一段代码里,我把所有资源(屏幕背景、player 二进制、音乐文件等)都塞进程序本身,再用 ldir 块复制指令把每个资源搬到它该在的位置。这等于每个块都存了两遍,而 player 例程的起始地址又是硬编码的 $C000,所以空间很快就见底,代码开始撞进播放例程里。最简单的办法是把东西拆成几个独立的代码块,每块直接加载到它需要待的地址,再用一个多级 BASIC 磁带加载器把这一堆包起来:

10 CLEAR 32767
20 BORDER 0: PAPER 0: INK 0: CLS
30 LOAD "" CODE
40 LOAD "" CODE
50 LOAD "" CODE
60 RANDOMIZE USR 32768

这段代码先把主程序载入,再把播放器例程直接载入到 $C000,最后把背景画面直接载入屏幕内存。这确实意味着背景会在 demo 完全载入之前就开始显示,不过我想大多数人都会在模拟器或我的 TNFS 站点上看到它运行,所以问题不大。况且到这一步,我已经有陷进更多兔子洞的危险了(我仍然 非常 想搞明白怎么做出多色边框效果!),再折腾下去这东西就永远发不出来了。

Z80 的禅意

我的编程笔记本

回头看这个项目,感觉实在很奇妙。一方面,我实现了儿时的抱负,做出了让自己非常自豪的东西。如今也很少有人能拿出一个二进制文件,说清楚几乎每一个字节在做什么,以及它是怎么一步步变成现在这样的。但另一方面——现在我终于有答案解释自己为什么不写汇编了:因为我珍惜自己(仅剩的)理智!问题在于,要写出这样的代码,你必须开始像机器本身那样思考。你得在脑中想象寄存器和状态标志不断翻动,图形是一条条比特按奇怪的顺序拼进去的,循环和条件判断则是二进制逻辑,诸如此类。如此贴近硬件,确实让我对童年时代的硬件产生了更深的感情,也不难理解为什么汇编程序员和demo scene玩家会对此如此狂热。话虽如此,我可一点也不急着把自己的全部作品用汇编重写一遍!

我真正喜欢的项目,是那些能给我一个借口、纯粹为了好玩去学点东西的项目,也是那些能让我从现代职场不断加速的节奏和要求中稍微慢下来的项目。回到 Z80 代码,大概是我在不碰硬件改造的前提下能走到的最远处了。有时我觉得它是个引人入胜的谜题,更多时候则是一项令人沮丧的练习,好几个阶段都差点让我放弃。这个项目无疑是从某个功能的“想法”阶段到任何有意义的产出之间耗时最长的一个。

当然,过去一年我并非每天都在做它——真正写代码的时间大概也就是每周几个晚上、每次几个小时。但我几乎每天都在想它、琢磨它(我的狗可以作证)。最大的突破和“啊哈!”时刻通常不是发生在键盘前,而是来自笔记本上的涂鸦,以及在干别的事情时琢磨处理器的状态。

不过,这件事让我对那些能搞定这些、还做得举重若轻的开发者,生出了极大的敬佩。80 年代那批孩子和卧室编程大神更是加倍值得敬佩——当年他们全靠自己反复试验,一点点摸索出来。我能走到这一步,靠的是大量的书、在线教程、带示例代码的 GitHub 仓库、YouTube 视频……要是当年我手头只有一台 Spectrum、一台卡带录音机和一本笔记本,真不敢想这得花上多久。

所以,趁着给这个项目画上句号——暂时而已!——我举杯,借用 h0ffman 的话:“向黑客、图形艺术家和 Protracker 致敬。”

MDR,2026 年 10 月

来源: markround.com← 返回首页