Version 1.23
如今,对 Core War 这款游戏感兴趣的初学者并不多。当然,这也很自然——毕竟,没有多少人会觉得优化汇编代码是一件有趣的事——但入门门槛高的一个原因可能是,很难找到关于游戏基础知识的信息。诚然,市面上不乏优秀的文档,但它们大多要么技术性太强,要么已经过时,要么难以找到,要么干脆不完整。
正因如此,我决定撰写本指南。我的目标是引导新手从首次接触 Core War 和 Redcode 开始,直至他们能够编写一个能够运行的(即使不完美)战士,并能够进一步学习更具技术性的内容。
说实话,我自己在这款游戏里还是个新手。我虽然对语言掌握得相当不错,但还没能练出一个真正厉害的战士。不过,我决定不再等到自己更有经验了,而是趁自己还对新手玩家努力理解游戏特性的过程记忆犹新,尽快写下这份攻略。
本指南面向初学者。读者无需具备任何汇编语言(或一般编程)的先验知识,但了解其基本概念将有助于理解基本术语。Redcode,尤其是现代版本,可能看起来像某种汇编代码,但它比大多数汇编代码更为抽象,并且在细节上与其他汇编语言大不相同。
本指南中使用的 Redcode 版本(大部分)是当前事实上的标准,即带有 pMARS 0.8 扩展的 ICWS ‘94 标准草案 (译注:因为到后面 ICWS 基本死了,于是这个草案到如今也没有通过)。(有点像 Netscape 对 HTML 的扩展……额……幸运的是,我们还没有微软的 Corewar 模拟器。也许他们认为市场太小了。)本指南将简要提及较早的’88 标准,但主要关注的是’94 标准。对于那些想学习的人来说,网上有很多关于’88 标准的教程。
重要:以严格线性的方式教授 Redcode(或任何编程语言)并无捷径。尽管我试图将本指南按某种合理的顺序组织起来,但如果你想跳着看,也请随意。目录部分就是为你准备的。
为了保持整体的一致性,我经常不得不在向你展示某些内容后,隔几章再对其进行解释。如果你似乎不明白某件事,请继续往下读。如果你仍然无法理解,可以尝试浏览一下,看看是否在其他章节中有解释。
每个人的学习方式都各不相同,因此,无论你决定以何种顺序阅读这些章节,可能都会比我选择的顺序更好。但是,如果你觉得某部分内容枯燥乏味,就完全跳过不读,那么你可能会错过一些重要的信息。我已在重要部分做了强调标记,以便你知道在哪里停下来思考,但请务必仔细阅读所有内容。我无法将所有内容都详细阐述,否则本指南会变得太长,难以阅读。
Core War(或Core Wars)是一款编程游戏,游戏中汇编程序试图在模拟计算机的内存中互相摧毁。程序(或称warriors,战士)是用一种名为Redcode的特殊语言编写的,并由一个名为MARS(Memory Array Redcode Simulator,内存阵列 Redcode 模拟器)的程序运行。
与普通计算机系统相比,Redcode 和 MARS 环境都得到了大幅简化和抽象。这是一件好事,因为 CW 程序是为性能而编写,而非为清晰性。如果游戏使用普通的汇编语言,世界上可能只有两三个人能够编写出有效且耐用的战士,而且他们可能也无法完全理解。这无疑具有挑战性且充满潜力,但要达到中等水平可能需要数年时间。
程序运行的体系相当简单。核心(core,模拟计算机的内存)是一个连续的指令数组,除了相互竞争的程序外,其余部分均为空。核心是循环的,因此在执行完最后一个指令后,又会再次执行第一个指令。
事实上,由于没有绝对地址,程序无法判断内核的结束位置。也就是说,地址 0 并不代表内存中的第一条指令,而是代表包含该地址的指令。下一条指令是 1,而前一条指令显然是-1。
如你所见,在 Core War 中,内存的基本单位是一条指令,而非通常的一个字节。每条 Redcode 指令包含三个部分:操作码(OpCode)本身、源地址(即A-field,A 字段)和目标地址(即B-field,B 字段)。虽然可以在 A 字段和 B 字段之间移动数据,但通常需要将指令视为不可分割的块。
程序的执行也同样简单。MARS 一次执行一条指令,然后继续执行内存中的下一条指令,除非指令明确告诉它跳转到另一个地址。如果正在运行多个程序(通常情况就是这样),这些程序会交替执行,一次执行一条指令。每条指令的执行时间相同,都是一个周期,无论是执行MOV、DIV还是DAT(这会终止进程)。
Redcode 中的指令数量随着每个新标准的出现而增加,从最初的约 5 个增加到现在的 18 或 19 个。而这还不包括新的修饰符和寻址模式,这些模式实际上允许数百种组合。幸运的是,我们不需要学习所有的组合。只需记住指令以及修饰符如何改变它们就足够了。
以下是 Redcode 中使用的所有指令列表:
DAT — 数据(data,终止进程)MOV — 移动(move,将数据从一个地址复制到另一个地址)ADD — 加法(add,将一个数加到另一个数上)SUB — 减法(subtract,从一个数中减去另一个数)MUL — 乘法(multiply,将一个数乘以另一个数)DIV — 除法(divide,用一个数除以另一个数)MOD — 取模(modulus,用一个数除以另一个数并返回余数)JMP — 跳转(jump,从另一个地址继续执行)JMZ — 若为零则跳转(jump if zero,检测一个数字,如果它是 0,则跳转到某个地址)JMN — 非零跳转(jump if not zero,检测一个数字,若非 0 则跳转)DJN — 减量且若非零则跳转(decrement and jump if not zero,将一个数减一,除非结果为 0,否则跳转)SPL — 分裂(split,在另一个地址启动第二个进程)CMP — 比较(compare,与SEQ相同) (译注:属于‘88 标准的历史遗留问题)SEQ — 若相等则跳过(skip if equal,比较两条指令,若相等则跳过下一条指令)SNE — 如果不相等则跳过(skip if not equal,比较两条指令,如果不相等则跳过下一条指令)SLT — 小于跳转(skip if lower than,比较两个值,如果第一个值小于第二个值,则跳转到下一条指令)LDP — 从 p 空间加载(load from p-space,从私有存储空间加载一个数字)STP — 保存到 p 空间(save to p-space,将数字保存到私有存储空间)NOP — 无操作(no operation,不执行任何操作)事实上,Redcode 中最重要的部分恰恰是最简单的部分。大多数基本的战士类型是在新的指令和模式出现之前就已发明的。最简单,也可能是最早的 Core War 程序是Imp(小鬼),由 A. K. Dewdney 在 1984 年《科学美国人》(Scientific American)杂志上发表的那篇首次向公众介绍 Core War 的文章中公布。
MOV 0, 1
是的,就是这样。只是一个糟糕的MOV指令。但这会发生什么?当然,MOV指令是复制一条指令。你应该记得,在 Core War 中,所有地址都是相对于当前指令的,所以 Imp 实际上是将自己复制到紧随其后的指令位置。
MOV 0, 1 ; 这条指令刚刚被执行
MOV 0, 1 ; 这条指令将紧接着执行
现在,Imp 将执行它刚刚写下的指令!由于这个指令与第一个完全相同,它将再次向前复制自己一条指令,执行复制后的指令,并继续向前移动,同时用MOV填充核心。由于核心没有实际的终点,Imp 在填充完整个核心后,会再次回到起始位置,并快乐地运行着,永无止境。
因此,在执行代码时,Imp 实际上是在创建自己的代码!在 Core War 中,自我修改是一种规则而非例外。要想成功,就必须高效,而这几乎总是意味着要即时更改代码。幸运的是,抽象环境使得这一点比在普通汇编中更容易遵循。
顺便说一句,应该很明显的是,Core War 中没有缓存。好吧,实际上当前的指令是缓存的,所以在执行过程中你不能修改它 (译注:指正在运行的、读取的缓存等不会被修改,但内存上的指令仍会被修改),但也许我们应该把所有这些留到后面再说……
Imp 作为战士有一个小缺点。它不会赢得太多游戏,因为当它覆盖另一个战士时,它也会开始执行MOV 0, 1指令,并变成一个小鬼,导致平局。要杀死一个程序,你必须复制一个 DAT 文件覆盖它的代码。
这正是另一个由 Dewdney 编写的经典战士——Dwarf(矮人)所做的。它在等间距的位置用DAT“轰炸”核心,同时确保不会打中自己。
ADD #4, 3 ; 从这里开始执行
MOV 2, @2
JMP -2
DAT #0, #0
实际上,这并不完全是 Dewdney 所写的,但它的工作方式完全相同。执行再次从第一条指令开始。这次是ADD指令。ADD指令将源操作数和目标操作数相加,并将结果放入目标操作数。如果你熟悉其他汇编语言,你可能会认出#符号是标记立即寻址的一种方式。也就是说,ADD将数字 4 加到地址 3 的指令上,而不是将指令 4 加到指令 3 上。由于ADD之后的第三条指令是DAT,因此结果将是:
ADD #4, 3
MOV 2, @2 ; 下一条指令
JMP -2
DAT #0, #4
如果你将两条指令相加,A 字段和 B 字段将分别独立相加。如果你将一个数字添加到一条指令中,默认情况下,它会添加到 B 字段。在ADD指令的 B 字段中使用#也是完全可能的。这样,A 字段就会与ADD指令本身的 B 字段相加。
立即寻址模式可能看似简单且熟悉,但 ICWS ‘94 标准中的新修饰符将为其带来全新的变化。不过,我们先来看看 Dwarf。
MOV指令再次为我们展示了另一种寻址模式:@或间接寻址模式。这意味着DAT不会像看起来那样自我复制(那有什么意义呢?),而是会复制到其 B 字段所指向的指令上,如下所示:
ADD #4, 3
MOV 2, @2 ; --.
JMP -2 ; | +2
DAT #0, #4 ; <--' --. MOV的B字段指向这里
... |
... | +4
... |
DAT #0, #4 ; <------' DAT的B字段指向这里
如你所见,DAT将被复制到它前面 4 条指令的位置。下一条指令JMP只是让程序向后跳转两条指令,返回到ADD指令。由于JMP指令会忽略其 B 字段,所以我把 B 字段留空。MARS 会帮我将其初始化为 0。
顺便说一下,如你所见,MARS 不会追踪更多的间接地址链。如果间接操作数指向一条 B 字段为(比如)4 的指令,那么无论采用何种寻址模式,实际目标都将位于该指令的后 4 条指令处。
现在,ADD和MOV指令将再次执行。当执行再次到达JMP指令时,核心看起来像这样:
ADD #4, 3
MOV 2, @2
JMP -2 ; 下一条指令
DAT #0, #8
...
...
...
DAT #0, #4
...
...
...
DAT #0, #8
Dwarf 将每执行 4 条指令就继续丢出DAT,直到它绕过整个核心并再次回到自身:
...
DAT #0, #-8
...
...
...
DAT #0, #-4
ADD #4, 3 ; 下一条指令
MOV 2, @2
JMP -2
DAT #0, #-4
...
...
...
DAT #0, #4
...
现在,ADD会将DAT转回#0, #0,而MOV则会徒劳无功地将DAT复制到它原本所在的位置,整个过程将从头开始。
除非核心大小能被 4 整除,否则这自然行不通,因为否则的话,Dwarf 会击中DAT后面的第 1 到第 3 条指令中的一条,从而自我毁灭。幸运的是,目前最受欢迎的核心大小是 8000,其次是 8192、55400、800,它们都能被 4 整除,所以我们的 Dwarf 应该是安全的
另外,在战士中包含DAT #0, #0其实并无必要;核心最初填充的指令,我写成了三个点(…),实际上是DAT 0, 0。我将继续使用点来表示空核心,因为这样更简洁,也更易于阅读。
在 Core War 的早期版本中,唯一的寻址模式包括立即寻址(#)、直接寻址($或无)以及 B 字段间接寻址(@)模式。后来,又增加了前减寻址模式,即<。它与间接寻址模式相同,只是在计算目标地址之前,指针会先减一。
DAT #0, #5
MOV 0, <-1 ; 下一条指令
当执行这个MOV时,结果将为:
DAT #0, #4 ; ---.
MOV 0, <-1 ; |
... ; | +4
... ; |
MOV 0, <-1 ; <---'
ICWS ‘94 标准草案新增了四种寻址模式,主要是为了处理 A 字段间接寻址,使得寻址模式总数达到 8 种:
# — 立即$ — 直接($可省略)* — A 字段间接@ — B 字段间接{ — A 字段间接前减< — B 字段间接前减} — A 字段间接后增> — B 字段间接后增后增模式与前减模式相似,但如你所料,在指令执行后,指针将增加1。
DAT #5, #-10
MOV -1, }-1 ; 下一条指令
执行完将看起来像这样:
DAT #6, #-10 ; --.
MOV -1, }-1 ; |
... ; |
... ; | +5
... ; |
DAT #5, #-10 ; <--'
关于前减和后增模式,需要记住的一件重要事情是,即使指针没有被用于任何操作,它们也会被减/增。因此,即使JMP -1, <100所指向的值没有被用于任何操作,它也会将指令 100 减 1。同样,DAT <50, <60除了终止进程外,还会对地址进行减量操作。
如果你仔细查看前几章的指令表,你可能会对一个名为SPL的指令感到疑惑。在一般的汇编语言中,肯定没有这样的指令……
在 Core War 的历史早期,有人建议为游戏增加多任务处理功能,这样会使游戏更加有趣。由于普通系统中使用的粗略时间切片技术并不适合抽象的 Core War 环境(最重要的是,你需要一个操作系统来控制它们),因此发明了一种系统,在该系统中,每个进程依次执行一个周期。
用于创建新进程的指令是SPL。它像JMP一样,在其 A 字段中接受一个地址作为参数。JMP和SPL之间的区别在于,SPL除了在新地址开始执行外,还会继续执行下一条指令。
由此创建的两个(或更多)进程将平均分配处理时间。MARS 没有显示当前指令的单个进程计数器,而是有一个进程队列,这是一个按启动顺序重复执行的进程列表。SPL创建的新进程将紧接在当前进程之后添加,而执行DAT的进程将从队列中移除。如果所有进程都终止,战士将失败。
重要的是要记住,每个程序都有自己的进程队列。在内核中有多个程序的情况下,它们会交替执行,一次执行一个周期,无论进程队列的长度如何,这样处理时间就会始终被平均分配。如果程序 A有 3 个进程,而程序 B只有 1 个进程,那么执行顺序将会是:
最后,我们来看一个使用SPL的小例子。更多信息将在后续章节中提供。
SPL 0 ; 从这里开始执行
MOV 0, 1
由于SPL指向自身,一个周期后,进程将如下:
SPL 0 ; 第二个进程在这里
MOV 0, 1 ; 第一个进程在这里
在两个进程都执行完毕后,核心现在将呈现如下状态:
SPL 0 ; 第三个进程在这里
MOV 0, 1 ; 第二个进程在这里
MOV 0, 1 ; 第一个进程在这里
因此,这段代码显然会连续启动一系列的 imps,一个接一个。它会一直这样做,直到 imps 绕过整个核心并覆盖SPL。
每个程序的处理队列大小是有限的。如果已达到最大处理数量,SPL将继续执行为仅仅是下一条指令,这实际上与NOP的行为相同。在大多数情况下,处理限制相当高,通常与核心长度相同,但也可能更低(甚至为 1,在这种情况下,实际上就禁用了分裂)。
哦,说到真实往往比虚构更离奇,我最近偶然发现了一个名为“本该有的操作码”的网页。在众多荒诞不经的操作码中,我发现了“BBW——双向分支(Branch Both Ways)”。由于所有操作码都应该是虚构的,我只能得出结论,作者对 Redcode 并不熟悉…
ICWS ‘94 标准带来的最重要的新东西并非新的指令或新的寻址模式,而是修饰符。在旧的’88 标准中,寻址模式决定了指令的哪些部分会受到操作的影响。例如,MOV 1, 2总是移动整个指令,而MOV #1, 2则只移动一个数字(并且总是移动到 B 字段!)
当然,这可能会带来一些困难。如果你只想移动指令的 A 字段和 B 字段,而不想移动操作码怎么办?(你需要使用ADD指令)或者,如果你想把 B 字段的某些内容移动到 A 字段呢?(这是可能的,但非常棘手)为了澄清这种情况,指令修饰符被发明了出来。
修饰符是添加到指令后的后缀,用于指定它将影响源和目标中的哪些部分。例如,MOV.AB 4, 5表示将指令 4 的 A 字段移动到指令 5 的 B 字段。共有 7 种不同的修饰符可供选择:
MOV.A — 将源指令的 A 字段移入目标指令的 A 字段MOV.B — 将源指令的 B 字段移入目标指令的 B 字段MOV.AB — 将源指令的 A 字段移至目标指令的 B 字段MOV.BA — 将源指令的 B 字段移入目标指令的 A 字段MOV.F — 将源指令的两个字段同时移动到目标指令的相同字段中MOV.X — 将源指令的两个字段同时移动到目标指令的相反字段中MOV.I — 将整个源指令移入目标当然,同样的修饰符可以用于所有指令,而不仅仅是用于MOV指令。然而,像JMP和SPL这样的指令并不关心修饰符。(它们为什么要关心呢?它们并不处理任何实际数据,只是进行跳转。)
由于并非所有修饰符对所有指令都有意义,因此它们将默认为最接近且确实有意义的修饰符。最常见的情况涉及.I修饰符:为了保持语言的简洁性和抽象性,操作码没有定义数值等效项,因此对它们进行数学运算毫无意义。这意味着,对于除MOV、SEQ和SNE(以及CMP,它只是SEQ的别名)以外的所有指令,.I修饰符的含义与.F相同。
关于.I和.F,还有一点需要记住的是,寻址模式也是操作码的一部分,并且不会被MOV.F指令复制
我们现在可以重写旧程序,以使用修饰符为例。对于 Imp,自然会是MOV.I 0, 1。而对于 Dwarf,则会变成:
ADD.AB #4, 3
MOV.I 2, @2
JMP -2
DAT #0, #0
注意,我省略了JMP和DAT的修饰符,因为它们根本不使用这些修饰符。MARS 会将其转换为(例如)JMP.B和DAT.F,但谁在乎呢?
哦,还有一件事。我怎么知道该给哪个指令添加哪个修饰符呢?(更重要的是,如果我们不添加,MARS 系统会如何添加呢?)嗯,通常你可以凭借一点常识来做到这一点,但’94 标准确实为此定义了一套规则。
DAT, NOP
总是.F,但省略。
MOV, SEQ, SNE, CMP
如果 A 模式是立即的,.AB,
如果 B 模式是立即的,而 A 模式不是,.B,
如果两种模式都不是立即的,.I。
ADD, SUB, MUL, DIV, MOD
如果 A 模式是立即的,.AB,
如果 B 模式是立即的,而 A 模式不是,.B,
如果两种模式都不是立即的,.F。
SLT, LDP, STP
如果 A 模式是立即的,.AB,
如果不是,(永远是!) .B。
JMP, JMZ, JMN, DJN, SPL
总是 .B (但对JMP和SPL省略)。
‘94 标准中立即寻址模式(#)的既定行为相当不寻常。虽然该标准与旧语法 100%兼容,但立即寻址的定义方式非常巧妙且独特,使其能够与所有指令和修饰符逻辑地结合使用,从而成为一种非常强大的工具。
看看这些修饰符,你可能会好奇MOV.F #7, 10会做什么。.F应该移动两个字段,但源中只有一个数字??它会将 7 移动到目标的两个字段中吗?
不,它肯定不会。事实上,它会将 7 移动到目的地的 A 字段,将10移动到 B 字段!为什么?
原因在于,在’94 语法中,源(和目标)始终是一个完整的指令。在立即寻址的情况下,无论实际值是多少,它始终只是当前指令(即 0)。因此,执行指令MOV.F #7, 10会将源(0)的两个字段都移动到目标(10)。很令人惊讶,不是吗?
这同样适用于MOV.I。这种定义立即寻址的方式还让我们能够使用一些指令,这些指令在’88 标准中即使没有修饰符也是没有意义的,比如JMP #1234。显然,你不能跳转到某个数字,但你可以跳转到该数字的地址,或者跳转到 0。这提供了许多显而易见的优势,因为我们不仅可以“免费”在 A 字段中存储数据,而且即使有人对其进行减一操作,代码也能正常运行。现在,我们可以将之前 imp 制造机的代码重写得更健壮一些:
SPL #0, }1
MOV.I #1234, 1
它的工作原理仍然相同,但现在 A 字段是自由的。为了好玩,我让SPL递增了小鬼的 A 字段,这样所有的小鬼看起来都会不一样。由于SPL不使用其 B 字段,所以这个递增也是“免费”的。它有效,相信我——或者你自己试试!
你应该已经知道,内核中的地址是循环的,因此,当前指令前后一个 coresize 的指令实际上是指向当前指令本身。但事实上,这种影响要深远得多:在 Core War 中,所有数字都被转换为 0 到coresize-1 的范围。
对于那些已经了解编程和有限范围整数运算的读者,我只想说,在 Core War 中,所有数字都被视为无符号数,最大整数为coresize-1。如果这没有解释清楚,请继续阅读…
实际上,在 Core War 中,所有数字都会除以核心的长度(即coresize),并且只保留余数。你可以试着想象一个只有 8 位数字显示的计算器,它会舍去超过 8 位的数字,因此 100*12345678(当然,结果是 1234567800)在显示(和存储)时只会显示为 34567800。同样,在一个包含 8000 条指令的核心中,7900+222(即 8122)最终只会变成 122。
那么负数会怎么样呢?它们也会被归一化,通过加上coresize直到它们变为正数。这意味着我写的-1 实际上被 MARS 存储为coresize-1,或者在常见的 8000 指令核心中,存储为 7999。
当然,这对地址来说没什么区别,因为地址无论如何都会发生回绕。事实上,对于像ADD或SUB这样的简单数学指令来说,也没有任何区别,因为在coresize=8000 的情况下,6+7998 和 6-2 都会得到相同的结果 4(或 8004)。
那问题出在哪里呢?嗯,在一些指令上,结果会有所不同。像DIV、MOD和SLT这样的指令总是将数字视为无符号数。这意味着-2 / 2 的结果不是-1,而是(coresize-2)/2 = (coresize/2)-1(或者对于coresize=8000,7998/2=3999,而不是 7999)。同样,SLT认为-2(或 7998)大于 0!事实上,在 Core War 中,0 是可能的最小数字,所以所有其他数字都被认为大于它。
好的,你的耐心得到了回报。到目前为止,我只给了你一些零散的信息。现在,是时候通过向你描述每一条指令,把这些信息整合在一起了。
当然,我本可以在最开始,也就是我给你指令集的时候,就把它们列出来,这样或许能省去你很多猜测的时间。但至少在我看来,等待是有充分理由的。我不仅想在开始枯燥的理论讲解之前,先给你看一些实际的代码,更重要的是,我想让你在详细描述指令之前,至少能理解寻址模式和修饰符的基本概念。如果我在修饰符之前就描述指令,那么我就得先教你旧的’88 规则,然后再把包括修饰符在内的所有内容都教一遍。学习 Redcode 用这种方法也不错,但会让本指南变得不必要地复杂。
DAT
DAT原本是用来存储数据的,就像在大多数语言中一样。在 Core War 中,由于需要尽量减少指令数量,因此将指针等存储在其他指令的未使用部分是很常见的。这意味着,DAT最重要的功能是执行它会终止一个进程。事实上,由于’94 标准中没有非法指令,DAT被定义为完全合法的指令,该指令会从进程队列中移除当前正在执行的进程。听起来可能有点吹毛求疵,但精确地定义显而易见的事物往往能避免很多混淆。DAT没有影响,事实上,一些 MARS 会移除它们。但是,请记住,即使值未被用于任何操作,前减和后增也总是会执行。DAT的一个不同寻常之处是,如果它只有一个参数,则该参数会被放置在B 字段中,这是以前标准的遗留物。MOV
MOV指令用于将数据从一个指令复制到另一个指令。如果你对此还不太了解,可能需要重读前面的章节。MOV是少数支持.I的指令之一,如果没有给出修饰符(且两个字段均未使用立即寻址),则这是其默认行为。ADD
ADD 指令将源值加到目标值上。这些修饰符的作用与 MOV 指令类似,但 .I 不受支持,其行为类似于 .F。(那么 MOV.AB+DJN.F 是什么意思呢?)另外请记住,在 Core War 中,所有数学运算都是按coresize 取模进行的。SUB
MUL
MUL来说也是如此。如果你猜不出它的作用,那么你可能错过了一些非常重要的东西。DIV
DIV的工作原理也与MUL和其他指令基本相同,但有几件事需要牢记。首先,这是无符号除法,有时可能会得出令人惊讶的结果。除零会终止进程,就像执行DAT一样,并且目标值保持不变。如果你使用DIV.F或.X一次除两个数,其中一个除数为 0,另一个除法仍会正常进行。MOD
DIV所说的一切同样适用于此处,包括除零部分。请记住,像MOD.AB #10, #-1这样的计算结果取决于内核的大小。对于常见的 8000 指令内核,结果将是 9(7999 除以 10 取模)。JMP
JMP指令将执行转移到其 A 字段所指向的地址。与“数学”指令相比,一个明显但重要的区别是,JMP 只关心地址,而不关心地址所指向的数据。另一个显著的区别是,JMP完全不使用其 B 字段(因此也忽略其修饰符)。能够跳转到(或拆分到)两个地址将过于强大,这将使接下来三条指令的实现变得相当困难。记住,你仍然可以在未使用的 B 字段中放置一个增量或减量指令,运气好的话,这可能会破坏对手的代码。JMZ
JMP,但它不会忽略其 B 字段,而是会检测它所指向的值,并且仅当该值为零时才进行跳转。否则,执行将在下一个地址继续。由于只有一条指令需要检测,因此修饰词的选择相当有限。.AB的含义与.B相同。.BA与.A相同,.X和.I与.F相同。如果使用JMZ.F检测指令的两个字段,则只有当两个字段都为零时,它才会跳转。JMN
JMN的工作方式与JMZ类似,但如果检测的值不为零,它就会跳转(真是意料之中的事……)。如果JMN.F的任一字段不为零,它就会跳转。DJN
DJN类似于JMN,但在检测前其值会递减一。此指令可用于制作循环计数器,但也可用于对对手造成伤害。SPL
SPL加入到语言中,可能是对 Redcode 所做的最重大的改变,或许只有 ICWS ‘94 标准的引入才能与之媲美。SPL的工作方式与JMP类似,但执行还会继续到下一条指令,从而使进程“拆分”为两个新进程。下一条指令处的进程会在跳转到新地址的进程之前执行,这是一个虽小却非常重要的细节。(许多,如果不是大多数,现代战士如果没有它就无法工作! (译注:比如现代复制器 Silk 以及其他衍生的复制器) )如果达到了进程的最大数量,SPL的工作方式就会像NOP一样。与JMP类似,SPL会忽略其 B 字段和修饰符。SEQ
SEQ比较两条指令,如果它们相等,则跳过下一条指令。(由于没有空间容纳跳转地址,它总是只向前跳转两条指令。 (译注:就是跳到下 2 条指令,即跳过下一条指令))由于指令仅比较是否相等,因此支持使用.I修饰符。很自然,使用.F、.X和.I修饰符时,只有当所有字段都相等时,才会跳过下一条指令。SNE
JMZ和JMN一样……)CMP
CMP是SEQ的别名。在引入SEQ和SNE之前,这是该指令唯一的名称。如今,使用哪个名称其实并不重要,因为即使在’88 模式下,最流行的 MARS 程序也能识别SEQ。SLT
.I是没有意义的。看起来应该有一个名为SGT(如果大于则跳过)的指令,但在大多数情况下,只需交换SLT的操作数即可达到相同的效果。请记住,所有值都被视为无符号数,因此 0 是最小的可能值,-1 是最大的。NOP
你可能会注意到,LDP和STP这两条指令不见了。它们是该语言中相对较新的添加内容,我们稍后会讨论…嗯,现在就开始。 :-)
P 空间是 Redcode 的最新补充,由 pMARS 0.8 引入。“P”代表私人(private)、永久(permanent)、个人(personal)、可悲(pathetic)等,随你喜欢。基本上,P 空间是一个只有你的程序可以访问的内存区域,在多轮比赛中,它在轮次之间仍然存在。
P 空间在许多方面与常规核心有所不同。首先,P 空间的每个位置只能存储一个数字,而非整个指令。此外,P 空间中的寻址是绝对的,即无论包含 P 空间地址 1 的指令位于核心的哪个位置,P 空间地址 1 始终为 1。最后但同样重要的是,P 空间只能通过两条特殊指令LDP和STP来访问。
这两条指令的语法有些不同寻常。以STP为例,其源操作数在内核中有一个普通值,该值会被放入目标所指向的 P 空间字段中。因此,P 空间的位置并非由目标地址决定,而是由其值决定,即如果这是一条MOV指令,那么该值将被覆盖。
因此,以STP.AB #4, #5为例,它们会将值 4 放入P 空间字段5 中。同样地,
STP.B 2, 3
...
DAT #0, #10
DAT #0, #7
会把值 10 放入 P 空间字段7,而不是 3!如果STP本身使用间接寻址,这会导致一种“双间接”寻址系统,从而可能造成相当大的混淆。
LDP的工作方式与此相同,只是现在源是 P 空间字段,而目标是核心指令。
P 空间的大小通常小于核心的大小,通常为核心大小的 1/16。P 空间中的地址与核心中的地址一样会发生回绕。P 空间的大小必须恰好是核心大小的倍数,否则会出现异常情况。
P 空间位置 0 具有特殊性,因为它在每轮开始前都会被初始化为一个特殊值。对于第一轮,该值为-1;如果程序在前一轮中终止,则该值为 0;否则,该值为存活程序的数量。因此,在一对一的比赛中,0 表示失败,1 表示获胜,2 表示平局。这种特殊的初始化也意味着位置 0 不能用于将任何其他信息从一轮传递到下一轮,因为在下轮开始之前,它将被 MARS 覆盖。
在 P 空间的 pMARS 实现中有一个小特点。由于初衷是保持对 P 空间的访问速度较慢,因此不允许用一条指令同时加载或保存两个 P 空间字段。这是一件好事,但结果至少可以说是一种权宜之计。这实际上意味着LDP.F、.X和.I的工作方式都像LDP.B一样!(当然,STP也是如此)
P 空间最常用的用途无疑是用来选择策略。最简单的形式是,将之前的策略保存在 P 空间中,如果 P 空间字段 0 显示程序上次失败了,则切换策略。这类程序被称为 P-战士(P-warriors)、P-切换器(P-switchers)或 P-脑(P-brains)(发音为pea-brains)。
遗憾的是,P 空间并不像它看起来那样私密。虽然你的对手无法直接读取或写入你的 P 空间,但你的进程可能会被捕获并执行对手的代码,包括STP。这种技术被称为洗脑(brainwashing),所有 P-切换器都必须对此做好准备,如果策略字段包含奇怪的内容,也不要惊慌失措。
到目前为止,在我们的示例程序中,我将所有地址都写成了相对于当前指令的指令编号。但在较大的程序中,这样做可能会很烦人,更不用说难以阅读了。幸运的是,我们其实并不需要这样做,因为 Redcode 允许我们使用标签、符号常量、宏以及所有其他你在一个好的汇编器中所期望的东西。我们只需给指令打上标签,并用标签来引用它们,解析器就会为我们计算出真实的地址,就像这样:
imp: mov.i imp, imp+1
哇,发生了什么?这和我最开始给你看的程序一模一样。我只是把数值地址替换为了对标签“imp”的引用。当然,在这种情况下这样做是相当徒劳的。唯一使用标签的指令就是“imp”本身,在这个指令中,标签被替换为了 0。
在执行之前,MARS 中的解析器会将所有此类标签和其他符号转换为熟悉的数字。出于某种原因,这种“预编译”的 Redcode 文件被称为加载文件(load file)。所有 MARS 都必须能够读取加载文件,但有些可能没有真正的解析器。在加载文件格式中,之前的代码变为MOV.I 0, 1。我们也可以将相同的代码写成
imp: mov.i imp, next
next: dat 0, 0 ; 或者别的啥
在这种情况下,标记为“next”的指令是“imp”之后的一个指令,因此它被替换为 1。请记住,实际地址仍然是相对数字,所以即使在“next”之前复制了自身之后,“Imp”仍将继续是MOV.I 0, 1。
实际上,标签末尾的 : 并不是必需的。我在这里使用它只是为了帮助你看到标签的位置,但在我自己的程序中通常不会使用。这只是个人喜好问题。
哦,以防你对此感到疑惑,Redcode 指令是不区分大小写的。我喜欢在源代码中使用小写,因为这样看起来更整洁,而只在编译后的“加载文件”格式中使用大写(主要是因为它是一种传统)。
虽然前几章中的示例可能能够正常编译,但它们并不是完整的程序,而是程序的一部分。一个典型的 redcode 文件包含一些供 MARS 使用的额外信息。
;redcode-94
;name Imp
;author A.K. Dewdney
org imp
imp: mov.i imp, imp+1
end
你可能已经猜到了,在 Redcode 中, ; 后面的所有内容都是注释。然而,这个程序顶部的几行并不是普通的注释。MARS 使用它们来获取有关程序的一些信息。
第一行,;redcode-94,告诉 MARS 这确实是一个 Redcode 文件。MARS 会忽略这行之前的任何内容。实际上,MARS 只期望看到以;redcode开头,但我们可以利用该行的其余部分来识别所使用的 Redcode 类型。特别地,KotH 服务器会自行读取这一行,并使用它来识别程序将要前往的山丘。
;name和 ;author行只是提供了一些关于程序的信息。当然,你可以以任何格式提供这些信息,但使用特定的代码可以让 MARS 在程序运行时读取并显示这些名称。
带有END一词的行——不出所料——标志着程序的结束。该行之后的任何内容都将被忽略。与;redcode一起使用,例如,可以将 Redcode 程序包含在电子邮件中。
带有ORG的行告诉程序应从何处开始执行。这使我们能够在程序开头之前放置其他指令。ORG命令是’94 标准中包含的新内容之一。较旧的语法(在现代程序中仍然适用)是将起始地址作为参数传递给END。
;redcode-94
;name Imp
;author A.K. Dewdney
imp: mov.i imp, imp+1
end imp
简单、紧凑,但遗憾的是相当缺乏逻辑性。对于长程序,你不得不滚动到末尾才能看到程序从哪里开始。在 Redcode 术语中,ORG 和 END 都被称为伪操作码。它们看起来像实际的指令,但实际上并没有被编译到程序中。
不过说够了 Imp,让我们来看看在现代 Redcode 中,Dwarf 会是什么样子:
;redcode-94
;name Dwarf
;author A.K. Dewdney
;strategy Bombs the core at regular intervals.
;(slightly modified by Ilmari Karonen)
;assert CORESIZE % 4 == 0
org loop
loop: add.ab #4, bomb
mov.i bomb, @bomb
jmp loop
bomb: dat #0, #0
end
这些标签让程序更易于理解,不是吗?注意,我添加了两行新的注释。;strategy行简要描述了程序。程序中可能有多行这样的注释。大多数当前的 MARS(可能是某种编程语言或环境的名称,具体需根据上下文判断)会忽略它们,所以你也可以使用像我名字所在的普通注释,但山丘会向其他人展示;strategy行。将上面的程序发送给一个山丘,可能会显示类似以下内容:
A new challenger has appeared on the '94 hill!
Dwarf by A.K. Dewdney: (length 4)
;strategy Bombs the core at regular intervals.
[other info here...]
我们示例代码中的另一个新细节是;assert语句。它可以用来确保程序在当前设置下确实能正常运行。例如,如果核心的大小不能被 4 整除,那么 Dwarf 程序就会自我终止。因此,我使用了;assert CORESIZE % 4 == 0语句来确保它总是能被 4 整除。
CORESIZE是一个预定义的常量,它告诉我们核心的大小。也就是说,n+CORESIZE总是与n具有相同的地址。%是取模运算符,它给出除法运算后的余数。在 Redcode 中,用于;assert行和其他地方的表达式的语法与C 语言中的语法相同,尽管运算符的集合要有限得多。
对于那些不了解 C 语言的人,这里列出了在 Redcode 表达式中使用的一些运算符:
+ 加法- 减法(或负数)* 乘法/ 除法% 取模(余数)== 等于!= 不等于< 小于> 大于<= 等于或小于>= 等于或大于&& 与|| 或! 非= 赋值给变量;assert后面跟着一个逻辑表达式。如果表达式为假,则程序将无法编译。在 C 语言中,0 表示假,其他任何值都表示真。逻辑运算符和比较运算符对于真值返回 1,这一特性在后续可能会有用。
通常,使用;assert来检查核心的大小是否符合常量设计时的预期,例如;assert CORESIZE == 8000。如果程序使用 P 空间,则可以用;assert PSPACESIZE > 0来测试其是否存在。由于我们的示例程序 Dwarf 具有相当强的适应性,我只测试了CORESIZE的可除性,而没有测试特定的尺寸。而 Imp 可以在任何设置下运行,可以使用;assert 1、;assert 0 == 0等,所有这些总是评估为真。这很有用,因为否则的话,MARS 可能会抱怨"missing ;assert line -- warrior may not work with current settings."
一些预定义常量(如CORESIZE)是由’94 标准定义的,而其他常量则可能并且已经得到添加。pMARS 0.8 至少应支持以下内容:
预定义常量很有用,标签也是如此,但真的仅此而已吗?我不能使用一些变量或其他东西吗?
嗯,Redcode 是一种汇编语言,它并不怎么使用变量。但有一样东西几乎和它一样好,甚至有时可能更好。那就是伪操作码EQU,它让我们可以定义自己的常量、表达式甚至宏。它看起来像这样:
step equ 2667
在此之后,step总是被 2667 所取代。然而,这里有个陷阱。这种替换是文本上的,而非数值上的。在这种情况下,它应该不会造成任何损害,但尽管它使EQU成为一个非常强大的工具,它也可能带来一些问题,这些问题 C 程序员应该相当熟悉。让我们举个例子。
step equ 2667
target equ step-100
start mov.i target, step-target
MOV的 A 字段应该是 2567,这很正常。但 B 字段会变成 2667-2667-100 == -100 ,而不是 2667-(2667-100) == 2667-2567 == 100 ,这可能不是原意。解决方案很简单。只需在EQU中的每个表达式前后加上括号,例如“target equ (step-100)”。
使用现代版本的 pMARS,可以运用多行equ,从而创建某种宏。具体操作方式如下:
dec7 equ dat #1, #1
equ dat $1, $1
equ dat @1, @1
equ dat *1, *1
equ dat {1, {1
equ dat }1, }1
equ dat <1, <1
decoy dec7
dec7 ; 21行指令的诱饵
dec7
pMARS 解析器还有几个其他特性,而这一特性可能比上述任何特性都更强大(也更难学习)。FOR/ROF伪操作码不仅可以使源代码更简洁,还能轻松创建复杂的代码序列,而且它们还可以用于为不同设置创建条件代码。
一个FOR循环块以——你猜对了——伪操作码FOR开头,后面跟着该块应重复的次数。如果该块前面有一个标签,则该标签将用作循环计数器,如下所示:
index for 7
dat index, 10-index
rof
如您所见,这个代码块以ROF结束。(我认为这比“NEXT”或“REPEAT”这些老套的陈词滥调好多了。)pMARS 会将上面的代码块解析为:
DAT.F $1, $9
DAT.F $2, $8
DAT.F $3, $7
DAT.F $4, $6
DAT.F $5, $5
DAT.F $6, $4
DAT.F $7, $3
完全有可能在每个FOR块内部再嵌套多个FOR块。这些块甚至可以在内部包含EQU,这让我们能够创建一些非常有趣的代码。一个更有用的特性是,循环计数器可以通过&运算符与标签连接。这通常用于避免重复声明标签,但也可用于其他各种目的。
dest01 equ 1000
dest02 equ 1234
dest03 equ 1666
dest04 equ (CORESIZE-1111)
jtable
ix for 4
jump&ix spl dest&ix
djn.b jump&ix, #ix
rof
在解析完FOR/ROF之后,这将变为:
jtable
jump01 spl dest01
djn.b jump01, #1
jump02 spl dest02
djn.b jump02, #2
jump03 spl dest03
djn.b jump03, #3
jump04 spl dest04
djn.b jump04, #4
至于这有什么用,额,那就得靠你自己的想象力了。我见过的唯一使用如此复杂表达式的战士是一些快速扫描(quickscanners)。预定义的常量也可以与FOR/ROF一起使用,如下所示:
; 战士的主本体在这里
decoy
foo for (MAXLENGTH-CURLINE)
dat 1, 1
rof
end
这会在你的战士中剩余的空间中填充DAT 1, 1。只要你将你自己的程序从诱饵中复制(引导)出去,这样的诱饵就能误导其他战士的攻击。请注意,我使用了foo作为循环计数器,尽管它没有任何实际用途。这是因为如果不这样做,MARS(可能是某种编程语言或环境的名称)就会将 decoy 视为循环计数器,而不是它应该的标签。
最后,这里有一些使用FOR/ROF的更具创意的方法示例:
;由于该代码实际并不是一个战士,所以对其进行翻译。
;redcode-94
;name Tricky
;author Ilmari Karonen
;strategy 一些非常复杂的战士玩意儿
;strategy (一个不言自明的条件代码示例)
;assert CORESIZE == 8000 || CORESIZE == 800
;assert MAXPROCESSES >= 256 && MAXPROCESSES < 10000
;assert MAXLENGTH >= 100
org start
for 0
这是一个for/rof循环注释块。这将被重复0次,即
这意味着MARS将忽略这里的所有内容。这是一个
这是解释这位战士所使用的复杂策略的绝佳场合。
rof
;当然,使用普通注释也是可以的。你可以使用
;你喜欢的无论哪种选择。
for (CORESIZE == 8000)
step equ normalstep
;由于真比较返回1,假比较返回0,所以这段
;代码只有当比较结果为真时,才会被编译。
rof
for (CORESIZE == 800)
step equ tinystep
;在这里,我们可以为较小的核心尺寸设置优化的常数。
rof
for 0
;strategy 由于策略和断言行实际上是注释,因此它们
;strategy 在FOR 0 / ROF块内也会被解析!
rof
;[这里是实际代码..]
使用EQU定义的常量的问题在于,它们毕竟是常量。一旦定义,就无法更改其值。这在大多数情况下都没问题,但会让一些技巧变得几乎不可能实现。
幸运的是,pMARS 为我们提供了一些真正的变量。这些变量的使用有些复杂,而且我很久没见有人真正使用它们了,但它们确实存在。
变量名只有一个字母,实际上将它们的数量限制在了 26 个(a 到 z)。变量不是使用EQU赋值,而是使用=运算符来赋值。棘手的是,要使用该运算符,必须有一个表达式。由于 pMARS 不识别逗号运算符,因此可能需要编写虚拟表达式。
然而,这些变量可能很有用。例如,如果没有它们,以下自动生成的斐波那契数列可能就无法生成了。
dat #1, (g=0)+(f=1)
idx for 15
dat #idx+1, (f=f+g)+((g=f-g) && 0)
rof
注意表达式(g=f-g)是如何通过与 0 进行与运算而“隐藏”起来的。系统之所以能正常工作,是因为 pMARS 不会对表达式进行重新排序,而是始终先计算加法的左侧,这样在计算右侧时,f 的值已经增加了。
好的,我差点忘了。还有一个伪操作需要描述。它几乎从未被使用过,但确实存在。PIN代表“P-空间标识号(P-space Identification Number)”。如果两个程序的PIN相同,它们将共享它们的 P-空间。这可以用来提供一种进程间通信甚至合作的方式。不幸的是,这种策略似乎不值得花费精力去创造一种有效且快速的通信方法。当然,如果你想尝试一下,那就试试吧。你永远不知道它是否会成功…
如果程序没有PIN,则其 P 空间将始终保持私有状态。即使两个程序共享其 P 空间,特殊的只读位置 0 也始终保持私有状态。
如果你还不了解它们,那么山丘之王(the King of the Hill)服务器(通常简称为山丘,hill)是在互联网上持续进行的《核心战争》锦标赛。勇士们通过电子邮件发送——或在网络表单上输入——到服务器,与山丘上已有的所有程序(通常为 10-30 个)进行对决。总分最低的程序会被淘汰出局,新的勇士将取代它(假设其得分至少比原程序中的一个更好)。围绕这一基本设置,还有不少变种,如“无限”山丘、多样性山丘等。
请注意,为了节省时间,Hills 通常会在实际运行之前将战士预编译成加载文件。这可能会导致一些预定义的常量(如WARRIORS)出错,从而引发神秘的;assert问题。
目前(2012 年 4 月)主要有两个 KotH 服务器可供使用:
KotH.org 这是目前最古老且最著名的活跃 KotH 服务器。目前托管了 7 个设置不同的山丘地图,包括两个多人战士近战山丘地图和 两个使用较旧 Redcode ‘88 标准设定的山丘地图。
KOTH@SAL 还设有 7 个参数不同的山丘,其中包括一个新手山丘,新手们在成功通过 50 次挑战后会被自动推下,以方便新玩家登上山丘。
此外,Koenigstuhl服务器为已发布的战士托管了 10 个“无限”山丘。被派往这些山丘的战士永远不会被推下,因此山丘会越来越大。Koenigstuhl 还使用递归评分算法,根据战士的排名调整其对分数的贡献。
上述列表并不详尽,且可能已过时。(目前)可在corewar.info页面上找到更详细且最新的活跃 KotH 服务器列表。
版本号 0.50
版本号 0.51
版本号 0.52
版本号 0.53
版本号 0.54
版本号 0.55
版本 1.00
版本号 1.01
<DD>标签的一个小兼容性问题。版本 1.02
版本 1.03
版本号 1.10
版本号 1.20
版本号 1.21
版本 1.22
版本 1.23
Copyright 1997-2020 Ilmari Karonen.
This work is licensed under a Creative Commons Attribution 3.0 Unported License.