Go 语言中的 ZZT(基于 Pascal 转 Go 转换器)
摘要:在看到 Adrian Siekierka 的《Reconstruction of ZZT》后,我写了一个程序,将他的 Turbo Pascal 代码翻译成 Go。本文将介绍我的 Pascal 转 Go 转换器,以及我那个(并不完全完整的)Go 版 ZZT 移植。
我算不上“玩家”,但少年时让我乐在其中的一款游戏是 Epic MegaGames 的 ZZT——一款 1991 年发布的古老文本模式 DOS 游戏。即使在当时,它的画面也远称不上革命性……但 ZZT 之所以大获成功、至今仍拥有一批忠实拥趸,关键在于它自带的世界编辑器(即使是共享软件版本也免费提供)。
下面是原版共享软件《Town of ZZT》标题画面的样子:

世界编辑器提供了一种名为 ZZT-OOP 的脚本语言,其中的“O”指的就是 ZZT 中的“对象(object)”,即可编程的游戏角色或机器人。于是人们创作并分享了数以千计的自制世界——其中数百个至今仍可在 Museum of ZZT 下载、甚至在线游玩,这个网站堪称 ZZT 世界的 archive.org。
重现 ZZT
ZZT 的原始源代码已在一场电脑故障中丢失,但多年来人们曾尝试用其他语言重现这款游戏,包括 C++ 版本、JavaScript 的部分实现,甚至还有 Rust 的精确重制版。而 Adrian Siekierka 则是 Zeta 的作者,这是一款专门用来运行 ZZT 的 DOS 模拟器。
2020 年 3 月,Adrian 发布了他的 Reconstruction of ZZT,这是一份通过逆向工程重现的 Pascal 源代码,使用 ZZT 当年所用的 Turbo Pascal 5.5 编译后,能生成与原版字节级完全一致的 .EXE 文件。我个人觉得这是一项了不起的壮举!可以看看当时的 Hacker News 讨论以及 Adrian 后来写的介绍实现过程的文章。
此后他更进一步,创建了 libzoo,这是 ZZT 游戏引擎的可移植 C 语言重制版,采用宽松的许可协议,可供其他 ZZT 移植版使用。
Pascal 转 Go 转换器
很久以前我曾尝试用 Go 写一个 ZZT 版本,但在取得一点点进展后就放弃了——对于一个业余项目来说,这似乎是个太大的工程。不过,当 Pascal 重构版发布后,我又有了再试一次(ahem)go 的机会。
我喜欢折腾解释器和编译器,所以想试试能否写个程序,把 Adrian 的 Pascal 重构版半自动地转换成 Go。于是我写了一个并不太完整的 Turbo Pascal 解析器,以及一个转换器,它接收 Pascal 语法树,并尝试用 Go 的类型和语法将其输出。
Go 的结构和声明语法实际上与 Pascal 有不少相似之处,因此这让转换器的整体结构变得相当直接。举个例子,看看 OOP.PAS 中的这个小小的 Pascal 函数:
function WorldGetFlagPosition(name: TString50): integer;
var
i: integer;
begin
WorldGetFlagPosition := -1;
for i := 1 to 10 do begin
if World.Info.Flags[i] = name then
WorldGetFlagPosition := i;
end;
end;转换后的 Go 代码如下:
func WorldGetFlagPosition(name string) (WorldGetFlagPosition int16) {
var i int16
WorldGetFlagPosition = -1
for i = 1; i <= 10; i++ {
if World.Info.Flags[i-1] == name {
WorldGetFlagPosition = i
}
}
return
}几乎一模一样。为了让转换器保持简单,我选择对 Pascal 进行更直译式的转换,而不是试图生成符合习惯的 Go 代码——我的想法是,用程序自动生成地道的 Go 代码太难了,以后总有机会再手动整理。
请注意,这里利用 Go 的具名返回值来对应 Pascal 中将函数名用作返回值的做法。为了避免引入错误,我对整数大小进行了字面转换,因此这里返回的是 int16 而非普通的 int。
Pascal 允许声明从任意索引(而不仅仅是 0)开始的数组,实际使用中它们常常从 1 开始。因此 Pascal 版本中的 Flags[i] 在 Go 版本中就变成了 Flags[i-1]。
最困难的部分——也是我几乎没有自动化的部分——是 Turbo Pascal 的指针和字符串与其在 Go 中对应实现之间的差异。我让转换器将所有字符串类型都转换成普通的 string,然后手动修复由此产生的问题。
Pascal 的 var 参数(即其“按引用传递”的方式)在 Go 中会变成指针,但在 Go 中赋值时当然需要显式解引用。因此,这个 Pascal 函数:
procedure ElementSpinningGunDraw(x, y: integer; var ch: byte);
begin
case CurrentTick mod 8 of
0, 1: ch := 24;
2, 3: ch := 26;
4, 5: ch := 25;
else ch := 27 end;
end;在 Go 中就变成了这样:
func ElementSpinningGunDraw(x, y int16, ch *byte) {
switch CurrentTick % 8 {
case 0, 1:
*ch = 24
case 2, 3:
*ch = 26
case 4, 5:
*ch = 25
default:
*ch = 27
}
}我使用 go build -gcflags="-e" 来构建转换后的 Go 代码,以便打印所有错误而不仅仅是前几个,并将结果输出到 errors.txt 文件中。起初这个文件里有 800 多个错误。到自动转换结束时,只剩下了 33 个——随后我手动修复了它们。
我的 Pascal 转 Go 转换器的源代码在 github.com/benhoyt/pas2go。我首先构建了词法分析器和语法分析器,并将解析后的原始源码(orig 目录)以格式化后的 Pascal 形式输出到 parsed 目录。我通过对比这两个目录来判断我的 Turbo Pascal 解析器是否正确。
完成解析器后,我开始着手转换器。这不是我写过最优雅的代码;我只是想把事情做完。特别是,它把类型推断和 Go 源代码输出混在了一起——如果要构建一个真正的转译器,我会把这两步分开,先构建“类型”数据结构。
Pascal 对整数类型的处理与 Go 截然不同。Go 非常严格,不会进行任何自动的数值类型强制转换。我在网上找不到关于 Turbo Pascal 实际如何进行这类转换的可靠参考资料,不过结合 Free Pascal 的文档以及在 DOSBox 中运行 Turbo Pascal 5.5 时的实际尝试,我想我已经把大部分规则摸清楚了。
Pascal 似乎在进行数学运算时会提升为 integer(int16),并在赋值时根据需要自动提升或截断。因此 Go 版本中有许多显式转换,例如这段来自 ELEMENTS.PAS 的代码:
if Difference(Y, Board.Stats[0].Y) <= 2 then begin
shot := BoardShoot(element, X, Y,
Signum(Board.Stats[0].X - X),
0, SHOT_SOURCE_ENEMY);
end;在 Go 中就变成了一堆 int16() 转换:
if Difference(int16(stat.Y), int16(Board.Stats[0].Y)) <= 2 {
shot = BoardShoot(element, int16(stat.X), int16(stat.Y),
Signum(int16(Board.Stats[0].X)-int16(stat.X)),
0, SHOT_SOURCE_ENEMY)
}Go 移植版
半自动转换完成后,仍然有数十个 Go 编译错误。第一步是让它能够无错编译。我将 Go 代码移到了独立的 github.com/benhoyt/zztgo 仓库,并系统地处理 errors.txt 文件,修复问题的同时也移除了一些不那么重要的源代码(声音相关函数)。
能够编译之后,我还得加入视频功能。目前它通过 tcell 使用终端“图形”。要让它看起来像真正的 ZZT,你需要调整字体和终端配色以匹配旧版 DOS 的效果。下面是 zztgo 版本的样子(很接近吧!):

