NES / Famicom / Dandy对网络技术的仿真。Yandex报告

TypeScript,Canvas和Web Audio堆栈使您可以使用Web技术来模拟计算机系统。在我的报告中,以NES机顶盒为例,我讲述了计算机的体系结构是如何安排的-处理器,程序,外围设备,映射到内存的I / O。





该报告可以分为三个部分:



  1. 6502处理器的工作方式以及如何使用JavaScript进行仿真,
  2. 图形输出设备如何工作以及游戏如何存储其资源,
  3. 如何使用网络音频合成声音,以及如何使用音频Orlet将声音并行化为两个流。


我试图给出优化建议。尽管如此,仿真才是关键,在60 FPS时,几乎没有时间执行代码。



-大家好,我叫振亚。现在,在星期六将有一个关于这个项目的不寻常的小话题,讨论了许多星期六。让我们谈谈计算机系统的仿真,它可以在现有Web技术的基础上实现。实际上,网络上已经有很多工具,您可以做一些绝对令人惊奇的事情。更具体地说,我们将与所有人(可能是90年代著名的Dandy控制台)谈论模拟器,该控制台实际上称为Nintendo Entertainment System。







让我们记住一些历史。它始于1983年,当时Famicom在日本问世。它由任天堂发布。 1985年,发布了美国版,称为Nintendo Entertainment System。在上世纪90年代,我们在台湾地区拥有一个叫做丹迪的地区,但秘密地,这是一个非正式的前缀。任天堂最近一次提供这种铁礼物是在2016年,当NES mini出现时。不幸的是,我没有NES mini。有SNES mini,超级任天堂。看看有什么小事,在这张幻灯片上,您可以看到摩尔定律的所有荣耀。



如果我们看一下1985年以及控制台与操纵杆的比例,那么在2016年,我们可以看到一切都变得更小了,因为人们的手没有改变,操纵杆无法做得更小,但是控制台本身却变得很小。



正如我们已经注意到的,有很多模拟器。我们没有说出来,但至少有一位官员注意到了。 SNES mini或NES mini这个东西并不是真正的机顶盒。这是模拟控制台的硬件。也就是说,实际上,这是一个官方模拟器,但是它以这种有趣的铁形式出现。



但据我们所知,自2000年代以来,有一些程序可以模拟NES,因此,我们仍然可以享受那个时代的游戏。并且有许多模拟器。为什么还要问另一个问题,尤其是JavaScript语言?当我做这个事情时,我为自己找到了三个答案。



  1. , . - , . . , - , - . . . , , . , -.
  2. , , . , , , , NES — , , NTSC, 60 . 16 , . .
  3. . , . , , . , , — , . . , , .


我还观看了Matt Godbold的演讲,他还谈到了模拟运行NES的处理器。他说,我们正在用如此高级的语言来模仿如此低级的东西,这很有趣。我们无权使用硬件,我们是间接工作的。







让我们继续考虑将要仿真的东西,将如何仿真的东西等等。我们将从处理器开始。 NES本身是标志性的。对于俄罗斯来说,这是可以理解的,这是一种文化现象。但是在西方和东方,在日本,这也是一种文化现象,因为游戏机实际上拯救了整个家庭视频游戏产业。



该处理器也安装在标志性的MOS6502中。它的意义是什么?在它出现时,其竞争对手的价格为180美元,MOS6502的价格为25美元。也就是说,该处理器掀起了个人计算机革命。这里有两台电脑。第一个是Apple II,我们都知道并想像到这一事件对个人计算机世界的重要性。



还有一台BBC Micro计算机。他在英国更受欢迎,英国广播公司是一家英国电视公司。也就是说,这种处理器将计算机带入了大众,由于它,我们现在已经成为程序员,前端开发人员。



让我们看一下最小程序。我们需要什么来制作计算系统?







CPU本身是一个非常无用的设备。众所周知,CPU执行程序。但是至少为了将该程序存储在某个位置,需要内存。并且,当然,它包含在最低程序中。我们的内存由八位元组成,称为字节。



