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-01-00.png

星际重制版(SC:Remastered)修复了上文提到的Deaths/SetDeaths漏洞,因此在重制版刚刚发布时,已经不再有任何eud功能。但是暴雪的软件工程师Elias Bachaalany在重制版发布不久后便开发出了“重制版EUD模拟器”,该功能伴随星际1.23.0版本于2017年12月一并发布。自此之后,星际重制版在遇到了含有EUD触发的地图时,会自动启用EUD模拟器来执行地图中的触发,这使得作者依然可以像以前一样通过Deaths/SetDeaths触发来在重制版实现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条件苛刻,作者们仍然可以基于这个框架制作出很多精彩的地图。

阅读以下教程前需要准备的工具为:

(1) 最新版的scmd2

(2) scmd2插件TrigEditPlus(简称TEP)最新版

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内存表中的所有地址,以及本文之后的内容中所提到的所有的星际争霸游戏内的内存地址,其实都是相对的,并且我们对绝对的地址不感兴趣。

下例中的第一张图,每个字节所在地址是用十六进制表示的,第二张图的地址则是用十进制表示的。两张图的每个字节的数据都是用十六进制表示的。

EUD-02-01.png


EUD-01-02.png

我们可以看到,第一个字节的地址为0,或者写为0x00000000,这个字节所储存的数据为0x4D,即0b01001101,第二个字节所储存的数据为0x50,即0b01010000。地址为0x0000002F的字节所储存的数据为0x27,地址0x00000051内储存的数据为0x28。

即:

地址 数据
0x00000000 0x4D
0x00000001 0x50
0x00000002 0x51
0x00000003 0x1A
... ...
0x00000010 0x21
0x00000011 0x7B
... ...

