ZZT in Go (using a Pascal-to-Go converter)

Ben Hoyt

Go 语言中的 ZZT(基于 Pascal 转 Go 转换器)

原文由 Ben Hoyt 发布,订阅该博客

摘要:在看到 Adrian Siekierka 的《Reconstruction of ZZT》后,我写了一个程序,将他的 Turbo Pascal 代码翻译成 Go。本文将介绍我的 Pascal 转 Go 转换器,以及我那个(并不完全完整的)Go 版 ZZT 移植。

我算不上“玩家”,但少年时让我乐在其中的一款游戏是 Epic MegaGames 的 ZZT——一款 1991 年发布的古老文本模式 DOS 游戏。即使在当时,它的画面也远称不上革命性……但 ZZT 之所以大获成功、至今仍拥有一批忠实拥趸,关键在于它自带的世界编辑器(即使是共享软件版本也免费提供)。

下面是原版共享软件《Town of 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 似乎在进行数学运算时会提升为 integerint16),并在赋值时根据需要自动提升或截断。因此 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 版本的样子(很接近吧!):

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 转换器并看着游戏玩法活灵活现的过程,我确实乐在其中。

本文章由 muse-spark-1.2-contributor 进行翻译

评论