在JavaScript中,我们可以使用类型化的Uint8Array数组来模拟此内存,即可以分配一个数组。



为了使内存与处理器接口,有一条总线。总线允许处理器通过地址来寻址存储器。地址不再像数据一样由8位组成,而是由16位组成,这使我们能够寻址64 KB的内存。







处理器中存在某种状态,其中包含三个寄存器-A,X,Y。寄存器就像中间值的存储区。寄存器大小为一个字节或八位。这告诉我们处理器是八位的,它对八位的数据进行操作。



使用寄存器的示例。我们想加两个数字,但是内存中只有一个总线。原来,您需要将第一个数字存储在两者之间。我们将其保存在寄存器A中,我们可以从内存中获取第二个值,然后将它们相加,然后将结果再次放入寄存器A中。从



功能上讲,这些寄存器是非常独立的-可以用作通用寄存器。但是它们有一个含义,例如加法,结果在寄存器A中获得,并取第一个操作数的值。



或者,例如,我们处理数据。我们稍后再讨论。我们可以指定偏移量寻址模式,并使用X寄存器获取最终值。



处理器状态还包括什么?由于该地址是两个字节,所以有一个PC寄存器指向当前命令的地址。



我们还有状态寄存器,用于指示状态标志。例如,如果我们减去两个值并得到负值,则标志寄存器中的某个位将点亮。



最后是SP,它是指向堆栈的指针。堆栈只是普通的内存,并未与其他所有程序分开。仅处理器发出一条控制该SP指针的指令。这就是实现堆栈的方式。然后,我们将看一个导致这种有趣解决方案的伟大计算机思想。







现在我们知道处理器中有一个处理器,内存,状态。让我们看看我们的程序是什么。这是一个字节序列。它甚至不必保持一致。程序本身可以位于内存的不同部分。



我们可以想象一个程序,我在这里有一段代码-1,2,3,4,5,6,7,8,9,10这是一个真正的6502程序,该程序的每个字节,数组中的每个数字都是实体作为操作码。操作码-操作码。 “然后再说一个普通数字。



例如,有一个操作码169。它本身编码两件事-第一,一条指令。执行后,指令将更改处理器,内存等的状态,即系统的状态。例如,我们将两个数字相加,结果将出现在寄存器A中。这是一个示例指令。我们还有LDA指令,我们将对其进行更详细的考虑。它将一个值从内存加载到寄存器A中。



操作码编码的第二件事是寻址模式。他给出了有关从何处获取数据的说明。例如,如果这是IMM寻址模式,那么它说:获取当前程序计数器旁边单元中的数据。我们还将看到此模式如何工作以及如何在JavaScript中实现。



这样的程序。除了这些字节以外,所有内容都与JavaScript非常相似,只是在较低级别上。







如果您还记得我在说什么,可能会有一个有趣的悖论。事实证明,我们也将程序和数据存储在内存中。有人可能会问这个问题:程序可以充当数据吗?答案是肯定的。在执行该程序时,我们可以从程序本身进行更改。



或另一个问题:数据可以是程序吗?是的处理器无关紧要。他就像磨坊一样,磨碎送给他的字节并遵循指示。一个自相矛盾的事情。如果您考虑一下,那就太不安全了。您可以开始执行仅是堆栈中数据的程序,依此类推,但是这样做的好处是非常容易。无需进行复杂的电路。



这是我们今天遇到的第一个好主意。它被称为冯·诺依曼架构。但是实际上那里有很多合著者。



这里说明。有程序1,操作码169,然后是10,一些数据。好的。也可以这样查看该程序:169是数据,而10是操作码。这将是6502的合法程序。整个程序同样可以视为数据。



如果我们有一个编译器,我们可以构建一些东西,将其放在这块内存中,这将是一件很有趣的事情。



让我们看一下程序的第一部分-指令。







6502提供对73条指令的访问,包括算术:加法,减法。没有乘法和除法,对不起。有位操作,它们是关于以八位字操作位。



