Redcode 初学者指南

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 War?

Core War(或Core Wars)是一款编程游戏,游戏中汇编程序试图在模拟计算机的内存中互相摧毁。程序(或称warriors,战士)是用一种名为Redcode的特殊语言编写的,并由一个名为MARSMemory Array Redcode Simulator,内存阵列 Redcode 模拟器)的程序运行。

与普通计算机系统相比,Redcode 和 MARS 环境都得到了大幅简化和抽象。这是一件好事,因为 CW 程序是为性能而编写,而非为清晰性。如果游戏使用普通的汇编语言,世界上可能只有两三个人能够编写出有效且耐用的战士,而且他们可能也无法完全理解。这无疑具有挑战性且充满潜力,但要达到中等水平可能需要数年时间。

它是如何工作的?

程序运行的体系相当简单。核心core,模拟计算机的内存)是一个连续的指令数组,除了相互竞争的程序外,其余部分均为空。核心是循环的,因此在执行完最后一个指令后,又会再次执行第一个指令。

事实上,由于没有绝对地址,程序无法判断内核的结束位置。也就是说,地址 0 并不代表内存中的第一条指令,而是代表包含该地址的指令。下一条指令是 1,而前一条指令显然是-1。

如你所见,在 Core War 中,内存的基本单位是一条指令,而非通常的一个字节。每条 Redcode 指令包含三个部分:操作码OpCode)本身、源地址(即A-field,A 字段)和目标地址(即B-field,B 字段)。虽然可以在 A 字段和 B 字段之间移动数据,但通常需要将指令视为不可分割的块。

程序的执行也同样简单。MARS 一次执行一条指令,然后继续执行内存中的下一条指令,除非指令明确告诉它跳转到另一个地址。如果正在运行多个程序(通常情况就是这样),这些程序会交替执行,一次执行一条指令。每条指令的执行时间相同,都是一个周期,无论是执行MOVDIV还是DAT(这会终止进程)。


从 Redcode 开始

Redcode 指令集

Redcode 中的指令数量随着每个新标准的出现而增加,从最初的约 5 个增加到现在的 18 或 19 个。而这还不包括新的修饰符和寻址模式,这些模式实际上允许数百种组合。幸运的是,我们不需要学习所有的组合。只需记住指令以及修饰符如何改变它们就足够了。

以下是 Redcode 中使用的所有指令列表:

The Imp

事实上,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 中没有缓存。好吧,实际上当前的指令是缓存的,所以在执行过程中你不能修改它 (译注:指正在运行的、读取的缓存等不会被修改,但内存上的指令仍会被修改),但也许我们应该把所有这些留到后面再说……

The Dwarf

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 条指令处。

现在,ADDMOV指令将再次执行。当执行再次到达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 种:

后增模式与前减模式相似,但如你所料,在指令执行,指针将增加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 字段中接受一个地址作为参数。JMPSPL之间的区别在于,SPL除了在新地址开始执行外,还会继续执行下一条指令。

由此创建的两个(或更多)进程将平均分配处理时间。MARS 没有显示当前指令的单个进程计数器,而是有一个进程队列,这是一个按启动顺序重复执行的进程列表。SPL创建的新进程将紧接在当前进程之后添加,而执行DAT的进程将从队列中移除。如果所有进程都终止,战士将失败。

重要的是要记住,每个程序都有自己的进程队列。在内核中有多个程序的情况下,它们会交替执行,一次执行一个周期,无论进程队列的长度如何,这样处理时间就会始终被平均分配。如果程序 A有 3 个进程,而程序 B只有 1 个进程,那么执行顺序将会是:

  1. 程序 A, 进程 1,
  2. 程序 B, 进程 1,
  3. 程序 A, 进程 2,
  4. 程序 B, 进程 1,
  5. 程序 A, 进程 3,
  6. 程序 B, 进程 1,
  7. 程序 A, 进程 1,
  8. 程序 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指令。然而,像JMPSPL这样的指令并不关心修饰符。(它们为什么要关心呢?它们并不处理任何实际数据,只是进行跳转。)

