使用 Cosmopolitan 移植 UNIX 经典游戏的一些笔记

Title Screenshot 我制作了 Zork 三部曲 的独立可执行文件,从原始 Infocom UNIX 源码移植到 Cosmopolitan,适用于 Windows/Mac/Linux/bsd 的 arm/x86 机器。 这些文件无需进一步安装或外部文件即可运行。

以下是如何在 CLI 上下载并运行 Zork:

wget https://github.com/ChristopherDrum/pez/releases/download/v1.0.0/zork1
chmod +x zork1
./zork1
# 这个可执行文件可以在所有目标平台上运行
# `zork2` 和 `zork3` 也可用,供三部曲爱好者使用
# Windows 用户,将 `.exe` 添加到下载的文件,使 Windows 正常工作

想要运行任意的 .z3 文本冒险游戏文件吗? 从这里下载 z-machine

关于项目

最近我发布了 v3.0 版本的 Status Line,该项目使 Zork 可以在 Pico-8 上运行,并支持三大操作系统。 在成功部署之后(有“敲木头”的表情符号吗?),我开始使用 Cosmopolitan 移植 Infocom 的原始 UNIX z-machine 源代码。 在一个悠闲的星期天花了大约六个小时后,我将其移植到了包括 Windows 在内的六个主要操作系统。

与依赖 Pico-8 虚拟机主机的 Status Line 不同,这个移植版本在所有支持的系统上原生运行。 更好的是,由于 Cosmopolitan 的魔法,只有一个可分发的程序需要维护,它可以根据运行它的操作系统进行自我调整。

下面是关于我如何以及为何决定做这个项目,以及我在此过程中学到的东西的故事。

什么是 Z-Machine?

多年来,我花了很多时间研究和思考 Infocom 的 z-machine。 简而言之,Infocom 文本冒险游戏以平台无关的游戏文件形式发布,这些文件在公司支持的每个系统的平台特定虚拟机中运行。 该虚拟机的规范被称为“z-machine”。

我不知道他们是否是“第一个”使用 VM 在家用计算机上发布商业产品的公司,但他们绝对是最早的公司之一。 在 1980 年代,独特的计算机平台以惊人的速度发布(Zork 1 至少在 18 个平台上发布),因此能够快速迁移到新系统非常重要。 通过使用 VM,Infocom 可以迅速将其整个游戏库引入任何新机器。

如今,游戏玩家有很多现代 z-machine 解释器的选择,但在当时,它是专有代码。 只有 Infocom 才能制作一个 z-machine 解释器,他们称之为 ZIP,即“Zork Interpreter Program”。

ZIP 大多是用手工工具汇编语言编写的,每个平台都不同,以最大限度地从最小(16K?! 1.774Mhz?!)的硬件中榨取性能。 但它们并非全部用汇编语言编写; 还存在一个用 C 编写的 UNIX ZIP。 我不太擅长汇编,但我绝对精通 C,可以成为一个鲁莽的修补匠。 我懒洋洋地想知道,如果我原封不动地构建那个 C 代码,会怎么样。 编译一次后,我得到了答案:不行。

我没有什么优点,就是固执,而 z-machine 是我比一般人更了解的领域。 让它复活感觉是一个完美的项目,可以帮助我继续探索 Infocom 的历史方面,同时也足够简单,让我可以探索 Cosmopolitan 的潜力。

什么是 Cosmopolitan?

简而言之,Cosmpolitan 是 Justine Tunney 的创意,旨在将普通的 C 语言转变为一种“一次编写,到处运行”的语言。 考虑实现这一目标的典型方法,例如 Java、WASM,甚至 Infocom z-machine 本身。

在典型情况下,代码是用一种独特的(甚至是特定于领域的)语言编写的,并编译成自定义字节码。 在 Java/z-machine 的情况下,“到处运行”的承诺是通过一个定制的虚拟机来实现的,该虚拟机是为每个目标平台定制的,它消耗自定义字节码并运行程序。 对于 WASM,该虚拟机通常是 Web 浏览器,尽管存在独立的选项。