在我们的前端禁止进行跳转:jump语句,该语句仅将程序计数器转移到代码的某些部分。在编程中这是禁止的,但是如果您处理的是低级,这是进行分支的唯一方法。堆栈中有一些操作,等等。它们被分组。是的,我们有73条说明,但是如果您查看这些组及其用途,实际上它们并不多,而且它们都非常相似。



让我们回到LDA指令。正如我们所说,这是“将值从内存加载到寄存器A”。这就是JavaScript多么简单。入口处是寻址模式提供给我们的地址。我们更改内部状态,我们说this._a等于从内存中读取的值。



我们仍然需要在状态寄存器中设置这两个位字段-零标志和负标志。这里有很多按位的东西。但是,如果您创建了仿真器,则处理这些OR,负数等将成为您的第二自然。这里唯一可笑的是,第二个分支中有这样的256%。它再次向我们指出了我们钟爱的JavaScript语言的性质,即它没有类型化的值。我们输入Status的值可以超过256,可以容纳一个字节。我们必须应对这些技巧。



现在让我们看一下操作码的最后一部分,即寻址模式。







我们有12种寻址模式。如前所述,它们使我们能够获取并指示从何处获取数据的指令。



让我们看一下三件事。最后一个是ABS,绝对寻址模式,让我们开始吧,很抱歉给您带来一点尴尬。他做了这样的事情。我们给他完整的地址(16位)作为输入。他从这个存储单元中获取了我们的价值。在汇编器的第二列中,您可以看到它的外观:LDA $ ccbb。 ccbb是一个十六进制数,一个普通数,只是用不同的符号表示。如果您在这里不舒服,请记住,这只是一个数字。



在第三列中,您可以看到它在机器代码中的外观。前面是操作码-173,以蓝色突出显示。 187和204已经是地址数据。但是由于我们使用的是八位值,因此我们需要两个存储器位置来写入地址。



我也忘记说操作码在CPU上执行了一段时间,这有一定的成本。具有绝对寻址功能的LDA需要四个CPU周期。



在这里您已经了解了为什么需要这么多寻址模式。考虑下一个寻址模式ZP0。这是页面零寻址模式。第零页是在内存中分配的前256个字节。这些是从零到255的地址。



在汇编器中,同样,LDA * 10。此寻址模式有什么作用?他说:转到第0页,这里是前256个字节,带有这样的偏移量。在这种情况下,取10,然后从那里取值。在这里,我们已经注意到寻址模式之间的显着差异。



在绝对寻址的情况下,我们首先需要三个字节来编写这样的程序。其次,我们需要四个CPU周期。在ZP0寻址模式下,仅花费了三个CPU周期和两个字节。但是,是的,我们失去了灵活性。也就是说,我们只能将数据放在第一页上。



最终寻址模式IMM表示:从操作码旁边的单元中获取数据。汇编程序中的LDA#10可以做到这一点。结果发现程序看起来像[169,10]。它已经需要两个CPU周期。但是很显然,我们也失去了灵活性,我们需要操作码位于数据旁边。



在JavaScript中实现此操作很容易。这是一些示例代码。有一个地址。这是IMM寻址,它从程序计数器获取数据。我们简单地说,我们的地址是一个程序计数器,并将其加1,以便下次执行该程序时,它将跳转到下一条指令。



这是一件有趣的事。现在,我们可以像前端开发人员一样阅读机器代码。而且,我们甚至知道如何查看汇编器中的内容。







原则上我们已经知道我们需要的一切。有一个程序,它由字节组成。每个字节是一个操作码,每个操作码是一条指令,依此类推,让我们看看程序是如何执行的。并且仅在这些CPU周期中执行。



怎样编写这样的代码?例。我们需要从软件计数器中读取操作码,然后将其增加一。现在我们需要将此操作码解码为指令和寻址模式。如果您考虑一下,则操作码是质数169。在一个字节中,我们只有256个数字。我们可以使数组具有256个值。该数组的每个元素将简单地向我们指示要使用的指令,所需的寻址模式以及所需的周期。也就是说,它非常简单。我拥有的阵列只是处于处理器状态。



接下来,我们仅在第36行执行寻址模式的功能,这将为我们提供地址,并向其提供指令。