由于并非所有修饰符对所有指令都有意义,因此它们将默认为最接近且确实有意义的修饰符。最常见的情况涉及.I修饰符:为了保持语言的简洁性和抽象性,操作码没有定义数值等效项,因此对它们进行数学运算毫无意义。这意味着,对于除MOVSEQSNE(以及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

注意,我省略了JMPDAT的修饰符,因为它们根本不使用这些修饰符。MARS 会将其转换为(例如)JMP.BDAT.F,但谁在乎呢?

哦,还有一件事。我怎么知道该给哪个指令添加哪个修饰符呢?(更重要的是,如果我们不添加,MARS 系统会如何添加呢?)嗯,通常你可以凭借一点常识来做到这一点,但’94 标准确实为此定义了一套规则。

DAT, NOP 总是.F,但省略。 MOV, SEQ, SNE, CMP 如果 A 模式是立即的,.AB, 如果 B 模式是立即的,而 A 模式不是,.B, 如果两种模式都不是立即的,.IADD, SUB, MUL, DIV, MOD 如果 A 模式是立即的,.AB, 如果 B 模式是立即的,而 A 模式不是,.B, 如果两种模式都不是立即的,.FSLT, LDP, STP 如果 A 模式是立即的,.AB, 如果不是,(永远是!) .BJMP, JMZ, JMN, DJN, SPL 总是 .B (但对JMPSPL省略)。

深入探讨’94 标准

#的含义远不止表面看起来那么简单…

‘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。

当然,这对地址来说没什么区别,因为地址无论如何都会发生回绕。事实上,对于像ADDSUB这样的简单数学指令来说,也没有任何区别,因为在coresize=8000 的情况下,6+7998 和 6-2 都会得到相同的结果 4(或 8004)。

那问题出在哪里呢?嗯,在一些指令上,结果会有所不同。像DIVMODSLT这样的指令总是将数字视为无符号数。这意味着-2 / 2 的结果不是-1,而是(coresize-2)/2 = (coresize/2)-1(或者对于coresize=8000,7998/2=3999,而不是 7999)。同样,SLT认为-2(或 7998)大于 0!事实上,在 Core War 中,0 是可能的最小数字,所以所有其他数字都被认为大于它。

逐条指令的’94 标准

好的,你的耐心得到了回报。到目前为止,我只给了你一些零散的信息。现在,是时候通过向你描述每一条指令,把这些信息整合在一起了。

当然,我本可以在最开始,也就是我给你指令集的时候,就把它们列出来,这样或许能省去你很多猜测的时间。但至少在我看来,等待是有充分理由的。我不仅想在开始枯燥的理论讲解之前,先给你看一些实际的代码,更重要的是,我想让你在详细描述指令之前,至少能理解寻址模式和修饰符的基本概念。如果我在修饰符之前就描述指令,那么我就得先教你旧的’88 规则,然后再把包括修饰符在内的所有内容都教一遍。学习 Redcode 用这种方法也不错,但会让本指南变得不必要地复杂。

你可能会注意到,LDPSTP这两条指令不见了。它们是该语言中相对较新的添加内容,我们稍后会讨论…嗯,现在就开始。 :-)

P 空间——最后的边界

P 空间是 Redcode 的最新补充,由 pMARS 0.8 引入。“P”代表私人(private)、永久(permanent)、个人(personal)、可悲(pathetic)等,随你喜欢。基本上,P 空间是一个只有你的程序可以访问的内存区域,在多轮比赛中,它在轮次之间仍然存在。

P 空间在许多方面与常规核心有所不同。首先,P 空间的每个位置只能存储一个数字,而非整个指令。此外,P 空间中的寻址是绝对的,即无论包含 P 空间地址 1 的指令位于核心的哪个位置,P 空间地址 1 始终为 1。最后但同样重要的是,P 空间只能通过两条特殊指令LDPSTP来访问。

这两条指令的语法有些不同寻常。以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

我们示例代码中的另一个新细节是;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 至少应支持以下内容:

#define?嗯,差不多吧…

预定义常量很有用,标签也是如此,但真的仅此而已吗?我不能使用一些变量或其他东西吗?

嗯,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

“rof”的用途是什么?

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 和针头

好的,我差点忘了。还有一个伪操作需要描述。它几乎从未被使用过,但确实存在。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

版本 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.