在 Infocom 的案例中,每个游戏都捆绑了一个紧凑的解释器。 运行它是透明的体验,因为启动解释器会自动启动捆绑的游戏文件。 从用户的角度来看,她只是启动了一个游戏。 实际上,她启动了一个 VM,该 VM 又启动了游戏。

连接我们的东西

Cosmopolitan 采取了一种不同的“一次编写,到处运行”的方法。 Cosmopolitan 没有创建一个针对每台机器的独特差异进行调整的虚拟机,而是反其道而行之,评估了现代机器的_相似性_;随着时间的推移,什么保持了一致? 使用标准 C 库调用设计的通用 ABI 是围绕这些共享根设计的。

Justine 还注意到,每个平台上的可执行文件都有更多的共同点。 她开发的 APE 格式,Actually Portable Executable,其结构非常类似于 .zip 存档(不是 Infocom ZIP!),并且包含所有目标平台的本机代码。 在构建和编译之后,生成的应用程序将“到处运行”,因为它是所有地方的原生程序; 不需要虚拟机。

喜欢 APE

使用 Cosmopolitan 项目的库构建的 APE 文件可以提供给几乎任何运行 64 位机器的人,无论操作系统是什么,无论制造商是谁,它都可以运行。 我们不需要为 macOS x64、macOS M 系列、Windows 8、9、10、11、Ubuntu、选择一个 Linux、BSD 等进行单独的构建。 单个构建可以在几乎任何现代机器上运行。

对于这个项目,这意味着我花在让 Infocom 的 ZIP 再次工作上的任何周末工作都可能服务于数量不成比例的广大受众。 此外,我不需要担心调整每个平台的内容,或者制作复杂的 Makefile 魔咒。 我可以专注于游戏的正确性,而忽略特定于平台的怪癖。 我发现这种方法在精神上很自由。

APE 的 .zip 存档根的另一个好处是,我们可以更进一步,创建自包含的可执行文件,这些文件将 z-machine 和游戏数据文件嵌入到一个独立的包中。 我认为这是一种非常有趣的发行方式。

像 1985 年那样编码

我的日常工作是使用 Swift 和 Objective-C,而我的 周末 项目 往往是用 Lua 为 Pico-8 编写的。 我时不时地使用 C,但我的经验主要是在现代编码约定中。 我从来没有接触过 K&R 风格的 C,但来自 1985 年的代码迅速迫使我熟悉了它。

作为 K&R 风格的初学者,我注意到的主要事情是“假定”了很多东西。 例如,对于未声明返回类型的函数,假定为 int,即使该函数_实际上_不返回任何内容。 有些函数确实返回 int。 有些函数返回 char 但不声明返回值,因此调用函数在一种隐式转换中假定为 int。

函数参数仅通过对前向声明的“信任”来强制执行; 它们不需要声明。 而且,当您可以在调用函数中本地前向声明外部函数时,为什么要费心使用共享的前向声明呢?

使用 THEN 而不是大括号的 if 语句? 我猜你必须亲身经历过。

总而言之,我花了一些时间来调整我的代码阅读理解能力,并弄清楚我所看到的内容。

修复

让此源代码编译和工作所需的修复程序实际上非常简单。 这些更改归结为三个方面:

NULL 和 NULL 和 NULL

原始代码库中的 NULL 定义为:

#define NULL 0

然后在同一个文件中再次定义:

#define NULL 0 --不是拼写错误;它被双重定义了。

当然,在现代 C 库中,我们将 NULL 定义为:

#define NULL (void *)0

这为该项目提供了三个 NULL 定义。 好玩! 但我们只需要一个。 原封不动,这会导致编译失败,代码如下所示(K&R if/THEN 工作正常!):

newlin()
{ 
  *chrptr = NULL;    /* indicate end of line */
  if (scripting) THEN
    *p_chrptr = NULL;
  dumpbuf();
}