我们需要做的最后一件事是处理循环。 opcodeResolver返回周期数,我们将其写入剩余的Cyclees变量。我们查看每个处理器周期:如果还剩零个周期,那么我们可以执行下一条命令,如果它大于零,则只需将其减少一即可。就是这么简单。这就是在6502上执行程序的方式。







但是,正如我们已经说过的,程序可以位于内存的不同部分,具有不同的比率等。处理器如何理解在哪里开始执行该程序?我们需要一个来自C语言世界的int主体。



实际上,一切都很简单。处理器具有重置其状态的过程。在此过程中,我们从地址0xfffxc获取初始命令的地址。 0xfffxc还是十六进制数。如果您感到不舒服,请打分数,这是通常的数字。这就是通过0x用JavaScript编写它们的方式。



我们需要读取两个字节的地址,该地址是16位。我们从该地址读取低字节,从下一个地址读取高字节。然后,我们将这种情况与位操作的魔术结合起来。此外,重置处理器状态还会重置寄存器A,X,Y,指向堆栈的指针,状态中的值。重置需要八个周期。就是这样。







现在我们已经知道了一切。老实说,我很难写所有这些内容,因为我根本不知道如何测试它。我们正在编写一台可以运行为其创建的任何程序的计算机。如何理解我们的行动正确?



有一种极好的方法!我们采用两个CPU。第一个是我们制造的,第二个是参考CPU,我们可以肯定它运行良好。例如,有一个用于NES的仿真器nintendulator,它被认为是CPU的基准。



我们采用某个测试程序,在参考CPU上执行该程序,并将每个命令的处理器状态写入状态日志。然后,我们采用该程序并在CPU上执行它。并将每个命令后的每个状态与此日志进行比较。超级好主意!



当然,我们不需要CPU参考。我们只需要一个程序执行日志。该日志可以在Nesdev上找到。实际上,我不知道可以在周末的几天内编写处理器仿真器,这真是太好了!



就这样。我们获取日志,比较状态,然后进行交互式测试。我们执行第一个命令,但它不在我们正在开发的处理器中实现。我们实现它,转到日志的下一行,然后再次实现它。超级快!让您快速移动。



NES体系结构



现在,我们有了一个CPU,它实际上是计算机的心脏。我们可以看到NES本身的体系结构是由什么构成的,以及这种复杂的复合计算机系统是如何构成的。因为如果您考虑一下,那就好了,有CPU,有内存。我们可以接收值,记录等。







但是在NES的任何机顶盒中,也都有屏幕,声音设备等。我们需要学习如何使用外围设备。您甚至不需要为此学习任何新知识,我们的巴士的概念就足够了。这可能是第二个如此绝妙的主意,这是我在编写模拟器的过程中为自己做出的绝妙发现。



让我们想象一下,我们将64 KB的内存取为两个32 KB的范围。在较低的范围内,将有一个特定的设备,该设备是灯泡阵列,如此板的图片所示。



假设当写入此32兆字节的范围时,指示灯将打开或熄灭。如果我们在此处写入值1,则灯亮,如果为0,则熄灭。同时,我们可以读取该值并了解系统状态,了解该屏幕上显示的图片。



同样,在地址的高位范围内,我们将程序所在的普通存储器放入其中,因为在复位过程中我们需要在地址的高位范围内。



这实际上是一个超级天才的想法。与外围设备进行交互,不需要其他命令,等等。我们只是像以前一样写入旧内存。但是同时,该内存可能已经是其他设备。







现在,我们已准备就绪,可以看一下NES体系结构。和往常一样,我们有一个CPU及其总线。还有两个额外的千字节内存。有一个APU-声音输出设备。不幸的是,现在我们不会考虑它,但是那里的一切也都很棒。还有一个墨盒。它放置在较高范围内并提供程序数据。他还提供了这些图形,现在我们将考虑。 CPU总线上的最后一件事是PPU,即图像处理单元,例如原始视频卡。如果您想学习如何使用视频卡,我们现在甚至将学习如何实现视频卡。



