手机上的快速复古游戏

模拟是使复古游戏成为可能的酷炫技术,即在现代设备上玩旧视频游戏。它允许像素爱好者重温过去的游戏体验。在本文中,我们将证明 Web 平台适合模拟,即使在移动设备上,在定义上一切都是有限的。

模拟是一项挑战

模拟包括在 JavaScript 中重新创建游戏控制台的所有内部结构。原始的 CPU 及其功能被完全重新实现。它与视频和声音单元通信,同时监听游戏手柄输入。

传统上,模拟器被构建为原生应用程序,但 Web 堆栈同样强大,前提是使用了正确的技术。在基于 Web 的操作系统(如 Firefox OS)上,进行复古游戏的唯一方法是使用 HTML 和 JavaScript。

模拟器是资源密集型应用程序。在移动设备上运行它们绝对是一个挑战。更重要的是,Firefox OS 旨在为计算资源进一步受限的低端设备提供动力。但不要害怕,因为有一些技术可以使在我们的心爱的掌上电脑上实现全速复古游戏成为现实。

起初是 ROM

视频游戏模拟从 ROM 映像文件(简称 ROM 文件)开始。ROM 文件是通过称为转储的过程获得的游戏卡带芯片的表示。在大多数视频游戏系统中,ROM 文件是一个包含游戏所有方面的单个二进制文件,包括

  • 逻辑(玩家移动、敌人的 AI、关卡设计……)
  • 角色和背景精灵
  • 音乐

现在让我们考虑 Sega Master System 和 Game Gear 游戏机。以自制游戏 Blockhead 为例,并检查文件开头

0xF3 0xED 0x56 0xC3 0x6F 0x00 0x3F 0x00 0x7D 0xD3 0xBF 0x7C 0xD3 0xBF 0xC9 0x00
0x7B 0xD3 0xBF 0x7A 0xD3 0xBF 0xC9 0x00 0xC9 0x70 0x72 0x6F 0x70 0x70 0x79 0x00
0xC9 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0xC9 0x62 0x6C 0x6F 0x63 0x6B 0x68 0x65

上面列出的元素混合在 ROM 中。难点在于区分不同的字节

  • 操作码(操作码,它们是 CPU 指令,类似于基本的 JavaScript 函数)
  • 操作数(可以将其视为传递给操作码的参数)
  • 数据(例如,游戏使用的精灵)

如果我们根据它们的类型以不同的方式突出显示这些元素,那么我们会得到

0xF3 0xED 0x56 0xC3 0x6F 0x00 0x3F 0x00 0x7D 0xD3 0xBF 0x7C 0xD3 0xBF 0xC9 0x00
0x7B 0xD3 0xBF 0x7A 0xD3 0xBF 0xC9 0x00 0xC9 0x70 0x72 0x6F 0x70 0x70 0x79 0x00
0xC9 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0xC9 0x62 0x6C 0x6F 0x63 0x6B 0x68 0x65
标题
操作码 操作数 数据

用解释器从小处开始

让我们开始播放这个 ROM,一次一条指令。首先,我们将二进制内容放入 ArrayBuffer(您可以使用 XMLHttpRequestFile API 来实现)。由于我们需要以不同的类型访问数据,例如 8 位或 16 位整数,最简单的方法是将此缓冲区传递给 DataView

在 Master System 中,入口点是位于索引 0 处的指令。我们创建一个名为 pc 的变量作为程序计数器,并将其设置为 0。它将跟踪当前指令的位置。然后,我们读取位于 pc 当前位置的 8 位无符号整数,并将其放入名为 opcode 的变量中。将执行与此操作码关联的指令。从那里,我们只需重复此过程。

var rom = new DataView(romBuffer);

var pc = 0x0000;
while (true) {
  var opcode = rom.getUint8(pc++);
  switch(opcode) {
    // ... more to come here!
  }
}

例如,位于索引 3 处的第 3 条指令的值为 0xC3。它匹配操作码 JP (nn)(JP 代表跳转)。跳转将程序的执行转移到 ROM 中的其他位置。在逻辑方面,这意味着更新 pc 的值。目标地址是操作数。我们只需将接下来的 2 个字节读取为 16 位无符号整数(在本例中为 0x006F)。让我们将其整合在一起

var rom = new DataView(romBuffer);

var pc = 0x0000;
while (true) {
  var opcode = rom.getUint8(pc++);
  switch(opcode) {
    case 0xC3:
      // Code for opcode 0xC3 `JP (nn)`.
      pc = rom.getUint16(pc);
      break;
    case 0xED:
      // @todo Write code for opcode 0xED 0x56 `IM 1`.
      break;
    case 0xF3:
      // @todo Write code for opcode 0xF3 `DI`.
      break;
  }
}