正如我们所见,在编写源代码的那一年中,NULL 的假设和“契约”是 #define NULL 0。 如果这就是他们想要的,那么我们就给他们。

newlin()
{ 
  *chrptr = 0;    /* indicate end of line */
  if (scripting) THEN
    *p_chrptr = 0;
  dumpbuf();
}

函数声明(以及缺少声明)

许多编译错误与调用的函数尚未声明有关。 处理起来非常简单; 这是一个原始代码中使用模式的示例。

char *getpag(ptr, page)
char *ptr, *page;
{ 
  short blk, byt, oldblk;
  char *makeptr();
  pagfault = 1;            /* set flag */
  byt = (ptr - dataspace) & BYTEBITS; /* isolate byte offset in block */
  if (curblk) THEN {         /* in print immediate, so use */
    blk = curblk + 1;        /* curblk to find page */
    curblk++;            /* and increment it */
    }
  else
    blk = nxtblk(ptr, page);    /* get block offset from last */
  ptr = makeptr(blk, byt);      /* get page and pointer for this pair */
  return(ptr);
}

好的,首先,我们必须弄清楚传递值的类型声明是如何在函数头之后声明的。 再次,我们将让我们的眼睛忽略 THEN 的使用。 相反,请注意 char *makeptr()。 这是以后定义的函数的本地范围前向声明; 它的真实标题如下所示:

char *makeptr(blk, byt)
short blk, byt;
{...}

请注意,之前的前向声明没有费心处理讨厌的函数参数。 makeptr() 需要什么? 从表面上看,是愿望和梦想!

我切换了所有函数头以使用现代约定,将 makeptr 定义转换为我相信大多数阅读本文的人至少都略微熟悉的格式。

char *makeptr(short blk, short byt)
{...}

我将所有函数头收集到 .c 文件顶部的大的前向声明块中,并迅速(嗯,乏味地)消除了大约 80% 的编译器警告和错误。 有了一组干净的前向声明,所有本地范围的声明都会抛出错误,从而使它们易于定位以进行消除。

弃用

时代在变(过去是)。 有些事情只是改变了它们需要完成的方式。

mtime()
{ /* mtime get the machine time for setting the random seed. */
 long time(), tloc;
  
 rseed = time(tloc); /* get system time */
 srand(rseed);    /* get a random seed based on time */
 return;
}

我只是用下面的代码替换了它。 正如俗话所说,“对政府工作来说足够好”。

mtime()
{ 
 srand(time(0));
}

struct termio ttyinfo;
ttyfd = fileno(stdin);    /* get a file descriptor */
if (ioctl(ttyfd, TCGETA, &ttyinfo) == -1) THEN
 printf("\nIOCTL - TCGETA failed");

变成

struct termios ttyinfo;
ttyfd = fileno(stdin);    /* get a file descriptor */
if (tcgetattr(ttyfd, &ttyinfo) == -1) {
 printf("\ntcgetattr failed");
}

cosmocc -o zm phg_zip.c -mtiny

感谢 Cosmpolitan 的编译工具 cosmocc,这一行代码让 z-machine 在 6 个现代操作系统上运行起来。 没有 Makefile,没有按系统编译的恶作剧,也没有我这边的条件代码。 Cosmopolitan 非常简单地让我能够定位与硬件无关的 ABI,并且仅将最小的(通常是表面上的)补丁应用于原始源代码。

让我说,看到 Zork 著名的介绍从创建它的公司的沉睡源代码中焕发活力,真是特别的一刻。 在 Status Line 上花费了这么长时间之后,我预计会对“West of House”再次感到厌倦。 老实说,情况恰恰相反。 了解代码库的历史及其在计算机游戏遗产中的地位只会增强这种发现和探索的感觉。

但我们可以更进一步

APE 文件隐藏着秘密的超能力。 Infocom z-machine 在命令行中采用 -g 标志,后跟用于启动给定游戏的 .z3 数据文件的路径。 实际上可以将该启动标志及其相关的数据文件嵌入到 APE 文件本身中。 然后,该游戏将在启动时在内部检查预先填充的启动参数,这些参数可以引用特定数据文件的内部数据结构。