我还得编写 ZZT 世界的序列化例程——Turbo Pascal 版本只是将二进制结构直接加载到内存中(在那个好日子里,你只需假设是小端序和紧凑结构就行)。
在消除了主要错误后,令人惊讶的是,大部分游戏玩法竟然直接就能用了。一切开始步入正轨。我尤其记得两次提交:这次提交让大部分游戏玩法得以运行,这次提交则让游戏内的世界编辑器基本可用。为自动代码转换欢呼吧!
自行运行 zztgo
想自己运行:安装 Go,克隆 仓库,输入 go build,然后运行 ./zztgo。如果你想让它看起来更原汁原味,可以安装 IBM EGA 字体并将行距调为零。在 macOS 上你可以使用这个 Terminal 配置文件。
接下来何去何从?
遗憾的是,我已经没有时间继续投入这个项目了。大部分游戏玩法已经就位,玩起来也还算流畅,但如果你想 fork 并进一步完善,欢迎随意动手!据我所知,缺失或有问题的部分包括:
- 计时代码远非理想:目前只是调用
time.Sleep()固定休眠一段时间,而不是精确等待到下一个 tick 时刻。应该不难修复。 - 声音完全无法工作。sounds.go 中所有有用的内容都被注释掉了。要修复这个问题需要做大量工作,还得引入声音/游戏库。
- 说到游戏库,或许最好使用一个正经的图形库来进行渲染,以便更好地控制输出(而不是非得安装 DOS 字体、折腾终端行距)。
- 大多数 ZZT-OOP 对象似乎都能工作,但肯定还有一些错误,比如 Preposterous Machines 标题画面错乱就证明了这一点。
- EditorTransferBoard 被注释掉了。
- 最后但同样重要的是,它绝对不是地道的 Go!代码有一种很明显的“从 Pascal 自动转换而来”的感觉(不知为何)。
不过我还是决定就这样发布出来——祝玩得开心!实现这个 Pascal 转 Go 转换器并看着游戏玩法活灵活现的过程,我确实乐在其中。
随机一篇博客
评论
登录后参与讨论