EUD
简介
概览
EUD是Extended Unit Death的缩写,是指星际争霸1地图编辑的一项技术,它的基本原理是将星际地图触发中的Deaths条件/SetDeaths动作中的playerID、unitID参数填写成超出其正常范围的数值来读写更大范围的内存,从而实现普通地图所无法拥有的更多功能。
在星际1.08时代,地图作者发现了星际地图触发系统中Deaths条件(Deaths(playerID, comparison, amount, unitID)))和SetDeath动作(SetDeaths(playerID, modifier, amount, unitID))的漏洞:其中的playerID参数和unitID参数可以填写超出其正常范围内的值,并且触发仍然会生效。这就使得Deaths条件可以读取更大范围的内存地址、SetDeaths动作可以修改更大范围的内存地址内的数据。星际地图的触发使用4字节来储存Deaths/SetDeaths的playerID参数,使用2字节来储存unitID参数。playerID的正常取值范围为0至26,若将Deaths/SetDeaths的playerID参数设为此范围之外的值(除去0至26的任何在-2147483648至2147483647之间的数值),则会被视为Extended Player Death,简称EPD。unitID的正常取值范围为0至232,若将Deaths/SetDeaths的unitID参数设为此范围之外的值(除去0至232的任何在0至65535之间的数值),则会被视为Extended Unit Death,简称EUD。在实际操作中,为了读写更大范围的内存,我们通常会使用EPD,或者EUD、EPD二者混用,即可在理论上读写所有内存地址(0x00000000至0xFFFFFFFF)。由于习惯问题,这项技术的名字还是以EUD命名,而非EPD。
含有至少一条使用了EUD或EPD的Deaths条件或SetDeaths动作的触发,被称为EUD触发。
含有至少一条EUD触发的地图被称为EUD地图。
星际1.08的EUD
星际1.08允许EUD触发读取、修改任意内存,因此1.08的EUD自由度很高,在理论上可以实现任何事情。各路大神开发了1.08的EUD综合插件(基本原理是通过修改内存直接改变星际触发中Comment动作的运行函数),并将其应用在制图工具Starcraft Map Cracker (SMC)内部。因此星际1.08的EUD地图达到了功能性上的巅峰,可以实现的功能包括但不限于:消除单位个数上限(原本的单位上限为1700个单位,1.08的EUD技术可将其拓展至无限)、修改单位的各种数据(武器、射程、子弹等)、修改单位的模型及图像、实现基于鼠标点击的按钮菜单系统及背包系统等等。
星际重制版的EUD
在星际重制版中,暴雪限制了地图触发对于内存的读写:有的内存只可读取、不可写入,有的内存不可读取或写入,只有少部分内存既可读取又可写入,详见内存表。如果在EUD地图在游戏中运行某条触发时读取或写入了非法内存,则游戏立即被终止(弹窗报错:抱歉,这张EUD地图现在不被支持......错误码是一个十六进制数,用0xFFFFFFFF减去这个十六进制数得到的结果就是当前触发正在尝试读取或写入的非法内存地址)。这就导致重制版EUD技术在功能上有了诸多限制,比如重制版无法修改任何与模型、图像相关的东西,比如无法拓展单位上限,比如无法将1.08的EUD综合插件照搬到重制版等等。
在星际争霸重制版中,EUD地图拥有以下特点:
(1)单位上限只能为原版的1700,而不是拓展的单位上限3400。(建立主机时,“单位上限”选项整个一行均为灰色,并被强制选择为“原版”,而无法选择“拓展”)
(2)无法在游戏中保存游戏
(3)游戏结束后无法保存录像
韩国制图大神trigger king开发了名为eudplib的python程序库,搭建了重制版的EUD框架,其地位相当于1.08的EUD综合插件。此外,他还基于eudplib发明了epScript编程语言(简称eps语言)。重制版的eud制图工具(EE2和EE3)即是基于eps语言的GUI。这个eud框架极其强大,即便重制版的eud条件苛刻,作者们仍然可以基于这个框架制作出很多精彩的地图。
EUD基础理论知识
【第一章】二进制数
人类使用十进制数(Decimal number)来记录数值的大小,十进制数的特点是:每个数由若干位组成,从小到大分别是个位、十位、百位...,每位上的数字都是0至9这10个数字中的一个。数数的时候,从0开始数,0,1,2,......,数到9的时候,下一个数要进一位,因此下一个数就是10(我们可以把它读作“一零”,方便理解)。
而计算机使用二进制数(Binary number)来记录数值的大小,特点是:每个数由若干位组成,每位上的数字只能是0或1中的一个。用二进制数来数数时,从0开始数,下一个数是1,再下一个数是10(读作“一零”,不是“十”),比10大1的数是11(读作“一一”),比11大1的数是100(读作“一零零”,不是“一百”)。以此类推。
我们可以看到,数值是客观存在的,但是用不同进制表示出来是不一样的。比如地上摆着四个石子,我们想要用数字来表示石子的数量,那么用十进制数表示就是4,用二进制数表示就是100,虽然写出来长相不一样,但是它们所表示的数值都是“四”这个数值。
由于二进制数写起来太麻烦,所以我们通常使用十六进制数(Hexadecimal number)来表示计算机内储存的二进制数据。十六进制数的特点:由若干位组成,每位都是0,1,2,3,4,5,6,7,8,9,A,B,C,D,E,F这十六个数中的一个,数数时:0,1,2,3,4,5,6,7,8,9,A,B,C,D,E,F,10,11,12,13,14,15,16,17,18,19,1A,1B,1C,1D,1E,1F,20......
同理,n进制数的特点也不言而喻:由若干位组成,每位上的数字有n种,并且逢n就进一位。
十进制 | 二进制 | 十六进制 | 十进制 | 二进制 | 十六进制 | 十进制 | 二进制 | 十六进制 | 十进制 | 二进制 | 十六进制 | |||
0 | 0 | 0 | 16 | 10000 | 10 | 32 | 100000 | 20 | 48 | 110000 | 30 | |||
1 | 1 | 1 | 17 | 10001 | 11 | 33 | 100001 | 21 | 49 | 110001 | 31 | |||
2 | 10 | 2 | 18 | 10010 | 12 | 34 | 100010 | 22 | 50 | 110010 | 32 | |||
3 | 11 | 3 | 19 | 10011 | 13 | 35 | 100011 | 23 | 51 | 110011 | 33 | |||
4 | 100 | 4 | 20 | 10100 | 14 | 36 | 100100 | 24 | 52 | 110100 | 34 | |||
5 | 101 | 5 | 21 | 10101 | 15 | 37 | 100101 | 25 | 53 | 110101 | 35 | |||
6 | 110 | 6 | 22 | 10110 | 16 | 38 | 100110 | 26 | 54 | 110110 | 36 | |||
7 | 111 | 7 | 23 | 10111 | 17 | 39 | 100111 | 27 | 55 | 110111 | 37 | |||
8 | 1000 | 8 | 24 | 11000 | 18 | 40 | 101000 | 28 | 56 | 111000 | 38 | |||
9 | 1001 | 9 | 25 | 11001 | 19 | 41 | 101001 | 29 | 57 | 111001 | 39 | |||
10 | 1010 | A | 26 | 11010 | 1A | 42 | 101010 | 2A | 58 | 111010 | 3A | |||
11 | 1011 | B | 27 | 11011 | 1B | 43 | 101011 | 2B | 59 | 111011 | 3B | |||
12 | 1100 | C | 28 | 11100 | 1C | 44 | 101100 | 2C | 60 | 111100 | 3C | |||
13 | 1101 | D | 29 | 11101 | 1D | 45 | 101101 | 2D | 61 | 111101 | 3D | |||
14 | 1110 | E | 30 | 11110 | 1E | 46 | 101110 | 2E | 62 | 111110 | 3E | |||
15 | 1111 | F | 31 | 11111 | 1F | 47 | 101111 | 2F | 63 | 111111 | 3F |
日常生活中,我们写的所有数都会被默认为是十进制数,比如你写了100,人们就会把它理解为“一百”这个数值。但是当我们在计算机领域内讨论问题时,你写一个100,别人不知道这个数是什么进制,它可以被理解为十进制,表示的数值为“一百”,也可以被理解为二进制,表示的数值为“四”,甚至可以被理解为八进制等等。所以,当无法从前后文推断出一个数是什么进制时,为了消除歧义,我们约定:当一个数是二进制数时,我们在它前面加上"0b"这个记号。当它是十六进制数时,我们在前面加上"0x"这个记号。当一个数是十进制数时,我们不用添加任何多余的记号。
例:
12345: 表示十进制数12345,所代表的数值是12345
1000: 表示十进制数1000,所代表的数值是1000,即一千
0b1000: 表示二进制数1000,所代表的数值是8
0b00001000: 同上,表示二进制数1000,所代表的数值是8。高位填充的0可以无视
0b00010001: 也可以写成0b10001,表示二进制数10001,所代表的数值为17
0x10: 表示十六进制数10,所代表的数值是16
0x000000FF: 表示十六进制数FF,所代表的数值是255
一个数值无论以何种进制来表示,都会是一串数字,从左到右由高位写到低位,其中最右侧的数字叫做最低位,最左侧的叫做最高位。比如246这个数,我们不用管它是什么进制,数字"6"所在的位置叫最低位,数字"2"所在的位置叫做最高位,数字"4"所在的位置相对于"6"是高位,相对于"2"所在的位置是低位。
每一位上的数字都有它自己的意义。当246是十进制数时,"6"代表6个1,"4"代表4个10,而"2"代表2个100,即十进制数从最低位到最高位的数字的基数分别为1, 10, 100, 1000......
所以:
十进制的246 = 2*100 + 4*10 + 6 = 2*10^2 + 4*10^1 + 6*10^0
十进制的62039 = 6*10^4 + 2*10^3 + 0*10^2 + 3*10^1 + 9*10^0
其中a^b代表a的b次方。
同理,二进制数从最低位到高位的数字的基数分别为二进制的1, 10, 100, 1000..., 即2^0, 2^1, 2^2, 2^3...,即1, 2, 4, 8...
十六进制数从最低位到高位的数字的基数分别为十六进制的1, 10, 100, 1000..., 即16^0, 16^1, 16^2, 16^3...,即1, 16, 256, 4096...
n进制数从最低位到高位的数字的基数分别为n^0, n^1, n^2, n^3...
即:
十六进制的246 = 2*16^2 + 4*16^1 + 6*16^0
十六进制的62039 = 6*16^4 + 2*16^3 + 0*16^2 + 3*16^1 + 9*16^0
二进制的110010 = 1*2^5 + 1*2^4 + 0*2^3 + 0*2^2 + 1*2^1 + 0*2^0
n进制的vwxyz = v*n^4 + w*n^3 + x*n^2 + y*n^1 + z*n^0
所以,我们很容易得到将二进制数和十六进制数转为十进制数的方法,即用这个数的每一位数乘以它的基数,然后把它们加起来:
0b110010 = 1*2^5 + 1*2^4 + 0*2^3 + 0*2^2 + 1*2^1 + 0*2^0 = 32+16+2 = 50
0b10011001 = 1*2^7 + 1*2^4 + 1*2^3 + 1^2^0 = 128+16+8+1 = 153
0xAB = 10*16^1 + 11*16^0 = 160+11 = 171
0x246 = 2*16^2 + 4*16^1 + 6*16^0 = 512+64+6 = 582
那么,如何把十进制数转化为n进制呢?使用短除法即可。将十进制数除以n,得到一个商和余数,再以这个商为新的被除数,除以n,得到第二个商和余数,不断重复此过程,直到商为0,然后把余数从下往上依次写出来,即可得到对应的n进制数。
下表是使用短除法将十进制数10 转化为二进制数的过程:
除数 | 被除数,商 | 余数 |
---|---|---|
2 | 10 | |
2 | 5 | 0 |
2 | 2 | 1 |
2 | 1 | 0 |
0 | 1 |
余数从下到上分别为1010,所以转化成的二进制数就是1010
这样,我们就得到了将十进制数转化为其他进制数的方法,和将其他进制数转化为十进制数的方法。
下面来讲二进制数和十六进制数的相互转化:
由于16正好等于2的4次方,所以二进制数和十六进制数的转化非常容易,完全不需要转化为十进制数然后再短除。
二进制数转化为十六进制数时,只需要将二进制数分为若干组即可,从最低位开始,每4位为一组,然后参照上面表格的第一列,将每组数替换为十六进制数字即可。
比如:
0b1110110101 = 0b 11 1011 0101 = 0x3B5
同理,十六进制数转化为二进制数,只需要将十六进制数的每一位上面的数字转化为其对应的二进制数即可。
比如0x70AB:
0x7 = 0b0111
0x0 = 0b0000
0xA = 0b1010
0xB = 0b1011
所以0x70AB = 0b0111000010101011
至此,二进制数、十进制数、十六进制数相互转化方法都已介绍完毕。在阅读后面的章节时,你可以使用含有此功能的计算器或网页来直接完成转化。
window自带的计算器有一个programmer模式(程序员模式),即可完成二进制、八进制、十进制、十六进制的互相转化及运算。
习题:
(1)
1011, 0b1011, 0x1011分别是多少进制的数字?分别求出他们所表示的数值(计算的结果用十进制数表示)
(2)将0xFF00和0x00FF转化为二进制数
(3)将0b111011110100101转化为十六进制数
(4)一个2位的十进制数所能表示的最大数值为多少?一个4位的十六进制数所能表示的最大数值为多少?一个8位的二进制数所能表示的最大数值为多少?
【第二章】 认识内存和内存地址
计算机储存数据使用的最小单元称为“位(bit)”,它由一些电子元件构成,并且只能处于"开"或"关"两种状态中的一种,分别记作1和0。“位”也可以简称为小写字母b。为了避免歧义,下文将用英文bit来表示这个概念。
1个bit有两种可能的状态: 0, 1
2个bit就会有4种可能的状态:00, 01, 10, 11
3个bit就会有8种可能的状态: 000, 001, 010, 011, 100, 101, 110, 111
...
计算机利用这些“位(bit)”的各种状态的组合来储存数据。
我们将8位作为一组,称为一个“字节(byte)”,也可简称为大写字母B,一个字节可以有256种不同的状态:
00000000
00000001
00000010
00000011
......
00001111
00010000
......
11111110
11111111
注意,11111110表示的是该字节的状态,也可以说它是该字节所储存的数据。可以发现,它与二进制数具有等价的形式。一个字节的每个状态刚与一个8位的二进制数一一对应,所以我们可以用8位二进制数来表示每个字节的状态。而一个8位的二进制数又刚好可以转化为一个2位的十六进制数。由于二进制数很冗长,所以我们一般使用一个2位的十六进制数来表示一个字节所储存的数据:
字节的状态 | 用二进制数表示 | 用十六进制数表示 | 用十进制数表示 |
---|---|---|---|
00000000 | 0b00000000 | 0x00 | 0 |
00000001 | 0b00000001 | 0x01 | 1 |
00000010 | 0b00000010 | 0x02 | 2 |
00000011 | 0b00000011 | 0x03 | 3 |
...... | ...... | ...... | |
00001111 | 0b00001111 | 0x0F | 15 |
00010000 | 0b00010000 | 0x10 | 16 |
...... | ...... | ...... | |
11111110 | 0b11111110 | 0xFE | 254 |
11111111 | 0b11111111 | 0xFF | 255 |
注意!这个十六进制数只是表示了这个字节所储存的数据而已,而这个数据可以被解释为各种含义,比如可以被解释为一个数值,或者一个字符,等等等等。
在玩游戏的时候,所有的游戏数据都储存在内存中。为了更方便地存储和读取数据,内存把它的每一份储存空间(每个字节)都进行了编号,就好比每个住户都有一个门牌号。编号是从0开始的,内存中的第一个字节被编号为0,第二个字节被编号为1,依次类推。假设内存总共有65536字节的储存空间,那么从第一个字节到最后一个字节会分别被编号为0,1,2,3,...,65535,这个编号就叫做内存地址(Memory Address),或者简称为地址(Address)。内存地址一般会被记作十六进制数,所以上面的例子里面的内存地址会写为:0x0001, 0x0002,...,0xFFFF。
在星际eud领域内讨论内存时,我们一般会将内存地址记为一个8位的十六进制数,即:
0x00000000,
0x00000001,
0x00000002,
...
0x0000000F,
0x00000010,
0x00000011,
...
注意!内存地址是内存中存储着的数据的一个标识,并不是数据本身,通过内存地址可以找到内存当中存储的数据。
在实际情况中,计算机内通常会有多个程序同时运行,同时占用内存,因此每个程序都会独自占用一块属于它自己的内存空间,不同程序一般互不干扰。所以,我们在讨论某个特定程序时(比如星际争霸游戏),会以本程序所占用的第一个字节的地址为基准,称它的地址为0,剩下的其他字节依次编号1,2,3,...。EUD内存表中的所有地址,以及本文之后的内容中所提到的所有的星际争霸游戏内的内存地址,其实都是相对的,并且我们对绝对的地址不感兴趣。
下例中的第一张图,每个字节所在地址是用十六进制表示的,第二张图的地址则是用十进制表示的。两张图的每个字节的数据都是用十六进制表示的。
我们可以看到,第一个字节的地址为0,或者写为0x00000000,这个字节所储存的数据为0x4D,即0b01001101,第二个字节所储存的数据为0x50,即0b01010000。地址为0x0000002F的字节所储存的数据为0x27,地址0x00000051内储存的数据为0x28。
在有些情况,我们可能会以某个内存地址为基准,去定位其他内存,此时,那个作为基准的内存地址被称为基址(base address),而其他内存相对于基址的距离叫做偏移或者偏移量(offset),这是一个很重要的词汇,之后你会在eud的各种资料里面见到它。比如,如果以0x0000002F这个地址为基址,那么0x0000002E这个地址是基址的上一个字节,因此0x0000002E的偏移就是-0x000000001,当我们讨论的内存范围较小时,可以用较少位数来写,比如写成-0x001。那么基址的下一个地址,0x00000030的偏移就是+0x001。基址之前的地址的偏移量均为负数,基址之后的地址的偏移量均为正数。
习题:
(1) 一个byte等于多少bit?
(2) 4字节的储存空间总共有多少种可能的状态?
(3) 每个内存地址所对应储存空间有多大?
(4) 本节的示例图片中,地址0x0000017A所储存的数据是什么?
(5) 以0x59CCA8为基址,则0x59CD0C的偏移量为多少?
【第三章】数据的读取与写入
一个字节可以有256种不同的状态,所以我们如果使用一个字节去储存一个非负整数,那么我们可以储存0至255之间的所有整数(含0和255),我们可以规定:当这个字节的状态为00000000(即0x00)时,它所储存的就是0这个整数;当它的状态为00000001(即0x01)时,它所储存的就是1这个整数;......;当它是0xFF时,它所储存的就是255这个整数。为了保证信息在读取时与写入时的一致性,我们显然要规定:这个字节的不同状态与它所表示的信息是一一对应的关系,也就是说,我们不可能让整数0和1同时对应0x00这个数据,也不可能让0x00和0x01这两个数据同时对应整数0,即这256种不同的状态分别对应256种不同的信息,并且在这个例子中,对应关系如下:
字节中储存的数据 | 再此例中,它所代表的实际含义 |
---|---|
0x00 | 整数0 |
0x01 | 整数1 |
... | ... |
0xFF | 整数255 |
当然,如果你想的话,也可以制定如下规则:
0x00对应的是255
0x01对应的是254
...
0xFE对应的是1
0xFF对应的是0
当然,你还可以制定如下规则:
0x00对应0
0x01对应1
0x02对应2
...
0x7F对应127
0x80对应-128
0x81对应-127
...
0xFE对应-2
0xFF对应-1
甚至你还可以让一个字节去储存256个不同的偶数。
显而易见,你有无数种方法去制定规则,但是无论你用哪种规则,一个字节如果用来储存整数,都只能储存256个不同的整数。所以,如果你想储存较大的数(范围较广的数),你就要使用更多的字节。
内存中的数据读取与写入的两大要素:
(1) 所要读取或写入的数据的地址
(2) 读取或写入数据的规则:包括该数据的长度,数据与其所表示的信息的对应关系(见上面两个例子),使用何种字节序(endian)等。
下面我们介绍几种常用的读写规则:
1. 单字节非负整数(8-bit unsigned integer, 简称uint8或者u8)
这种规则可以让一个字节储存0至255之间的所有整数,具体读写规则见上面的第一个例子,在此不再赘述。
假设我们要往地址0x0000FFF0内写入127这个数据,使用单字节非负整数(uint8)的规则,则我们会将0x0000FFF0内的数据改为0x7F。注意,数据的写入是涂抹性的,当我们决定在某个地址内写入新数据时,无论这个地址内原本的数据是什么,我们都会将新数据直接写入,覆盖掉原有数据。
同理,如果我们要在地址0x0000FFF0读取一个uint8,假设0x0000FFF0地址里储存的数据为0x7F,那么我们读取的结果就是127
注:在计算机领域内,非负整数的学名是unsigned integer,应当直译为无符号整数,我在此使用“非负整数”方便大家理解。