PPU也有自己的总线,在该总线上可以移动名称表,调色板和图形数据。但是图形数据来自墨盒。然后是对象的内存。这是架构。







让我们看看什么是墨盒。当您认为CD是过去的光盘时,这比CD的想法要酷得多。



她为什么很酷?在左侧,我们可以看到美国地区的弹药筒,即著名的游戏《塞尔达传说》,如果没有人玩过-超级。而且,如果我们拆开墨盒,我们会发现其中有微电路。没有激光盘等。通常,这些芯片仅包含一些数据。而且,墨盒直接切入我们的计算机系统,CPU和PPU总线。它使您可以做出色的事情并增强用户体验。



墨盒上有一个映射器,其中填充了地址的翻译。假设我们有一个大比赛。但是NES只能为程序寻址32 KB的内存。假设一个游戏是128 KB。映射器可以在程序执行过程中即时用全新的数据替换一定范围的内存。我们可以在程序中说:将我们加载到第2级,内存将立即被立即替换。



另外还有有趣的事情。例如,映射器可以提供扩展音轨,添加新音轨等的芯片。如果您曾玩过恶魔城,请听听日本地区恶魔城的声音。还有一种声音,听起来完全不同。在这种情况下,所有操作均在同一硬件上执行。也就是说,这种想法更类似于您购买视频卡,将其插入计算机并具有其他功能的想法。这里是一样的。那很棒。但是我们只能使用CD。







让我们继续最后一部分-让我们看一下这个图像输出设备的工作方式。因为如果您想制作一个模拟器,那么最小的程序就是制作一个处理器,用这个东西来观看图片和视频游戏的外观。



让我们从顶层实体开始-图片本身。它有两个计划。在前景放置更多动态实体,在背景放置更多静态实体(如场景)。



您可以在此处查看拆分。左边是同样著名的恶魔城游戏,因此我们通往PPU的整个旅程将由Simon Belmont完成。我们将与他一起考虑一切运作方式。



有背景,专栏等。我们看到它们是在背景中绘制的,但同时所有字符(Simon本人(左,棕色)和鬼影)都已经在前景中绘制了。也就是说,对于更多动态实体存在前景,而对于更多静态实体存在背景。







位图显示上的图片由像素组成。像素只是彩色的点。至少,我们需要颜色。 NES有一个系统选板。它由64种颜色组成,很遗憾,这是NES能够复制的所有颜色。但是我们不能从调色板中选取任何颜色。对于自定义调色板,内存中有一个特定范围,该范围又又分为两个这样的子范围。



背景和前景范围很广。每个范围分为四种颜色的四个调色板。例如,背景零调色板由白色,蓝色,红色组成。每个调色板中的第四种颜色始终是指透明颜色,这使我们可以制作透明像素。







带有调色板的范围不再位于CPU总线上,而是位于PPU总线上。让我们看看如何在其中写入数据,因为我们无法通过CPU总线访问PPU总线。



这里我们再次回到内存映射I / O的想法。地址为0x2006和0x2007,它们是十六进制地址,但它们只是数字。我们这样写。因为我们的地址是16位的,所以我们用两种方法将地址写入ox2006地址寄存器(八位),然后可以通过地址0x2007写入数据。真有趣。也就是说,实际上,我们需要执行三个操作,以便至少将某些内容写入调色板。







优秀的。我们有一个调色板,但是我们需要结构。颜色总是好的,但是位图具有一定的结构。



对于图形,有两个表,每个表四个KB,每个图块包含图块。所有这些记忆都是一种地图集。以前,当每个人都使用光栅图像时,他们会制作一个大图集,然后从中通过坐标通过背景图像选择必要的图像。这是相同的想法。



每个桌子有256个磁贴。同样,有趣的命理:精确地256允许您指定一个字节,256个不同的值。也就是说,我们可以在一个字节中指定所需的任何图块。原来有两个表。一个表用于背景,另一个表用于前景。







让我们看看这些磁贴是如何存储的。这里也很有趣。请记住,我们的调色板中有四种颜色。再次是命理学:一个字节有8位,而图块是8乘8。事实证明,使用一个字节,我们可以代表一块瓷砖,其中每一位将负责某种颜色。有了八个字节,我们就可以代表一个完整的八乘八图块。