当然,为了简单起见,这里省略了许多细节。

以这种方式工作的模拟器称为解释器。它们相对容易开发,但获取/解码/执行循环会增加大量的开销。

重新编译,全速的秘诀

解释器只是快速模拟的第一步,使用它们可以确保其他一切正常:视频、声音和控制器。解释器在桌面上的速度可能足够快,但在移动设备上肯定太慢,并且会耗尽电池电量。

让我们退一步,再看看上面的代码。如果我们可以生成 JavaScript 代码来模仿逻辑,那不是很好吗?我们知道当 pc 等于 0x0000 时,接下来的 3 条指令将始终依次执行,直到到达跳转。

换句话说,我们想要类似这样的东西

var blocks = {
  0x0000: function() {
    // @todo Write code for opcode 0xF3 `DI`.
    // @todo Write code for opcode 0xED 0x56 `IM 1`.
    // Code for opcode 0xC3 `JP (nn)`.
    this.pc = 0x006F;
  },
  0x006F: function() {
    // @todo Write code for this opcode...
  }
};
pc = 0x0000;
while (true) {
  blocks[pc]();
}

这种技术称为重新编译。

它之所以快,是因为每个操作码和操作数在 JavaScript 代码编译时只读取一次。然后,JavaScript VM 更容易优化生成的代码。

当重新编译使用静态分析生成代码时,称为静态重新编译。另一方面,动态重新编译在运行时创建新的 JavaScript 函数。

jsSMS(我实现了这些技术的模拟器)中,重新编译器由 4 个组件组成

  • 解析器:确定 ROM 的哪个部分是操作码、操作数和数据
  • 分析器:将指令分组到块中(例如,跳转指令关闭一个块并打开一个新块)并输出 AST(抽象语法树)
  • 优化器:应用多个遍以使代码更快
  • 生成器:将 AST 转换为 JavaScript 代码

动态生成函数可能需要时间。这就是为什么其中一种方法是使用静态重新编译并在游戏开始之前生成尽可能多的 JavaScript 代码。然后,由于静态重新编译是有限的,因此无论何时在运行时找到未解析的指令,我们都会在游戏进行时生成新的函数。

所以它更快,但快多少呢?

根据我在移动设备上运行的基准测试,重新编译器比解释器快 3-4 倍。

以下是在不同的浏览器/设备对上的某些基准测试

  • Firefox OS v.1.1 Keon
  • Firefox OS v.1.1 Peak
  • Firefox 24 三星 Galaxy S II
  • Firefox 24 LG Nexus 4

优化注意事项

在开发 jsSMS 时,我应用了许多优化。当然,首先要做的是实现这 关于 Firefox OS 游戏的文章 中建议的改进。

在更具体地说明之前,请记住,模拟器是一种非常特殊的类型的游戏应用程序。它们具有有限数量的变量和对象。这种架构是静态的、有限的,因此易于优化性能。

尽可能使用类型化数组

旧游戏机的资源是有限的,大多数概念都可以映射到类型化数组(堆栈、屏幕数据、声音缓冲区……)。使用此类数组使 VM 更容易优化。

使用密集数组

密集数组是没有空洞的数组。最常见的方法是在创建时设置长度并用默认值填充它。当然,它不适用于大小未知或可变的数组。

// Create an array of 255 items and prefill it with empty strings.
var denseArray = new Array(255);
for (var i = 0; i < 255; i++) {
  denseArray[i] = '';
}

变量应具有类型稳定性

JavaScript VM 的类型推断器用它们的类型标记变量,并使用此信息来应用优化。您可以通过在游戏运行时不更改变量的类型来帮助它。这意味着以下后果

  • 在声明时设置默认值。使用“var a = 0;”而不是“var a;”。否则,VM 会认为变量可以是数字或未定义。
  • 避免为不同类型循环使用一个变量。例如,数字然后是字符串。
  • 使布尔变量成为真正的布尔值。避免使用真值或假值,并使用“!!”或“Boolean()”进行强制转换。

某些语法对于 VM 来说是模棱两可的。例如,以下代码被 SpiderMonkey 标记为未知算术类型

pc += d < 128 ? d : d - 256;

一个简单的解决方法是将其重写为

if (d >= 128) {
  d = d - 256;
}
pc += d;

保持数值类型稳定

SpiderMonkey 以不同的方式存储所有 JavaScript 数值,具体取决于它们的外观。它试图将数字映射到内部类型(如 u32 或浮点数)。这意味着保持相同的底层类型很可能对 VM 有帮助。