想想 macOS 应用程序,以及它实际上只是一个可执行文件和数据的文件夹,可以很容易地被视为这样。 我们可以类似地使用 APE 格式。 为了使这项工作,我们需要两个部分:

嵌入 .args 和数据

创建一个名为 .args 的文件,该文件读取 -g/zip/game_filename.z3。 这类似于我们从命令行启动游戏的方式,但带有 zip/ 路径前缀。 这是这些数据文件所在的内部相对位置。 为了更轻松地操作可执行文件,请将 zm 重命名为 zm.zip。 使用以下命令将您的 .args 文件和相关的 .z3 游戏文件复制到 zm.zip 文件中

zip -j zm.zip .args /path/to/game_filename.z3

告诉应用程序查找嵌入的 .args

需要告诉适当的可执行文件查找嵌入的 .args。 Cosmopolitan 有一个方便的命令,可以精确地执行此操作,我们在 main() 的开头调用它

#include 
int main(int argc, char **argv) 
{
int _argc = cosmo_args("/zip/.args", &argv);
if (_argc != -1) argc = _argc;
...

这将使用嵌入的 args 填充 argv,就好像它们是由用户在命令行上手动传递的一样。 该存储库包含一个带有 embed 命令的 Makefile,该命令将为您完成所有这些繁琐的工作。 将 zm.zip 重命名为您想要调用的任何独立构建。 如果您使用的是 Windows,请不要忘记 .exe 文件扩展名。

一些主动提供的建议

作为理解将旧 UNIX 代码移植到现代 C 的过程以及如何使用 Cosmopolitan 的第一个项目,它被证明对从事我熟悉的事情非常有价值。 我非常清楚 z-machine 应该做什么,以及它应该是什么样子和感觉。 我提前了解了项目的范围和目标,并且我也知道什么时候有些事情不对劲(看着你的 fflush(stdout))。 主题熟悉程度对于在出现问题时提供直觉非常宝贵,甚至可以为如何解决某些类型的修复提供先验知识。

当您编译并看到大量的警告和错误列表时,请不要惊慌。 不要烦恼。 不要感到沮丧。 相反,将其视为您的“待办事项”清单,然后埋头苦干,并逐个解决这些编译器错误。 在编译器中,您可以使用 -w 标志关闭警告并仅专注于错误。 我们实际上不想为运送产品这样做,但如果您只对启动并为乐趣而工作的东西感兴趣,那么当您适应源代码时,它绝对可以将“待办事项”列表简化为可管理的内容。

最后,我真的无法充分强调 Cosmopolitan 提供的易于开发。 cosmocc 编译器本身构建在 gcc 之上,是一个 APE,因此是一个自包含的编译生态系统,与 Cosmopolitan Libc 一起捆绑在一起,Cosmopolitan Libc 是 C 标准库的直接替代品。

过去我花费了大量时间来设置 $PATH,将库放在正确的位置,安装依赖项,试图让 MSYS2 表现正常等等,以便在我机器上统一使用单个 APE 应用程序的便利性是一种感觉,“是的,事情应该如此。它应该如此简单。”

我希望您有同样的积极体验。

玩 Z-Machine 游戏

一个 z-machine 的预编译 APE 版本可在我的 github 上的 64 位系统上使用 以及有关如何使用它的说明。 Zork 三部曲的独立版本也可在那里获得,以展示 APE 格式的强大功能。 请记住,这个项目本质上反映了代码在 1985 年的状态; 我不对它的健壮性或准确性做任何保证! 但我认为这并不是真正检查它的原因。 如果您真的想玩互动小说,那么有许多比此端口更好的选择。

不,自己玩这个的原因是欣赏一个独特的历史时刻; 体验一下短暂地回到过去并与过去的重要对象建立联系的感觉。

我认为这并非没有价值。