但是这里有一个问题。就像我们说的,一位负责颜色,但是它只能代表两个值。磁贴存储在两个平面中。有一个最高有效位和最低有效位的平面。为了获得最终的颜色,我们结合了两个平面的数据。



您可以考虑-例如,在字母``I''的下部,数字为``3'',结果如下所示:我们采用最低有效位和最高有效位的平面并获得二进制数11,该二进制数等于十进制3.如此有趣的数据结构。



背景



现在我们终于可以渲染背景了!







有一个名称表。我们有两个,每个960字节,每个字节将我们指向特定的图块。即,在上表中指示了瓦片标识符。如果将这些960字节表示为矩阵,则会得到32 x 30的平铺屏幕。 NES分辨率将为256 x 240像素。



优秀的。我们可以在那里写图块。但是,您可能已经注意到,图块并不表示应与之一起显示的调色板。我们可以使用不同的调色板显示不同的图块,并且我们还需要将此信息存储在某个地方。不幸的是,每个名称表只有64个字节用于存储调色板信息。



这就是问题所在。如果我们进一步拆分表格,使其只有64个值,那么我们将得到四乘四的正方形,看起来像是一个红色正方形。这只是屏幕的很大一部分。如果不是,她将服从一个调色板。



我们记得,子选板中有四个选板,我们只需要两位来表示我们需要的一位。这64个字节中的每个字节都复制四乘四网格的调色板信息。但是,该网格仍然被两个两个地分成子网格。当然,有一个局限性:将2乘2的网格绑定到一个调色板。这些都是在Nintendo上显示背景的限制。有趣的事实,但总的来说,它并不会真正干扰游戏。







也有滚动。例如,如果我们回想起“马里奥”或《恶魔城》,我们就会知道:如果英雄在这些游戏中向右移动,那么世界似乎就沿着屏幕展开。这是通过滚动完成的。



回想一下,我们有两个已经对两个屏幕进行编码的名称表。当我们的英雄移动时,我们会在后面的名称表中添加数据。即时移动,当我们的英雄移动时,我们填写名称表。事实证明,我们可以指示从名称表中的哪个图块开始显示数据,然后将其展开成条状。滚动的整个技巧是从两个名称表中读取。



也就是说,如果我们横向超出一个名称表,那么我们将开始自动从另一个名称表中读取数据,以此类推。不要忘记再次填写数据。



顺便说一下,滚动在当时是一件非常大的事情。约翰·卡马克(John Carmack)的第一个成就是在滚动领域。看看这个故事,这很有趣。



前景



和前景。正如我们所说的,在前台有动态实体,它们存储在对象和属性的内存中。







其中有256个字节可以写入64个对象,每个对象四个字节。每个对象都对X和Y进行编码,这是屏幕上的像素偏移。加上图块的地址和属性。我们可以优先考虑背景,请看下面的图片?我们可以指定调色板。优先于背景告诉PPU,背景应该绘制在子画面的顶部。这使我们可以将西蒙放在雕像后面。



我们还可以进行定位,使其绕任何轴旋转,例如水平,垂直,如图片中的字母“ I”。我们用与调色板大致相同的方式编写:通过地址0x2003、0x2004。



最后,最后。我们如何渲染前景对象?







图片沿着称为扫描线的线展开,这是电视术语。在每条扫描线之前,我们只需从对象和属性存储器中抓取8个精灵即可。不超过八个,仅支持八个。也有这样的限制。例如,我们仅逐行显示它们,例如此处。在当前的扫描线上,以黄色显示条纹中的云彩,太阳和心脏。并且不显示笑脸。但是他仍然很高兴。



查看“单机编码器”超级频道特别是在编程过程中-对NES仿真器进行编程。Nesdev包含仿真的所有信息-什么,它的组成等。最后一个环节是我的仿真器的代码看一下是否有兴趣。用TypeScript编写。



谢谢。希望你喜欢。



All Articles