在有些情况,我们可能会以某个内存地址为基准,去定位其他内存,此时,那个作为基准的内存地址被称为基址(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,应当直译为无符号整数,我在此使用“非负整数”方便大家理解。

2. 双字节非负整数(16-bit unsigned integer, 简称uint16或u16)

用两个字节来储存一个非负整数,显然我们就可以储存0至65535之间的所有整数了:

u16
数据 数据的意义
0x0000 整数0
0x0001 整数1
... ...
0x00FF 整数255
0x0100 256
0x0101 257
... ...
0xFFFF 65535

储存这个整数的这两个字节是相邻的两个字节,并且我们用靠前的那个地址来作为这个数所在的地址。比如我们要用0x0000FFF0和0x0000FFF1这两个字节来储存这个双字节整数,则我们称:储存这个数的地址为0x0000FFF0

储存时,首先我们将这个整数写为一个4位的十六进制数。由于这个数据占用的是两个字节,就牵扯到了字节序(endian)的问题。字节序分为大端序(big endian)小端序(little endian),它们的区别为:

我们现在要储存127这个双字节非负整数,那么首先将它转化为十六进制数0x007F,如果我们要把它储存在0x0000FFF0这个内存中,则如果按照大端序(big endian)的方式,则会在0x0000FFF0中储存0x00,在0x0000FFF1中储存0x7F。如果按照小端序(little endian)的储存方式,则会在0x0000FFF0中储存0x7F,在0x0000FFF1中储存0x00。

大端序(big endian)即高字节优先:

大端序
内存地址 数据
0x0000FFF0 0x00
0x0000FFF1 0x7F

小端序(little endian)即低字节优先:

小端序
内存地址 数据
0x0000FFF0 0x7F
0x0000FFF1 0x00

读取数据时也是同理。假设目前内存中储存的数据如下:

内存地址 数据
0x0000FFF0 0x00
0x0000FFF1 0x7F
0x0000FFF2 0x12
0x0000FFF3 0x34

那么我们现在如果在地址0x0000FFF0处以big endian读取一个双字节非负整数,那么我们读取到的结果就是0x007F,即127。如果在地址0x0000FFF0处以little endian读取一个双字节非负整数,那么我们读取到的结果就是0x7F00,即32512。如果在地址0x0000FFF1处以little endian读取一个双字节非负整数,那么我们读取到的结果就是0x127F,即4735。

可以看到,我们在写入数据和读取数据时必须使用完全一致的规则,且读取的位置与写入的位置是同一个地址,才能保证我们读取的数据跟我们写入的数据的意义是一样的,否则就会天差地别

3. 四字节非负整数(32-bit unsigned integer, 简称uint32或u32)

可储存的范围是0至4294967295之间的所有整数,其它与双字节非负整数类似,也有little endian和big endian两种字节序,在此不做赘述,只举两例:

将12345678以四字节非负整数(little endian)的形式储存到0x0000FFF0中,得到的结果是:

内存地址 数据
0x0000FFF0 0x4E
0x0000FFF1 0x61
0x0000FFF2 0xBC
0x0000FFF3 0x00

地址0x0000FFF0储存的数据为0x4E

地址0x0000FFF1储存的数据为0x61

地址0x0000FFF2储存的数据为0xBC

地址0x0000FFF3储存的数据为0x00

解释:12345678表示为十六进制是0x00BC614E,按照低字节优先的方式储存。

将127以四字节非负整数(little endian)的形式储存到0x0000FFF0中,得到的结果是:

内存地址 数据
0x0000FFF0 0x7F
0x0000FFF1 0x00
0x0000FFF2 0x00
0x0000FFF3 0x00

地址0x0000FFF0储存的数据为0x7F

地址0x0000FFF1储存的数据为0x00

地址0x0000FFF2储存的数据为0x00

地址0x0000FFF3储存的数据为0x00

解释:127表示为十六进制是0x0000007F,按照低字节优先的方式储存,即第一个字节储存0x7F,后面3个字节都储存0x00。

在这个例子中我们可以看到,使用little endian的好处:在读取0x0000FFF0中的数据时,无论你读取的是单字节整数、双字节整数还是四字节整数,你读取到的都是127这个数。

注意:星际争霸1中所有整数型数据的储存方式皆为小端序(little endian),低字节优先!故下文全部默认是little endian

4. 有符号整数(signed integer)

单字节有符号整数(8-bit signed integer, 简称s8)

储存的范围是-128至127之间的所有整数,共256个整数。规则为:

s8
数据 二进制形式的数据 数据的意义
0x00 0b 0000 0000 整数0
0x01 0b 0000 0001 整数1
... ...
0x0F 0b 0000 1111 15
0x10 0b 0001 0000 16
0x11 0b 0001 0001 17
... ...
0x7E 0b 0111 1110 126
0x7F 0b 0111 1111 127
0x80 0b 1000 0000 -128
0x81 0b 1000 0001 -127
... ...
0xFE 0b1111 1110 -2
0xFF 0b1111 1111 -1

可以发现,在此规则中,数据最高位为1时,所代表的的数值就是负数,且任何正数x的相反数-x即为x的二进制码的补码+1,比如整数1所对应的数据的二进制形式为00000001,其补码为11111110,再加1就是11111111,即为-1;再比如整数127所对应的数据的二进制形式为01111111,其补码为10000000,再加1就是10000001,即为-127。这种方式被称为二补数(two's complement)规则。星际中储存的有符号整数皆使用该规则。

注意到,当s8数据所代表的数值为非负数时,它跟u8完全一样。这个特性适用于所有的“二补数”规则下的有符号整数数据类型。

双字节(s16)和三字节(s24)略。

四字节有符号整数(32-bit signed integer, 简称s32)

储存的范围是-2147483648至2147483647之间的所有整数,共2^32个整数。也是使用二补数规则,即为:

s32
数据 数据的意义
0x00000000 整数0
0x00000001 整数1
... ...
0x000000FF 整数255
0x00000100 256
... ...
0x7FFFFFFF 2147483647
0x80000000 -2147483648
0x80000001 -2147483647
... ...
0xFFFFFFFF -1

例:

向0x0000FFF0写入一个s32(little endian)数值-2147483647,得到的结果是:

内存地址 数据
0x0000FFF0 0x01
0x0000FFF1 0x00
0x0000FFF2 0x00
0x0000FFF3 0x80

地址0x0000FFF0储存的数据为0x01

地址0x0000FFF1储存的数据为0x00

地址0x0000FFF2储存的数据为0x00

地址0x0000FFF3储存的数据为0x80

解释:根据s32规则,-2147483647对应的数据是0x80000001,按照低字节优先的方式储存,第一个字节储存0x01,中间两个字节储存0x00,第四个字节储存0x80。

例:内存状态如下时:

内存地址 数据
0x0000FFF0 0x01
0x0000FFF1 0x00
0x0000FFF2 0x00
0x0000FFF3 0x80

从地址0x0000FFF0读取一个s32(little endian),读取的结果是:

首先用little endian的规则来获取数据,获取到的数据为0x80000001,然后按照s32的规则,这个数据所代表的数是-2147483647,即为读取结果。

注意,从地址0x0000FFF0读取一个s8或者s16(little endian),读取结果都是1

5. 定点数(Fixed-point)

这个数据类型是用来储存小数的,但是其效果等价于整数。星际中只有极少数数据的类型是小数(其实本质上也是整数),比如单位的血量值(hitpoints)、实时护盾值(shield)都是是4字节有符号定点数(4 byte signed fixed-point)类型。

因此,关于定点数的知识,本教程仅介绍星际中单位的血量(hitpoint)的储存方法。注:在计算机领域,浮点数(float-point)是使用率最高的含小数点数据类型,但星际的游戏数据没有用到浮点数。

星际用4字节储存单位的血量,其中3字节(高3字节)是血量的整数部分(有符号),1字节(最低字节)是血量的小数部分。其效果等价于4字节有符号整数(s32)。虽然星际游戏中在正常情况下不会出现负数血量,但星际仍使用有符号类型来储存血量。

规则如下:

数据 数据的意义(十六进制) 数据的意义(十进制)
0x00000000 0x000000.00 0
0x00000001 0x000000.01 1/256
... ... ...
0x00000080 0x000000.80 0.5
... ... ...
0x000000FF 0x000000.FF 255/256
0x00000100 0x000001.00 1
... ... ...
0x7FFFFF00 0x7FFFFF.00 8388607
0x7FFFFF01 0x7FFFFF.01 8388607 又 1/256
... ... ...
0x7FFFFFFF 0x7FFFFF.FF 8388607 又 255/256
0x80000000 -0x800000.00 -8388608
0x80000001 -0x7FFFFF.FF -(8388607 又 255/256)
... ... ...
0xFFFFFFFF -0x000000.01 -1/256

例:

假如内存地址0x0059CCB0储存着地图中某个枪兵的血量。

假设其数据为:

内存地址 数据
0x0059CCB0 0x00
0x0059CCB1 0x28
0x0059CCB2 0x00
0x0059CCB3 0x00

则其所代表的数值为40。解释:读取到的数值为0x000028.00,整数部分为0x000028,即为40;小数部分为0x00,即为0

假设数据为:

内存地址 数据
0x0059CCB0 0x80
0x0059CCB1 0x27
0x0059CCB2 0x00
0x0059CCB3 0x00

则其表示的数值为0x000027.80,即0x27 + 0x80/256 = 39+128/256 = 39.5,即此枪兵的血量为39.5

注意,血量的小数部分为1字节,所以其精度为1/256,所以血量的小数部分只能为0, 1/256, 2/256, 3/256, ..., 255/256这256种情况中的其中一种,不可能出现0.3这种血量

如果我们以s32的方式来读取血量,那么读取到的数值除以256就是单位的实际血量。故这种数据类型等价于整数类型。

在实际操作中,我们会以u32来读取该数据。注意到,血量在绝大多数情况下不会小于0,所以用u32读取到的血量就是单位的血量(*256)。

6. 布尔变量(Boolean variable)

布尔变量又称逻辑变量,取值只能为True(真)或者False(假),因此它只需要占用1bit的储存空间:

0: False

1: True

布尔变量是本文中唯一一个只占用1bit空间的变量,其它变量所占用的空间都是以字节为单位的。星际中最典型的布尔变量就是Switch的开关状态。“开(Set)”就是True,对应的比特值是1,“关(Cleared)”就是False,对应的比特值是0。在星际游戏刚开始时,所有开关全部处于默认的关闭状态,即0,之后通过触发来改变。

一个内存地址代表一个字节,一字节等于8bit,因此可以储存8个布尔变量。在星际中,地址0x0058DC40储存的是Switch1至Switch8这8个switch的开关状态,也遵循little endian原则,即低位优先,即0x0058DC40内所储存的数据的最低位是Switch1的开关状态,最高位是Switch8的开关状态。

若0x0058DC40储存的数据为0xB3,则:

0xB3 = 0b10110011

我们可以得知,此时Switch1,2,5,6,8处于Set状态,而Switch3,4,7处于Cleared状态。

目前为止,我们的读写精度都仅限于字节,而布尔变量需要精确到bit,故在此暂不介绍其读写方式。

7. 字符(character)和字符串(string)

字符和字符串的特点是长度不固定,这部分内容将会在另一个教程里面详细介绍。

8. 指针(Pointer)

指针(指针变量)的读写标准等同于u32,也是一个四字节的非负整数,但是其储存的数据含义为内存地址。比如内存0x00628430储存着一个指针变量,若其值为0x0059CCA8,即:

内存地址 数据
0x00628430 0xA8
0x00628431 0xCC
0x00628432 0x59
0x00628433 0x00

则我们称:内存0x00628430储存的这个指针变量指向内存地址0x0059CCA8

当指针储存的值为0时,我们称它为空指针(Null Pointer)。在eud制图的实际操作中,当我们需要使用指针时,必须先确保该指针不为Null,才可使用。

指针变量对数据的读写、内存的分配有着至关重要的作用,不过这不是本节内容的重点,故暂不做详细介绍。以后可能会在科普CUnitNode结构时做详细介绍。注:UnitNode Table (CUnit Table)储存着地图上1700个单位的详细信息,是一个双向链表,unit数据的读写皆由指针控制。

习题:

1. 在某个内存地址中写入新数据,是否会覆盖掉这个地址内的原有数据?

2. 如果我们在内存0x00AABBCC中以little endian的方式写入了一个四字节非负整数(u32),那么我们在读取这个信息时,应该在哪个内存读取?以什么规则读取?

3. 假设内存中储存着如下数据:

内存地址 数据
0x00FF8800 0x7F
0x00FF8801 0x00
0x00FF8802 0x80
0x00FF8803 0x00

(1)从0x00FF8800读取一个u8(little endian),读取到的数是什么?

(2)从0x00FF8800读取一个u16(little endian),读取到的数是什么?

(3)从0x00FF8800读取一个u32(little endian),读取到的数是什么?

(4)从0x00FF8801读取一个u16(little endian),读取到的数是什么?

(5)从0x00FF8801读取一个s16(little endian),读取到的数是什么?

(6)从0x00FF8802读取一个s16(little endian),读取到的数是什么?

(7)向0x00FF8800以u16(little endian)的方式写入数值128后,写出以上四个地址的新状态。

(8)向0x00FF8800以u32(little endian)的方式写入数值128后,写出以上四个地址的新状态。

(9)向0x00FF8803以u16(little endian)的方式写入数值128后,写出以上四个地址的新状态。

(10)若地址0x00FF8800储存的是一个指针,则该指针指向的地址为?

4. 已知地址0x00AABB00中储存的数据是0x20,且从0x00AABB00读取u8、u16、u32所得到的数都一样。那么请问读取到的数是什么?读取方式为little endian还是big endian?0x00AABB01、0x00AABB02、0x00AABB03这三个地址中储存的数据是什么?

5. 星际中一共有256个可用的Switch,储存它们(的开关状态)至少需要多少字节?

【第四章】数组(Array)

数组(Array),是由相同类型的元素(element)的集合所组成的数据结构,分配一块连续的内存来存储。利用元素的索引(index)可以计算出该元素对应的存储地址。以上定义摘自维基百科。

简单来讲,数组就是一组数,每个数是同类型的,具有完全相同的特征。比如,一个班级内每个学生的考试成绩,一个公司内每个员工的薪水等等。数组的一个重要特征就是长度(length),即它所含有的元素个数。一个数组所占用的内存空间等于它的单个元素所占用的内存空间乘以这个数组的长度。如果我们将一个长度为n的数组命名为a,则它的第一个元素叫做a[0],第二个元素叫做a[1],...,第n个元素叫做a[n-1]。其中,中括号内的0,1,...,n-1就叫做元素的索引(index)。一个数组所在的地址一般是指它的第一个元素所在的地址。

下面用星际游戏中的游戏数据来举例:

每个玩家的实时水晶矿数就是一个数组,确切来讲,是一个长度为12的数组,每个元素都是一个u32数据(little endian就不用说了),该数组所在的地址为0x0057F0F0,占用的总内存为4*12=48字节。如果将此数组命名为mineral,则:

mineral[0]为Player1的水晶矿数,储存地址为0x0057F0F0

mineral[1]为Player2的水晶矿数,储存地址为0x0057F0F4

mineral[2]为Player3的水晶矿数,储存地址为0x0057F0F8

mineral[3]为Player4的水晶矿数,储存地址为0x0057F0FC

mineral[4]为Player5的水晶矿数,储存地址为0x0057F100

...

mineral[11]为Player12的水晶矿数,储存地址为0x0057F11C

显而易见,mineral[k]所在内存为0x0057F0F0+4*k,其中k为0至11间的整数。

所以,对于任意一个数组,索引为k的元素所在地址相对于该数组所在地址的偏移量为:单个元素所占的空间大小乘以k

即:若数组a的长度为n,每个元素大小为s,则元素a[k]相对于数组a地址的偏移量为ks,其中k=0,1,2,...,n-1

星际中有很多很多这种长度为12的数组,记录着每个玩家的某个数据。有的时候,Player9至Player12这4个玩家是没用的,所以有的数组长度仅为8。考虑到数组的索引是从0开始的,所以我们有时会使用玩家编号(player ID)来指代玩家:

Player1的玩家编号(player ID)为0

Player2的player ID为1

...

Player12的Player ID为11

所以,playerID为k的玩家的水晶数所在内存地址为0x0057F0F0+4k

注意,playerID是一个极其重要的概念,之后会经常使用,它不是指玩家的游戏名ID,而是玩家的编号。playerID中的ID可以理解为index。

二维数组(2d Array)

二维数组又可称为矩阵(Matrix),其储存的数据一般可以被列为一个表格,有行和列。二维数组储存的数据有两个维度,因此定位一个元素需要两个index。如果一个二维数组的两个维度分别为m和n,则我们称它为一个m×n数组,将它写为一个表格则有m行、n列。注意,m和n的顺序不能互换。每个元素都有一个行索引(row index)列索引(column index)。单位死亡数(Death Table)就是一个典型的二维数组:

死亡数表 Death Table
UnitID\PlayerID 0 (Player1) 1 (Player2) 2 (Player3) 3 (Player4) 4 (Player5) 5 (Player6) 6 (Player7) 7 (Player8) 8 (Player9) 9 (Player10) 10 (Player11) 11 (Player12)
0 (Terran Marine) 0 0 8 0 6 0 0 1 0 0 0 0
1 (Terran Ghost) 0 0 0 0 0 0 0 0 0 0 0 0
2 0 0 0 0 0 0 0 0 0 0 0 0
3 0 0 0 0 0 0 0 0 0 0 0 0
4 0 0 0 0 0 0 0 0 0 0 0 0
5 0 0 0 0 0 0 0 0 0 0 0 0
... ...                      
226 ...                      
227 ...                      

可以看到,这个表格有228行(星际一共有228种单位)和12列(总共有12个玩家),所以这个数组是一个228×12的数组,它所含有的元素个数为228×12=2736,每个元素都是一个u32(四字节非负整数)。如果我们将这个二维数组命名为DeathTable,则我们用DeathTable[x][y]或者DeathTable[x, y]来表示它第x+1行、第y+1列所对应的元素。比如DeathTable[0][0]表示的就是第一行第一列的元素,即玩家1的Terran Marine死亡数,DeathTable[0][1]代表玩家2的Terran Marine死亡数,DeathTable[0][2]代表玩家3的Terran Marine死亡数,DeathTable[1][0]代表玩家1的Terran Ghost死亡数。中括号内的数字就是元素的索引(index),二维数组有两个维度,所以需要两个索引才能定位一个元素,第一个index是行的index,第二个index是列的index,在DeathTable中,第一个index就是单位编号(unitID),第二个index就是玩家编号(playerID)

星际是使用C/C++代码编写的,因此二维数组在内存中的储存方式为:把每一行都当成一个普通数组来储存,用连续的内存来储存第一行,然后紧接着储存第二行,以此类推。一行一行地储存,而不是一列一列地储存。DeathTable中的2736个元素的储存顺序为:

DeathTable[0][0], DeathTable[0][1], DeathTable[0][2], DeathTable[0][3], ..., DeathTable[0][11], DeathTable[1][0], DeathTable[1][1], ..., DeathTable[227][10], DeathTable[227][11]

如果我们把这2736个元素视为一个普通的一维数组,并命名为a,则DeathTable[0][0]为a[0],DeathTable[0][1]为a[1],DeathTable[0][2]为a[2],...,DeathTable[1][0]为a[12],DeathTable[1][1]为a[13],...,DeathTable[227][10]为a[2734],DeathTable[227][11]为a[2735]。

很容易找到规律:DeathTable[x][y]为a[12x+y]

我们将二维数组所在的内存地址定义为它的第一行第一列的元素所在的地址,即元素[0][0]所在的地址。DeathTable所在的地址为0x0058A364,即DeathTable[0][0]所在地址为0x0058A364,根据二维数组的储存特点,我们可知DeathTable[0][1]所在地址为0x0058A368,以此类推,可知:

DeathTable[x][y]所在地址为0x0058A364 + 4(12x + y),即0x0058A364 + 48x + 4y

上面这个结论是一定要记住的,它可以被称为eud这座大厦的理论基石。

内存地址 数据类型 数据的含义
0x0058A364 u32(4字节无符号整数,小端序) P1的枪兵死亡数
0x0058A368 u32 P2的枪兵死亡数
0x0058A36C u32 P3的枪兵死亡数
0x0058A370 u32 P4的枪兵死亡数
... ... ...
0x0058A364 + 11 * 4 u32 P12的枪兵死亡数
0x0058A364 + 12 * 4 u32 P1的Ghost死亡数
0x0058A364 + 13 * 4 u32 P2的Ghost死亡数
... ... ...
0x0058A364 + 2735 * 4 u32 P12的227号单位的死亡数

举例:

问:P4的Terran Medic死亡数所在内存地址为多少?

答:P4的playerID为3,Terran Medic的unitID为34,因此我们要找的是DeathTable[34][3]的地址,根据公式可得:

0x0058A364 + 4(12*34 + 3) = 0x0058A9D4

即P4的Terran Medic死亡数所在内存地址为0x0058A9D4

所以:

若二维数组a为一个m×n数组,每个元素大小为s,则元素a[x][y]所在的地址相对于数组a所在地址的偏移量为(nx+y)s,其中x=0,1,2,...,m-1,y=0,1,2,...,n-1


再举一例:

每个玩家前46项升级是否可用(SC Upgrades Available (0-45)),就是一个46*12的二维数组,每个元素为u8(单字节整数),0代表本玩家本升级不可用,1代表可用,2至255没有意义。

注:除了SC Upgrades(共46项)之外,还有BW Upgrade(共15项),前者是星际1原版的升级,后者是星际1资料片所增加的升级项目。

SC Upgrades Available (0-45)表
PlayerID\UpgradeID 0号升级

(Terran Infantry Armor)

(人族小兵防御)

1号升级

(Terran Vehicle Plating)

(人族车辆防御)

2

(Terran Ship Plating)

(人族空军防御)

3

(Zerg Carapace)

(虫族地面防御)

... 44

(Khaydarin Core (Arbiter +50))

(仲裁者+50能量上限)

45

(Unknown Upgrade45)

0 (Player1) 1 1 1 1 ... 0
1 (Player2) 1 1 1 1 ... 0
2 (Player3) 1 1 1 1 ... 0
3 1 1 1 1 ... 0
4 1 1 1 1 ... 0
5 1 1 1 1 ... 0
6 ...          
7 ...          
8 ...          
9
10
11 (Player12)

此表有12行和46列(总共有12个玩家),所以这个数组是一个12×46的数组。并且,数组的元素是u8。此数组所在内存地址为 0x0058D088。与DeathTable不同,此表中,玩家ID变成了行。因此,“Player3的Zerg Carapace是否可用”这个信息在上表中的第3行、第4列,是储存0x0058D088 + 46 * 2 + 3这个内存中的u8数据,当其值为1时,代表P3可以升级Zerg Carapace,当其值为0时,代表P3不能升级Zerg Carapace,即其虫族升级建筑(Zerg Evolution Chamber)不含有该升级按钮。

内存地址 数据类型 数据的含义
0x0058D088 u8(单字节无符号整数) P1的Terran Infantry Armor是否可用(1为可用,0位不可用)
0x0058D089 u8 P1的Terran Vehicle Plating是否可用
0x0058D08A u8 P1的Terran Ship Plating是否可用
0x0058D08B u8 P1的Zerg Carapace是否可用
... ... ...
0x0058D088 + 45 u8 P1的45号升级是否可用
0x0058D088 + 46 u8 P2的Terran Infantry Armor是否可用
0x0058D088 + 46 + 1 u8 P2的Terran Vehicle Plating是否可用
0x0058D088 + 46 + 2 u8 P2的Terran Ship Plating是否可用
... ... ...
0x0058D088 + 46*2 u8 P3的Terran Infantry Armor是否可用
0x0058D088 + 46*2 + 1 u8 P3的Terran Vehicle Plating是否可用
... ... ...
0x0058D088 + 550 = 0x58D2AE u8 P12的44号升级是否可用
0x0058D088 + 551 = 0x58D2AF u8 P12的45号升级是否可用

习题:

(1)一个数组所在的内存地址为0x00AA00,数组长度为60,每个元素都是u16,则数组中的第一个数的index是多少?最后一个数的index是多少?这个数组总共占用多少字节的内存?数组的第10个元素所在地址为?index为20的元素所在地址为?

(2)内存0x57F120储存的是每个玩家的实时气体数量,是一个长度为12的数组,每个元素都是u32。已知在某一时刻,该数组的所有元素的值都是0,之后玩家4采集了16气体,其他玩家没有采集任何气体。请问,玩家4采集了16气体后,具体哪个地址(字节)内的数据发生了变化?变成了什么(以十六进制数表示)?

(3)已知二维数组a是一个30×12数组,每个元素都是u32,则a一共含有多少个元素?a总共占用多少字节?a[3][4]所在的地址相对于a的地址的偏移量为多少?

(4)playerID为p的玩家的枪兵死亡数所在的内存地址是多少(用p表示)?玩家3的unitID为k的单位的死亡数所在的内存地址是多少(用k表示)?

【第五章】EUD的理论基础 - Deaths, SetDeaths

一个长度为n的一维数组的索引的取值范围是0,1,2,...,n-1。一个m×n的二维数组的行索引取值范围为0,1,2,...,m-1,列索引的取值范围为0,1,2,...,n-1。显然,索引在取值范围内才可以表示这个数组内的元素。那么,如果索引的值在取值范围之外,会发生什么呢?比如一个长度为10的数组a,其10个元素为a[0], a[1], ..., a[9]。那么a[-1]是谁?a[10]又是谁呢?

这种索引超出其合法取值范围的情况,称为index out of range,一般可能会导致程序运行出错,所以一个编程严谨的程序是绝对不会允许这种情况发生的。但是,如果程序允许index out of range,则a[-1]和a[10]可以读取到数据,具体读取到什么数据,代入公式就行了。公式在上一节已经介绍:

若数组a的长度为n,每个元素大小为s,则元素a[k]相对于数组a地址的偏移量为ks,其中k=0,1,2,...,n-1

若二维数组a为一个m×n数组,每个元素大小为s,则元素a[x][y]所在的地址相对于数组a所在地址的偏移量为(nx+y)s,其中x=0,1,2,...,m-1,y=0,1,2,...,n-1

若我们允许index out of range,即不再限制其取值范围,则上述公式的k和x,y取任何(在其数据类型所允许的范围内的)整数值,都可以读到数据。

比如数组a所在地址为0xAABB08,数组长度为10,每个元素都是u32(大小为4字节),则元素a[-1]相对于数组a地址的偏移量为-4,所以读取a[-1]就等同于在0xAABB04读取一个u32,读取a[10]就等同于在0xAABB08+40=0xAABB30读取一个u32。你甚至可以读取a[-124198],a[8935239],.....。所以,以一个数组为基址,通过改变index的值,可以读取到所有内存地址内的数据。

EUD的全称为Extended Unit Death,中文直译为拓展单位死亡数。其中,“拓展”的含义就是拓展索引,即index out of range。星际触发中有一个condition叫做Deaths(......),有一个action叫做SetDeaths(...),由于星际争霸的程序员的疏忽,地图作者可以任意设定Deaths和SetDeaths的playerID和unitID参数,导致游戏在执行触发时发生了index out of range,以致于触碰到了本不应该触碰的游戏数据。地图作者们也利用这个bug,来通过Deaths/SetDeaths触发随心所欲地读取、修改游戏数据,并由此衍生出各种各样的EUD制图工具,使得星际争霸1.08有了各种各样的RPG地图。

注意,星际争霸的EUD制图技术不属于“游戏的mod版”,因为它是在游戏开发者允许的范围内进行发挥的,一切EUD行为都是基于游戏中的触发的执行来实现的,而“触发”正是游戏开发者提供给地图作者们的一个“API”,EUD技术仅仅是游戏开发者所制定的规则内的一个漏洞。而游戏的mod则是修改一些游戏文件的数据,重新制定“游戏规则”,使得它变成一个“新的游戏”。

星际重制版已经修复了这个漏铜,在重制版刚刚问世时,地图作者写EUD触发并不能够达到之前的index out of range的效果了。很快,由于作者们的抱怨,星际开发者不得不重新启用EUD,开发了“重制版EUD模拟器(EUD Emulator)”。重制版在读取星际地图文件时,如果检测到了地图文件中含有EUD触发,则会自动使用“EUD模拟器”来读取这个地图,使得地图中的EUD触发生效,且效果跟在星际1.16中打开这个地图是一样的。但是,EUD模拟器并不允许我们触及到一些特定的内存地址(实际上重制版EUD能触及到的内存地址是非常有限的),尤其是跟模型、图片相关的内存,所以地图作者不再能够像在星际1.08时代一样通过EUD触发来随意修改单位的贴图。

重制版的EUD Emulator使得重制版的EUD地图有如下特点:

(1)单位上限只能为原版的1700,而不是拓展的单位上限3400。(建立主机时,“单位上限”选项整个一行均为灰色,并被强制选择为“原版”,而无法选择“拓展”)

(2)无法在游戏中保存游戏

(3)游戏结束后无法保存录像

下面来简单介绍一下触发中的Deaths和SetDeaths是如何读取、写入任意内存的。代码部分均为TEP代码

Deaths condition:

Deaths(playerID, comparison, number, unitID)

其中:

playerID: 一个u32数据,合法(有意义的)取值范围为0至26。在TEP代码中,此参数可以写成P1,P2,P3,...,P8,...P12,...或CurrentPlayer等,或者任意数字。详见List of Players/Group IDs

comparison: 一个u8数据,值为0 (代表AtLeast),或 1 (AtMost),或10 (Exactly)。在TEP代码中,此参数可以写成AtLeast或AtMost或Exactly或任意数字。详见Complete Modifier List

number: 一个u32数据,任意取值都可以(0至4294967295)。

unitID: 一个u16数据,合法取值为0至232。在TEP代码中,此参数可以写成"Terran Marine", "Terran Ghost"等任意单位名字符串,或者任意数字。详见Trigger Unit Types

含义:

Deaths(1, AtLeast, 3, 0)的含义为“P2的Terran Marine死亡数大于等于3”。注,写成Deaths(P2, 0, 3, "Terran Marine")也表示相同的意思。

SetDeaths action:

SetDeaths(playerID, modifier, number, unitID)

其中:

playerID、number、unitID同上

modifier: 一个u8数据,值为7(SetTo),或 8 (Add),或9 (Subtract)。在TEP代码中,此参数可以写成SetTo或Add或Subtract或任意数字

含义:

SetDeaths(1, Add, 3, 0)的含义为“将P2的Terran Marine死亡数增加3”


举例:

EUD-05-01 re.png

以上触发所对应的TEP代码为:

Trigger {
    players = {P1},
    conditions = {
        Deaths(P2, Exactly, 3, "Terran SCV");
    },
    actions = {
        SetDeaths(P5, Add, 666, "Terran Vulture");
    },
}

如果把P1、P4和单位的名称改成PlayerID和UnitID的形式,并把comparison和modifier参数也改成数值形式,则可以写成:

Trigger {
    players = {0},
    conditions = {
        Deaths(1, 10, 3, 7);
    },
    actions = {
        SetDeaths(4, 8, 666, 2);
    },
}

以上两种TEP代码写法是完全等价的,二者会被编译为完全相同的触发。其中,全部使用数值的写法更贴近触发的本质。无论你用何种工具(scmd自带的触发编辑器或者TEP代码等),触发都会以数据的形式存入地图文件(chk文件的TRIG section)中。具体来讲,每一个触发都会占用2400字节,触发中的每个condition占用20字节,每个action占用32字节,详见星际地图chk结构

其中,Deaths(P2, Exactly, 3, "Terran SCV")这个condition在chk文件中储存的形式为:

EUD-05-02.png


SetDeaths(P5, Add, 666, "Terran Vulture")这个action在chk文件中储存的形式为:

EUD-05-03.png


可见,我们使用编辑器来编辑触发的本质就是向地图的chk文件中注入一些数据。之后在游戏开始时,星际争霸程序会读取地图文件中的这些数据,并将其再翻译为触发,然后执行。理论上来讲,我们可以在地图文件中写入任何数据。比如我们知道,playerID这个参数的本质是一个u32数据,我们可以对其写入任何在0至4294967295之间的数值,虽然只有当其取值为0至26中的一个整数时才有实际意义,但是我们仍然可以任意设定它的数值。同理,虽然unitID的取值在0至232之间时才有实际意义,但是它的本质是一个u16数据,我们可以将其数值设为0至65535之间的任意数。

现在,我们尝试将Deaths的第一个参数(playerID)强行设置为100:

EUD-05-05.png


输入完毕后点击空白处,可以发现它变成了:


EUD-05-04.png


我们也可以使用如下TEP代码来得到等价的结果:

Trigger {
    players = {P1},
    conditions = {
        Deaths(100, Exactly, 3, "Terran SCV");
    },
    actions = {
        SetDeaths(P5, Add, 666, "Terran Vulture");
    },
}

保存并编译后,再打开TEP界面,我可以看到:

Trigger {
    players = {P1},
    conditions = {
        Memory(0x58A644, Exactly, 0x00000003);
    },
    actions = {
        SetDeaths(P5, Add, 666, "Terran Vulture");
    },
}

之所以会变成这样,正是因为在允许index out of range的情况下,Deaths(100, Exactly, 3, "Terran SCV")含义为:

playerID=100的玩家的unitID=7的单位的死亡数等于3

即:

DeathTable[7][100]的值等于3

即:

内存地址0x0058A364 + 4(12*7 + 100) = 0x0058A644所储存的u32数据的数值等于3


其实,对任意给定的整数u和p,其中u为0至65535之前的任意整数,p为0至4294967295之间的除了13至26之外的任意整数(p取值12至26有特殊含义,之后会讲到),

Deaths(p, Exactly, 3, u)的含义为:

DeathTable[u][p]的值等于3

即内存地址0x0058A364 + 4(12u + p)所储存的u32数据的数值等于3


再举一个例子:

SetDeaths(100, Add, 5, 1000)的含义为:

将DeathTable[1000][100]的u32数据的值加5

即:将地址为0x0058A364 + 4(12*1000 + 100) = 0x00596074内的数据的值+5


进一步思考,Deaths(playerID, comparison, number, unitID)这个condition的本质其实就是:

读取某个特定内存地址的u32数据,让这个数据与某个给定数值(number)进行比较。其中,这个特定的内存是由playerID和unitID这两个参数决定的。我们可以通过修改playerID和unitID的值,来让这个condition读取任意的内存。

同理,SetDeaths(playerID, modifier, number, unitID)这个action的本质是:

修改某个特定内存的u32数据。


将Deaths或SetDeaths的unitID参数设为232以上的数值,称作Extended Unit Death,简称EUD

将Deaths或SetDeaths的playerID参数设为26以上的数值,称作Extended Player Death,简称EPD


很显然,EPD和EUD的目的完全相同,都是为了读取或修改某个特定的内存地址内的数据,他俩也是完全等价的。我们通常管这种写触发的技术统称为EUD技术。

问:我们想要读取或修改内存地址0x59CCB0所储存的u32数据,应如何设定playerID和unitID?

答:

根据公式可得:0x0058A364 + 4(12u + p) = 0x59CCB0,

化简得:12u + p = 19027

所以,任意一组满足以上等式的u和p都可使我们触及(get access to)内存0x59CCB0。

比如,如果我们想要写一个condition,其功能为“获取0x59CCB0的u32数据,并判断其是否等于256”,则可以选择以下方式中的任意一种:

Deaths(19027, Exactly, 256, 0)

Deaths(19015, Exactly, 256, 1)

Deaths(19003, Exactly, 256, 2)

......

Deaths(43, Exactly, 256, 1582)

Deaths(31, Exactly, 256, 1583)

(注意,playerID的取值为12至26有特殊含义,在此不做讨论)

Deaths(7, Exactly, 256, 1585)

Deaths(-5, Exactly, 256, 1586)

Deaths(-17, Exactly, 256, 1587)

......

Deaths(-767381, Exactly, 256, 65534)

Deaths(-767393, Exactly, 256, 65535)


注:参数playerID是一个4字节数据,在做加减乘除计算时,由于溢出效应(Overflow),将其视为s32或者u32是完全等价的,具体原因在此不做赘述。在上面的例子中,我们将其视为s32数据,方便理解。即,playerID的值为0xFFFFFFFF时,等价于它的值为-1。


虽然我们有6万多种方式来触及同一个内存地址,但是在实际运用时,我们通常将unitID取值为0,通过改变playerID的值来获取我们想要的内存地址。这是因为unitID只有2字节,而playerID有4字节。

令u=0,然后任取p的值,就可以任意读取或修改所有4的倍数的内存地址中所储存的u32数据。即,令u=0:

0x0058A364 + 4(12u + p) = 0x0058A364 + 4(12*0 + p) = 0x0058A364 + 4p

所以,一般的EUD触发都是默认u=0,即Terran Marine的死亡数,然后任意指定p的值,从而读取或修改特定的内存。本质上来讲,这个应该叫做EPD,但是大家叫EUD叫习惯了,所以就这么一直叫下来了。

以下是p的取值与内存的对应关系:

内存地址 playerID的取值(又称EPD值)
0x00000000 -1452249
0x00000004 -1452248
... ...
0x0058A35C -2
0x0058A360 -1
0x0058A364 0
0x0058A368 1
0x0058A36C 2
... ...
0xFFFFFFFC 1072289574


我们可以看到,每一个4的倍数的内存地址(a)都与一个p值一一对应,我们将这个p值称为playerID或者EPD值,它们的对应关系为:

a = 0x0058A364 + 4p

p = (a - 0x0058A364)/4

因此,我们可以定义一个名为EPD的函数:

EPD(a) = (a - 0x0058A364)/4,其中a为任意一个4的倍数的内存地址。

由于内存地址写起来很冗长,所以我们经常以EPD值来表示一个内存地址,比如0x0058A364的EPD值就是0,0x0058A35C的EPD值就是-2


由于DeathTable里面的数据都是u32,即四字节非负整数,所以使用EUD(或者EPD)所读取或写入的数据也必须是u32,所涉及的地址也必须是4的倍数。即,无论这个内存里的数据本身是什么类型的(可能是u16, s32, 甚至string),只要是用eud技术来读取,那么我们只能以u32来读取;同理,写入也只能写入一个u32。至于如何读取非4的倍数的内存里的数据,或者如何以非u32的方式来读取、写入数据,将会在之后的bitmask章节中详细介绍。

继续以地址0x59CCB0举例:

如果我们想要读取0x59CCB0所储存的u32数值并判断这个值是否等于256,则一般我们会写如下TEP代码作为condition:

Deaths(19027, Exactly, 256, 0)

如果我们想要向地址0x59CCB0写入u32数值256,则会写如下action:

SetDeaths(19027, SetTo, 256, 0)

为了方便制图者,TEP为我们提供了Memory这个condition和SetMemory这个action。在这个例子中,我们可以直接写:

Memory(0x59CCB0, Exactly, 256)

在当前版本的TEP中,它完全等价于Deaths(7, Exactly, 256, 1585)

SetMemory同理:

SetMemory(0x59CCB0, SetTo, 256)

它完全等价于SetDeath(7, Exactly, 256, 1585)

在TEP中,Memory condition以及SetMemory action的语法为:

Memory(address, comparison, number)
SetMemory(address, modifier, number)

其中,address必须为4的倍数,代表内存地址。

二者分别会被TEP编译为的Deaths condition和SetDeaths action并写入chk的TRIG section中。

那么TEP如何将address转化为Deaths/SetDeaths中的playerID和unitID参数呢?下面做一个简单的介绍:

由于有多组playerID, unitID参数都使得address=0x58A364+4*(12*unitID+playerID)成立,故TEP用如下方式决定playerID, unitID的参数值:

固定playerID在0到11,解出满足address=0x58A364+4*(12*unitID+playerID)的playerID和unitID(肯定有且仅有1组解),如果解出来的unitID在0到65535之间,则使用此组解作为Deaths/SetDeaths的playerID和unitID参数;若解出来的unitID为负数或者大于65535,则固定unit=0,并令playerID=(0x58A364-address)/4,即:

Deaths((0x58A364-address)/4, comparison, number, 0)
SetDeaths((0x58A364-address)/4, modifier, number, 0)


现在我们来举一个具体的例子。假设我们想要写一个preserved触发(执行玩家为P1),使得它的功能为“当内存地址0x59CCB0所储存的u32数据大于等于256时,就将其值减去256”。

如果想使用Deaths和Setdeaths,则需要先计算出0x59CCB0所对应的EPD值,算出来是19027,故可以如下TEP代码来实现这个触发:

Trigger {
    players = {P1},
    conditions = {
        Deaths(19027, AtLeast, 256, 0);
    },
    actions = {
        SetDeaths(19027, Subtract, 256, 0);
    },
    flag = {preserved},
}

当然,我们可以直接使用Memory和SetMemory:

Trigger {
    players = {P1},
    conditions = {
        Memory(0x59CCB0, AtLeast, 256);
    },
    actions = {
        SetMemory(0x59CCB0, Subtract, 256);
    },
    flag = {preserved},
}


这条触发的实际效果是将CUnit Table中的位于0x59CCA8的这个单位的HP不断减少1,读者若有兴趣可自己建立一张空的地图并摆上一个预置单位(根据星际的内存分配规律,地图中第一个预置单位object将会被分配在内存地址0x59CCA8中),并自行查看效果。


总结:

将condition Deaths(playerID, comparison, number, unitID)中的playerID参数设为0-26以外的值(4字节)、或者将unitID参数设为0-232以外的值(2字节),则这个condition叫做EUD condition,

将action SetDeaths(playerID, modifier, number, unitID)中的playerID参数设为0-26以外的值(4字节)、或者将unitID参数设为0-232以外的值(2字节),则这个action叫做EUD action,

含有至少一个EUD condition或者EUD action的触发,叫做EUD触发(EUD Trigger),

含有至少一条EUD触发的地图,叫做EUD地图。

特殊地,

若EUD condition中的playerID的取值不在12-26之内,则其效果相当于读取内存0x0058A364 + 4(12*unitID + playerID)所储存的u32数据,并让其与number比较(AtLeast/AtMost/Exactly)

若EUD action中的playerID的取值不在12-26之内,则其效果相当于修改内存0x0058A364 + 4(12*unitID + playerID)所储存的u32数据,Add/Subtract/SetTo number


习题:

1. EUD和EPD的全称分别是?二者在功能与目的上是否等价?

2. 触发中的条件Memory与动作SetMemory所能读写的数据类型为?

3. 判断正误:EUD地图必然含有至少一条EUD触发。

4. 相对于非EUD地图,EUD地图的劣势是什么?

5. 函数EPD(a)的自然定义域为?(a的合法取值范围是什么?)

6. 下列action中,合法的是哪些?

(1) SetMemory(0x6615A8, SetTo, 0);

(2) SetMemory(0x6615A9, SetTo, 0);

(3) SetMemory(0x6615AA, SetTo, 0);

(4) SetMemory(0x6615AB, SetTo, 0);

(5) SetMemory(0x6615AC, SetTo, 0);

7. 判断下列每个condition是否为EUD condition,若是,则写出它所读取的内存地址。注:每个condition都是使用TEP的语法标准来写的。

(1) Deaths(P1, Exactly, 3, "Terran Marine")

(2) Deaths(P12, AtLeast, 1208471293, 200)

(3) Deaths(30, Exactly, 0x12345678, 100)

(4) Memory(0x58A370, AtMost, 666666666)

(5) Memory(0x58A360, AtMost, 0)

(6) Deaths(CurrentPlayer, Exactly, 5, "Terran Vulture")

(7) Deaths(19025, Exactly, 0x1234, "Terran SCV")

8. 对于Deaths condition,如果仅让其unitID参数超出限定(可以取任意2字节数值),而保持其playerID参数取值为0至11,那么改Deaths condition所能读取的内存地址范围是?(写出起始地址和终止地址)

【第六章】星际争霸的内存表

星际游戏中的大多数游戏数据都有固定的内存,比如单位死亡数这个二维数组(Death Table)永远储存在0x0058A364,玩家的实时水晶矿数量永远储存在0x0057F0F0等等等等。星际1的前辈们通过实践,将几乎所有的重要游戏数据所在的内存地址全部找到,并总结成一个列表,这就是星际1的内存表(Memory table),是eud制图的重要参考资料。

EUD Memory Table (原网址,需翻墙,作者Farty1billion)

http://farty1billion.dyndns.org/EUDDB/

EUD Memory Table (Corbo克隆的数据库,不需翻墙,一直在更新,推荐使用)

https://euddb.website/

EUD Memory Table (Ar3sgice大佬在克隆的数据库,已不再更新)

https://ldconval.github.io/eudtools/Include/EUDDB.html

Sayoka总结的内存表(已不再更新,需要翻墙)

https://docs.google.com/spreadsheets/d/195jZK7Ap71eO1-qdVskC2xsVl7EbNdVp0hbh7N3D38A/edit#gid=0


打开内存表,我们可以看到Address、Player ID、Version等表头信息:

EUD-06-01.png

Address:内存地址,不做过多的解释。

Player ID:该内存所对应的EPD值

Version:当前内存表所适用的星际版本,目前看到的都是1.16,即该内存表适用于1.16版本的星际。星际重制版的内存表与1.16版本的基本相同。

Name:这个内存里面储存的数据的含义

Size和Length: 星际里面的所有几乎所有游戏数据都可以看成是数组,因为很多数据都满足“若干个基本元素”这样的结构。内存表中所有Length大于1的数据都可以看做“数组”,其中size代表“数组”中每个基本元素所占用的空间(单位为字节),length代表数组的长度。比如0x0057F0F0储存的是Player Mineral,就是一个size为4(每个元素都是u32,占用4字节,)、length为12的数组,总共占用48字节的空间。而length等于1的数据则不可以视为数组,比如0x006509B0储存的是CurrentPlayer值,size是4,length为1,说明它就是一个普通的独立的4字节数据(也是一个u32)。

SCR: 该内存是否被重制版支持。Simple Data、Support都是在重制版可以读写的内存,Backed By Code我不太清楚。Read Only是只读,即只能读取这个内存的值,但是不允许写入,一旦尝试向该内存写入内容,游戏直接报错。Unsupported就是不支持,即不能读也不能写,一旦尝试读写则游戏会报错。这些都仅供参考,具体还要靠自己试验。毕竟这些也是前人试验出来的结果,不是暴雪官方给的。

Description: 即备注、详细解释。点击左边蓝色的内存地址,即可查看相应Description的全部内容。

作者们没有灵感的时候,就可以去看看内存表,看看有哪些游戏数据是可供修改的。

内存表中有一些特定的结构,比如CUnit(UnitNode Table), CSprite等等,将在之后的内容介绍。

下面举几个例子。


(例一)Location Table

在内存表中找到地址0x58DC60,即Location Table,可以看到它的size为20,length为255,可知:

整个Location Table所占用的空间为20*255=5100字节,地图中总共有255个location,每个location的信息占用20字节。点击左边蓝色的0058DC60,即可查看详细说明。

详细说明的内容解释了这20个字节代表了什么信息:

前16字节储存了4个u32,分别是该location的左边界横坐标(L)、上边界的纵坐标(U)、右边界的横坐标(R)、下边界的纵坐标(D),单位为像素(Pixel)

最后的4字节中的前两个字节(u16)储存的是这个location的名字string的stringID,这个概念会在以后的string教程中介绍;后两字节储存的是这个location的各种flag,每个flag占用1bit,总共只有6个flag,所以这些flag只会占用6bit(最低的6bit),剩下的高位10bit都是0。其中,LowGround为最低位,剩5个flag,以此类推。某个地形层级的flag值为1时,表示此location不包含此地形层级。

比如,内存中储存的数据如下:

内存地址 数据 内存地址 数据 内存地址 数据
0x0058DC60 0xC0 0x0058DC70 0x08 0x0058DC80 0xA0
0x0058DC61 0x07 0x0058DC71 0x00 0x0058DC81 0x08
0x0058DC62 0x00 0x0058DC72 0x28 0x0058DC82 0x00
0x0058DC63 0x00 0x0058DC73 0x00 0x0058DC83 0x00
0x0058DC64 0x40 0x0058DC74 0xC0 0x0058DC84 0x09
0x0058DC65 0x07 0x0058DC75 0x07 0x0058DC85 0x00
0x0058DC66 0x00 0x0058DC76 0x00 0x0058DC86 0x00
0x0058DC67 0x00 0x0058DC77 0x00 0x0058DC87 0x00
0x0058DC68 0x60 0x0058DC78 0x20
0x0058DC69 0x08 0x0058DC79 0x08
0x0058DC6A 0x00 0x0058DC7A 0x00
0x0058DC6B 0x00 0x0058DC7B 0x00
0x0058DC6C 0xC0 0x0058DC7C 0x60
0x0058DC6D 0x07 0x0058DC7D 0x08
0x0058DC6E 0x00 0x0058DC7E 0x00
0x0058DC6F 0x00 0x0058DC7F 0x00

根据这些数据,我们可以知道:

0x0058DC60至0x0058DC73这20个字节储存的是整个地图中第一个location的信息:

这个location的4个边界坐标分别为0x000007C0, 0x00000740, 0x00000860, 0x000007C0,即1984,1856,2144,1984

这个location的名字的stringID为0x0008,即8

它的flag状况为0x0028,即0b00101000,即第4个和第6个flag的状态为1,所以这个location在scmd中为:

EUD-06-02.png

同理可知,地图中的第二个location的信息为:

EUD-06-03.png


注意,用eud读取内存时只能在4的倍数的内存地址读取u32,所以,我们获取第一个location的stringID或者flag信息时只能在0x0058DC70读取u32,这个数值包含了stringID和flag信息。这个u32有32bit,而6个flag分别位于这32bit的第17、18、19、20、21、22位,所以如果第一个flag值为1,它将为这个u32的数值贡献2^(17-1)=2^16=65536,第二个flag值为1则会为这个u32的数值贡献2^(18-1)=131072以此类推。这也就是为什么内存表对于location的详细说明中的flags后面写了这些数。

在实际操作中,我们无法看到内存中每个字节的数据,只能通过读取4的倍数的内存地址内的u32来获取数据。因此,在这个例子中,我们读取0x0058DC70的数据会得到2621448这个数值,我们要其转化为十六进制数0x00280008才可更直观地解读它的意义。

这里顺带提一下,星际中的每个location也有编号,这个编号称为locationID,只不过location的编号是从1开始的,而不是从0开始的。第一个location的locationID为1,第二个location的locationID为2,以此类推。"Anywhere"是默认存在的一个location,其ID为64。locationID为k的location的信息所在的内存地址相对于Location Table所在地址的偏移量为20(k-1)字节。

在了解了Location信息在内存中的储存方式之后,我们便可以只用EUD触发来实现普通触发所实现不了的功能:在游戏中实时修改location的边界。

比如,我们想在游戏开始时把6号location的四个边界全部改成128,则可以使用如下EUD触发(假设P1一定在游戏中):

Trigger {
    players = {P1},
    actions = {
        SetMemory(0x58DCC4, SetTo, 128);
        SetMemory(0x58DCC8, SetTo, 128);
        SetMemory(0x58DCCC, SetTo, 128);
        SetMemory(0x58DCD0, SetTo, 128);
    },
}


解释:locationID为6的location的信息所在的内存地址为0x58DC60+20*(6-1)=0x58DCC4,所以其左、上、右、下边界的坐标数值所在地址分别为0x58DCC4, 0x58DCC8, 0x58DCCC, 0x58DCD0

若想在游戏开始时把1至100号location的四个边界全部改成128,则可以写如下TEP代码:

for locID = 1, 100 do
    Trigger {
        players = {P1},
        actions = {
            SetMemory(0x58DC60 + 20*(locID-1) + 0, SetTo, 128);
            SetMemory(0x58DC60 + 20*(locID-1) + 4, SetTo, 128);
            SetMemory(0x58DC60 + 20*(locID-1) + 8, SetTo, 128);
            SetMemory(0x58DC60 + 20*(locID-1) + 12, SetTo, 128);
        },
    }
end

保存并编译后会自动生成100条触发。


(例二)调整游戏速度

星际的最小时间单位是游戏帧,游戏速度的本质是“一游戏帧等于多少现实毫秒”,玩家只能通过更改这个毫秒数来控制游戏速度,下文简称“帧时长”(单位为毫秒)。显然,这个数值越高,1游戏帧所花费的时间越长,游戏速度也就越慢。星际争霸预设了7个速度等级,分别为0 (Slowest),1 (Slower),2 (Slow), 3 (Normal), 4 (Fast), 5 (Faster), 6 (Fastest),并且这7个等级的帧时长分别为167, 111, 83, 67, 56, 48, 42毫秒。详见游戏速度。在不使用EUD触发的前提下,玩家只能通过调整游戏速度等级来调整游戏速度,且多人游戏中,只能由主机玩家设定一个固定的速度等级(通常为6级,Fastest),在游戏开始后则无法修改游戏速度。

所幸,我们可以通过EUD触发来在游戏中实时更改帧时长。7个速度等级的帧时长以数组的形式储存在内存地址0x5124D8中(详见内存表):

在内存表中可以看到,这一组游戏数据的size是4,length是7:即这个数组的每个元素都是u32,且数组的长度为7:

内存地址 储存的u32数据 数据所代表的意义
0x005124D8 167 游戏速度等级为0时的帧时长(毫秒)
0x005124DC 111 游戏速度等级为1时的帧时长
0x005124E0 83 游戏速度等级为2时的帧时长
0x005124E4 67 游戏速度等级为3时的帧时长
0x005124E8 56 游戏速度等级为4时的帧时长
0x005124EC 48 游戏速度等级为5时的帧时长
0x005124F0 42 游戏速度等级为6时的帧时长

星际争霸决定游戏速度的逻辑是:先读取当前游戏的速度等级,再去帧时长数组中读取相应的数据。比如当前游戏速度等级为5,星际就会读取内存0x005124EC的值,读取到的结果为48,于是将帧时长设为48毫秒。(注:在星际重制版中,我们尚未发现“当前游戏的速度等级”这个游戏数据所在的内存地址,或许此游戏数据的内存地址并无法被重制版EUD Emulator读取到)。

在实际游戏中,我们一般会将游戏速度等级设为6 (Fastest),因此如果我们想在游戏中更改游戏速度,则可以修改0x5124F0中储存的数值。

比如我们想将游戏速度设为平常情况下的2倍,则可以写如下触发:

Trigger {
    players = {P1},
    actions = {
        SetMemory(0x5124F0, SetTo, 21);
    },
}


(例三)人口

人口在内存中的储存方式为“人口点数”,2人口点数=1人口,星际使用“人口点数”这一概念的原因是虫族的小狗(Zerg Zergling)和自爆蚊(Zerg Scourge)这两个兵种,两个才等于1人口。比如,神族叉叉(Protoss Zealot)在游戏中占用人口为2,则占用的人口点数为4,虫族小狗和虫族自爆蚊占用的人口点数均为1。比如正常情况下每个玩家每个种族的人口上限为200,其实就是400人口点数。

游戏中玩家的人口数据的可以视为一个三维数组,储存在内存地址0x582144。数组的三个维度按顺序分别为:

种族:Zerg, Terran, Protoss

人口信息种类:可用人口数(能够提供人口的单位所提供的总人口点数)、已用人口数(玩家建造的单位所占用的总人口点数)、人口上限(默认为400)

玩家编号:0, 1, 2, ..., 11

数组的每个元素都是u32数据。

若将此三维数组命名为Population,则Population[race][type][playerID]这个u32元素所在的内存地址是0x582144 + 4*(race*12*3 + type*12 + playerID),含义是编号为playerID的玩家的race种族的type人口信息

比如:

Population[0][0][0]是P1的Zerg可用人口,

Population[0][0][1]是P2的Zerg可用人口,

Population[0][0][2]是P3的Zerg可用人口,

...

Population[0][0][11]是P12的Zerg可用人口,

Population[0][1][0]是P1的Zerg已用人口数,

Population[0][2][0]是P1的Zerg人口上限,

...

Population[1][2][0]是P1的Terran人口上限,

...

Population[2][1][5]是P6的Protoss已用人口数,

...


当然,我们也可以像内存表网页中所写的一样,将人口数据理解为9个一维数组(每个数组的长度都是12,元素是u32):

1维数组所在内存地址 储存的数据 含义
0x00582144 长度为12的u32数组 P1至P12的虫族可用人口数,即Population[0][0][]
0x00582174 长度为12的u32数组 P1至P12的虫族已用人口数,即Population[0][1][]
0x005821A4 长度为12的u32数组 P1至P12的虫族人口上限,即Population[0][2][]
0x005821D4 长度为12的u32数组 P1至P12的人族可用人口数,即Population[1][0][]
0x00582204 长度为12的u32数组 P1至P12的人族已用人口数,即Population[1][1][]
0x00582234 长度为12的u32数组 P1至P12的人族人口上限,即Population[1][2][]
0x00582264 长度为12的u32数组 P1至P12的神族可用人口数,即Population[2][0][]
0x00582294 长度为12的u32数组 P1至P12的神族已用人口数,即Population[2][1][]
0x005822C4 长度为12的u32数组 P1至P12的神族人口上限,即Population[2][2][]

比如我们想将P2的Protoss人口上限从400(游戏中的200人口)修改为800(游戏中的400人口),则相当于是修改Population[2][2][1],即SetMemory(0x005822C8, SetTo, 800)


习题:

1. EPD(0x58A364)的值为多少?EPD值-2000所对应的内存地址为?储存"Anywhere"这个location的信息的内存地址为?

2. 已知下面两个触发完全等价。填空。

Trigger {
    players = {P1},
    conditions = {
        Deaths(P4, AtLeast, 3, "Terran Marine");
    },
    actions = {
        SetResources(P2, SetTo, 100, Gas);
    },
}
Trigger {
    players = {P1},
    conditions = {
        Memory(____, AtLeast, ____);
    },
    actions = {
        SetMemory(____, SetTo, ____);
    },
}


提示:回忆第四章讲到的每个玩家的实时水晶矿数是如何在内存中储存的,并在内存表中找到储存玩家实时气体的地址。

3. 若尝试在地址0x00000000读取或写入数据,会发什么事?(请使用scmd自行试验)

【第七章】位掩码(Bit Mask) - DeathsX, SetDeathsX

Memory()和SetMemory()只能读写u32数据,本节将介绍的位掩码(Bit Mask)可以用来协助读写各种长度的数据,包含u16, u8, 甚至是Boolean等。使用的触发条件/动作为DeathsX,MemoryX和SetDeathsX,SetMemoryX,在scmd的Cllassic Map Triggers界面为"EUD: Memory Value (Masked)"条件,和"EUD: Modify Memory (Masked)"动作。

DeathsX/MemoryX的TEP语法为:

DeathsX(playerID, comparison, number, unitID, bitmask)

MemoryX是TrigEditPlus提供的一个condition,它等价于一个DeathsX condition,它与DeathsX的关系类似于 Memory()与Deaths()的关系:

MemoryX(address, comparison, number, bitmask)会被TEP编译为DeathsX(playerID, comparison, number, unitID, bitmask),playerID与unitID的确定方法与上一章所介绍的类似,不再赘述。

下面的SetDeathsX和SetMemoryX同理:

SetDeathsX(playerID, comparison, number, unitID, bitmask)
SetMemoryX(address, comparison, number, bitmask)

DeathsX的本质就是一个Deaths condition,如果他的playerID和unitID参数都在合理范围内,则会被星际争霸视为非eud condition,即一个普通的Deaths condition,此时bitmask参数不生效。只有当其playerID或unitID参数数值令它成为一个EUD condition时,其bitmask参数才会生效。SetDeathsX同理。

我们知道,Deaths condition的本质是读取一个u32数据,SetDeaths action的本质是修改一个u32数据。在DeathsX与SetDeathsX中,bitmask参数的效果是给目标数据加一个bitmask(位掩码),用来设定我们具体需要读写这个u32数据的哪些位。bitmask也是一个32bit的数值,一般写作十六进制,但是在理解它的作用时,我们通常要把它写为二进制形式,它的作用就是“掩盖掉”目标u32数据中我们不感兴趣的bit,只对我们感兴趣的bit做读写处理,类似于IP的位掩码。

首先来介绍逻辑运算(logical operation)。三个基本的逻辑运算符(logical operator):"与(And)"、"或(Or)"、"非(Not)"

1bit的值只可能是0或1,定义1为真(True),0为假(False),即可定义以下逻辑运算:

与(And),二元运算符,用符号表示为"&"

0 & 0 = 0

0 & 1 = 0

1 & 0 = 0

1 & 1 = 1

或(Or),二元运算符,用符号表示为"|"(一个竖线)

0 | 0 = 0

0 | 1 = 1

1 | 0 = 1

1 | 1 = 1

非(Not),一元运算符,用符号表示为"¬"或者"!"或者"~"

!0 = 1

!1 = 0

这些定义都是符合逻辑的,不做过多解释。

位操作(Bitwise operation),或称位运算,是指将整数写成二进制形式,在bit基础上进行一些操作。这里仅介绍“按位与(bitwise And)”运算,符号也为&:

bitwise And运算的法则是将两个运算子写成二进制形式,低位对齐(高位bit填充0),然后对每一位都进行“与”逻辑运算,并得到结果。

例:

5 & 11 = 1

解释:

5 = 0b0101

11 = 0b1011

0b0101 & 0b1011 = 0b0001

因为:第4位(最高位)0&1=0, 第3位1&0=1,第2位0&1=0,最低位1&1=1,因此结果为0b0001

写成竖式比较直观:

   5:  0 1 0 1

  11:  1 0 1 1

---------------

   1:  0 0 0 1


由于“按位与”这个中文翻译在句子中看起来怪怪的,因此之后会用英文bitwise And来表示这个运算。

由And运算的定义可知:无论是0还是1,只要跟0进行了And运算,结果都是0,只要跟1进行了And运算,结果都是它本身。所以Bitwise And运算可以帮助我们屏蔽掉不想要的数据。比如,我们读取到了一个8bit长度的数据,但是我们仅对其中的低4位感兴趣,那么我们就可以利用Bitwise And来屏蔽掉其高4位的数据:

0b???????? & 0b00001111 = 0b0000????

如果我们仅对高4位感兴趣,则:

0b???????? & 0b11110000 = 0b????0000

其中,0b00001111和0b11110000就可称为位掩码(Bit Mask),因为它屏蔽掉了我们不感兴趣的部分。Mask有“掩盖”、“遮罩”的含义,故bit mask译为“位掩码”。在实际操作中,位掩码通常会被写成十六进制。因此上述的两个位掩码分别为0x0F和0xF0。

我们知道,eud读取的数据一定是u32,所以针对它的位掩码一定也是32bit的,写成十六进制形式就是一个8位十六进制数。假设我们仅对u32中的高字节的u16感兴趣,则我们需要施加位掩码0b1111 1111 1111 1111 0000 0000 0000 0000,即0xFFFF0000。同理,如果我们仅对低字节的u16感兴趣,则需要施加位掩码0x0000FFFF。

比如,内存0xAABBCC00中储存了一个u16数据1234,即0x04D2,内存0xAABBCC02中储存了一个u16数据6789,即0x1A85,即:

0xAABBCC00: 0xD2

0xAABBCC01: 0x04

0xAABBCC02: 0x85

0xAABBCC03: 0x1A

我们用eud读取数据时只能在0xAABBCC00读取一个u32,读取到的数据为0x1A8504D2,即444925138=6789*65536+1234。 假设我们仅对高字节的u16感兴趣,即我们想要获取位于0xAABBCC02的u16,则我们可以对0xAABBCC00中的u32施加位掩码0xFFFF0000,因而得到0x1A850000,即444923904=6789*65536。注意,使用这个位掩码之后得到的数据是0x1A850000而不是0x1A85

假设我们仅对低字节的u16感兴趣,即0xAABBCC00的u16,则可使用位掩码0x0000FFFF,因而得到0x000004D2,即1234。

下面介绍scmd触发中的条件"EUD: Memory Value (Masked)"的含义:

下面这个condition:

[p14]

等价于下面的TEP代码(需使用TEP v1.0或更高版本!)

MemoryX(0x0057F1D4, AtLeast, 8386808, 0xFFFF0000);

它的直接含义为:

对内存0x0057F1D4所储存的u32数据使用位掩码0xFFFF0000后所得到的数值大于等于8386808

注意到8386808=128*65536,所以这个condition的本质含义为:

0x0057F1D6所储存的u16数据的数值大于等于128

为了方便书写理解,下文将统一使用TEP代码的形式来表示"EUD: Memory Value (Masked)"条件。

再看上面的0xAABBCC00的例子,如果我们想要写出一个condition:“内存0xAABBCC02中的u16数据的数值恰好为6789”,则我们在scmd中要写:

MemoryX(0xAABBCC00, Exactly, 444923904, 0xFFFF0000);

如果我们想要写出一个condition:“内存0xAABBCC00中的u16数据的数值恰好为1234”,则我们在scmd中要写:

MemoryX(0xAABBCC00, Exactly, 1234, 0x0000FFFF);

同理,考虑如下condition:

[p15]

Switch("Switch 11", Set);

如果将它写成EUD condition,要如何写?

首先我们知道,Switch Table位于0x58DC40,每个switch占用1bit,每8个switch占用1字节,因此"Switch 11"位于Switch Table的首4个字节中的第2个字节的第3位(我们仅对这1bit感兴趣,需要屏蔽其他31bit),因此我们需要读取的内存为0x58DC40,需要加的位掩码为:

0b0000 0000 0000 0000 0000 0100 0000 0000

即0x00000400

即写成EUD condition是:

MemoryX(0x0058DC40, Exactly, 1024, 0x00000400);

读者可自行试验:

[p16]

下面介绍scmd触发中的动作"EUD: Modify Memory (Masked)"的含义:

下面这个action

[p17]

等价于TEP代码:

SetMemoryX(0x0058D2B4, Add, 16777216, 0xFF000000);

它的直接含义为:

用0x0058D2B4内的数据使用跟0xFF000000做bitwise And运算得到数值x

用16777216跟0xFF000000做bitwise And运算得到数值y

计算x+y的值,得到结果z,将z写成32位二进制数。然后看0xFF000000的哪些位是1,就将z的哪些位写入0x0058D2B4的对应bit。

它的本质含义为:

将0x0058D2B7内储存的u8数据增加1,即P1的Terran Infantry Weapons等级+1

比如0x0058D2B4储存的u32为0x04030201,则SetMemoryX(0x0058D2B4, Add, 16777216, 0xFF000000)之后,0x0058D2B4储存的u32变为0x05030201。计算过程为:

0x0058D2B4储存的u32是0x04030201,跟0xFF000000做bitwise And运算之后为0x04000000

16777216即为0x01000000,跟0xFF000000做bitwise And运算之后为0x01000000

计算两数之和,为0x05000000

由于0xFF000000只有第25至32位为1,所以仅修改0x04030201的第25至32位(即最高字节),修改为0x05000000的第25至32位,修改结果为0x05030201

当然,在实际操作中,我们不用思考这么复杂的事情,因为我们使用位掩码的目的就是屏蔽掉我们不感兴趣的bit,因此我们只需要知道SetMemoryX里面的位掩码的功能是“保持被屏蔽的bit不被修改、只修改我们感兴趣的bit”即可。上面的例子中,位掩码为0xFF000000,所以说明我们对0x0058D2B4储存的u32数据的低3字节都不感兴趣,希望让他们保持不变,并且只修改最高字节的数据。

思考:

如果我们想将0x0058D2B4内储存u8数据的数值增加1,那么下面两个Action是否都可达到我们的目的?

SetMemoryX(0x0058D2B4, Add, 1, 0x000000FF);

SetMemory(0x0058D2B4, Add, 1)

答:

第一个是正确的,第二个是错误的。注意,我们的目的是将0x0058D2B4内储存u8数据的数值增加1,而不是将0x0058D2B4内储存u32数据的数值增加1,所以我们必须保证0x0058D2B5、0x0058D2B6、0x0058D2B7内的数据保持不变。假设下面4个地址储存的数据为:

0x0058D2B4: 0xFF

0x0058D2B5: 0x00

0x0058D2B6: 0x00

0x0058D2B7: 0x00

那么第一个action的结果是:

0x0058D2B4: 0x00

0x0058D2B5: 0x00

0x0058D2B6: 0x00

0x0058D2B7: 0x00

因为根据u8的加法计算法则:0xFF + 1 = 0x00

注:这种现象叫做“溢出(overflow)”,即计算结果超出该数据所能表示的最大值,当数据类型为u8时,该数值将仅保留最低的8bit,即用结果去除以256,取余数。

u8加法:0xFF + 1 = 0x100(溢出),仅保留最低的8bit,即0x00

u8加法:0xFF + 0xFF = 0x1FE(溢出),仅保留最低的8bit,即0xFE

u32加法:0x000000FF + 0x000000FF = 0x000001FE,正常

u32加法:0xFFFFFFFF + 3 = 0x100000002(溢出),仅保留最低的32bit,即0x00000002

第二个action的结果是:

0x0058D2B4: 0x00

0x0058D2B5: 0x01

0x0058D2B6: 0x00

0x0058D2B7: 0x00

因为:0x000000FF + 1 = 0x00000100

很显然,第二个action不能保证“只修改最低的字节、保持3个高字节数据不变”,所以我们只能使用第一个action,即利用位掩码。

所以说,位掩码可以协助我们读取和修改u16、u8、Boolean等等各种类型的数据。

顺便科普一下0x0058D2B0(SC Upgrades Researched)的数据结构。这个内存中储存的是Upgrade Researched Table的SC部分,它的另一部分(BW部分)在0x0058F32C。这个table也是一个二维数组,详细介绍如下:

[p18]

正如简介所写,这个table的行和列跟Death Table正好相反,本table的行索引为playerID,而列索引为UpgradeID,总共12行,46列。若将此二维数组命名为UpgradeResearchedTableSC,则UpgradeResearchedTableSC[3][5]表示的是playerID为3的玩家的UpgradeID为5的升级等级,即P4的Protoss Armor等级。DeathTable中的每个元素都是u32,而UpgradeResearchedTableSC中每个元素都是u8。

[table03]

所以我们想要读取或修改其中的u8数据,就要使用位掩码。

习题参考答案

第一章习题

(1)

1011是十进制,值为1011

0b1011是二进制, 值为11

0x1011是十六进制,值为4113

(2)

0xFF00 = 0b 1111 1111 0000 0000

0x00FF = 0b 0000 0000 1111 1111

(3)

0b 111 0111 1010 0101 = 0x77A5

(4)10^2-1=99, 16^4-1=65536, 2^8-1=255

第二章习题

(1) 8bit

(2) 2^32=4294967696

(3) 1字节

(4) 0xC3

(5) 0x59CD0C - 0x59CCA8 = 0x64 = 100

第三章习题

1. 是

2. 应该在0x00AABBCC中以little endian的方式读取u32

3.

(1) 0x7F = 127

(2) 0x007F = 127

(3) 0x0080007F = 8388735

(4) 0x8000 = 32768

(5) 0x8000(s16) = -32768

(6) 0x0080 = 128

(7)

地址0x00FF8800: 0x80

地址0x00FF8801: 0x00

地址0x00FF8802: 0x80

地址0x00FF8803: 0x00

(8)

地址0x00FF8800: 0x80

地址0x00FF8801: 0x00

地址0x00FF8802: 0x00

地址0x00FF8803: 0x00

(9)

地址0x00FF8800: 0x7F

地址0x00FF8801: 0x00

地址0x00FF8802: 0x80

地址0x00FF8803: 0x80

(10) 0x0080007F

4. 读取到的数为0x20=32,little endian,后面3字节的数据都是0x00

5. 256/8=32字节

第四章习题

(1)第一个数index为0,最后一个数index为59,总共占用120字节,第10个元素地址为0x00AA00+2*9=0x00AA12,index为20的元素所在地址为0x00AA00+2*20=0x00AA28

(2) 内存0x57F12C内的数据变为0x10

(3) a一共含有360个元素,总共占用1440字节,a[3][4]偏移量为(3*12+4)*4=160=0xA0

(4)

0x0058A364 + 4p

0x0058A364 + 4(12k + 2)

第五章习题

1. Extended Unit Death, Extended Player Death, 等价

2. u32 (little endian)

3. 正确

4. EUD图的劣势:

(1)无法使用单位拓展,即单位上限为原版的1700,而不是3400

(2)无法在游戏中保存游戏

(3)游戏结束后无法保存录像

5. 自然定义域为“所有4的倍数的内存地址”,即内存地址a的尾数为0或4或8或C

6. 只有(1)和(5)是合法的,其他3个都会报错。

解析:因为SetMemory仅允许输入4的倍数的内存地址,即内存地址尾数只能为0, 4, 8, C中的一个

7. 做法:将每个condition都写成Deaths(playerID, xxx, xxx, unitID),若playerID大于26或unitID大于232,则为EUD condition

(1) Deaths(P1, Exactly, 3, "Terran Marine")。不是EUD condition

(2) Deaths(P12, AtLeast, 1208471293, 200)。不是EUD condition

(3) Deaths(30, Exactly, 0x12345678, 100)。是EUD condition,读取的内存是0x58B69C

(4) Memory(0x58A370, AtMost, 666666666)。不是EUD condition。因为它等价于Deaths(3, AtMost, 666666666, 0)

(5) Memory(0x58A360, AtMost, 0)。是EUD condition,读取的内存是0x58A360。解析:它等价于Deaths(-1, AtMost, 0, 0)或Deaths(0xFFFFFFFF, AtMost, 0, 0)

(6) Deaths(CurrentPlayer, Exactly, 5, "Terran Vulture")。不是EUD condition

(7) Deaths(19025, Exactly, 0x1234, "Terran SCV")。是EUD condition,读取的内存是0x59CDF8

8. 可读取的内存地址为0x58A364至0x88A364(不含右端点)

解析:unitID的取值范围是0至65535,题目限定的playerID是0至11,Deaths读取的内存地址是0x58A364+4*(12*unitID+playerID),代入unitID=playerID=0得到起始地址0x58A364,代入unitID=65535, playerID=11可得Deaths读取的最大地址是0x88A360(所储存的u32)

第六章习题

1.

EPD(0x58A364)=(0x58A364-0x58A364)/4 = 0

EPD值-2000所对应的内存地址为0x58A364-2000*4 = 0x588424

0x58DC60 + 20*63 = 0x58E14C

2.

Trigger {
    players = {P1},
    conditions = {
        Memory(0x0058A370, AtLeast, 3);
    },
    actions = {
        SetMemory(0057F124, SetTo, 100);
    },
}


3. 游戏报错0xFFFFFFFF

第七章习题

1.

(1) True

(2) True

(3) 0x04030202

(4) 0x04030001

(5) 0x04030101

(6) SetMemoryX(0x58D5B0, SetTo, 0x03000000, 0xFF000000)或SetMemoryX(0x58D5B0, SetTo, 50331648, 0xFF000000)

2.

Trigger {

players = {P1},

conditions = {

MemoryX(0x0058D314, AtLeast, 0x04000000, 0xFF000000);

},

actions = {

SetMemoryX(0x0058D3F4, SetTo, 0x00140000, 0x00FF0000);

},

}

Trigger {

players = {P1},

conditions = {

MemoryX(0x0058D314, AtLeast, 67108864, 0xFF000000);

},

actions = {

SetMemoryX(0x0058D3F4, SetTo, 1310720, 0x00FF0000);

},

}

解析:

Player3的Zerg Missile Attacks等级所在地址为0x0058D2B0+46*2+11 = 0x58D317,即0x58D314储存的u32的最高字节,故使用位掩码0xFF000000。大于3,即AtLeast 4,即AtLeast 4*16^6=67108864

Player8的Zerg Flyer Caparace等级所在地址为0x0058D2B0+46*7+4 = 0x58D3F6,即0x58D3F4储存的u32的次高字节,故使用位掩码0x00FF0000。设为20,即SetTo 20,即SetTo 20*16^4=1310720