为了解决这些类型更改,我曾经使用 JIT 检查器,这是一个 Firefox 扩展,它公开了 SpiderMonkey 的一些内部结构。但是,它与最新版本的 Firefox 不兼容,并且不再产生有用的输出。有一个错误可以跟踪该问题,但不要期望很快会有任何更改:https://bugzilla.mozilla.org/show_bug.cgi?id=861069

… 和往常一样,分析和优化

使用 JavaScript 分析器将帮助您找到最常调用的函数。这些是您应该首先关注和优化的函数。

深入代码

如果您想了解有关移动模拟和重新编译的更多信息,请查看此演讲,其中幻灯片实际上是在模拟器中运行的 ROM!

结论

移动模拟展示了 Web 平台的速度有多快,即使在低端设备上也是如此。使用正确的技术并应用优化可以让您的游戏流畅运行并达到全速。网络上关于浏览器模拟的文档很少,尤其是在使用现代 JavaScript API 的情况下。希望本文能够解决这一不足。

有如此多的视频游戏机,而基于 Web 的模拟器却很少,所以现在,抛开理论,让我们开始为复古游戏制作应用程序吧!

关于 Guillaume Cedric Marty

Guillaume 在 Web 行业工作了十多年。他对 Web 技术充满热情,并定期为开源项目做出贡献,他在自己的 技术博客 上撰写了这些项目的文章。他还痴迷于视频游戏、动画,并且作为日语使用者,还痴迷于外语。

Guillaume Cedric Marty 的更多文章…

关于 Robert Nyman [荣誉编辑]

Mozilla Hacks 的技术布道师和编辑。发表演讲并撰写有关 HTML5、JavaScript 和开放 Web 的博客文章。Robert 坚定地相信 HTML5 和开放 Web,并且自 1999 年以来一直在从事 Web 前端开发工作——在瑞典和纽约市。他还在 http://robertnyman.com 上定期撰写博客文章,并且喜欢旅行和结识新朋友。

Robert Nyman [荣誉编辑] 的更多文章…


11 条评论

  1. 这两个很好的例子是 GameBoy Color 的模拟器,位于 https://github.com/grantgalitz/GameBoy-Online 和 GameBoy Advance 的模拟器,位于 https://github.com/grantgalitz/IodineGBA/

    2013年10月22日 13:24

  2. Mindaugas J.

    发布的示例假设JP代码的操作数是小端序,但显示的用于检索它的JS代码并未反映这一点。

    2013年10月22日 18:04

    1. Guillaume Cedric Marty

      确实,字节序是我决定忽略的细节的一部分。但你说得对,当查询DataView时,我们必须提供`true`作为第二个参数:`rom.getUint16(pc, true)`。

      2013年10月25日 12:51

  3. Nick Fitzgerald

    小细节:你的密集数组初始化不起作用,因为map/forEach/等不会遍历不存在的属性,而new Array(n)不会创建属性0..n-1。

    http://i.imgur.com/bDkaa2m.png

    2013年10月22日 21:18

    1. Guillaume Cedric Marty

      抓住了!我更新了该部分。

      2013年10月25日 12:40

  4. Nick Fitzgerald

    很喜欢这篇文章!

    2013年10月22日 21:19

  5. Grant Galitz

    对于

    pc += d < 128 ? d : d – 256;

    是否可以重写为

    pc += (d <> 24;

    如果你试图将一个8位无符号数转换为8位有符号数,这是一种无需任何分支的方法(现代处理器不喜欢分支,但喜欢纯算术)。

    2013年10月26日 11:25

  6. Grant Galitz

    我认为,如果你要处理音频支持,请考虑使用Web Audio API。Firefox刚刚开始支持(WebKit已支持超过1年)它。

    2013年10月26日 11:37

  7. Grant Galitz

    抱歉,我的评论被注释解析器弄乱了,它将移位运算符视为注释本身的一些缩进或样式控制。基本上左移24位,然后右移24位要转换的数字,以便在javascript中将其从无符号8位转换为有符号8位。

    pc += (d “LSL” 24) “ASR” 24;

    2013年10月27日 19:52

  8. Martin Buchner

    不应该说
    “例如,位于索引3处的第4条指令,…”
    代替
    “例如,位于索引3处的第3条指令,…”?

    2013年11月21日 03:05

    1. Guillaume Cedric Marty

      Z80架构具有前缀指令,而0xED 0x56是一个具有特定操作码的单一指令。这就是为什么这些单元格在第二个表中合并的原因。
      这是我省略提及的细节的一部分,因为在本文的上下文中并没有真正用处。

      2013年11月21日 03:23

本文的评论已关闭。