[ESA]永恒的星际联盟

 找回密码
 立即注册

!connect_header_login!

!connect_header_login_tip!

查看: 79|回复: 3

重制版EUD - 字符串的操控

[复制链接]
发表于 2020-11-18 23:25:17 | 显示全部楼层 |阅读模式
本帖最后由 PereC 于 2020-11-19 21:04 编辑

在星际游戏中你所能看到的一切文字的本质都是字符串(strings),包括但不限于:各种单位、建筑的名字,单位的技能名称和注释,地图中的各种提示(如"Psi limit exceeded."),location和switch的名字,玩家的名字,玩家的聊天信息。。。。。。
我们可以利用eud技术来对这些字符串进行操纵,即在地图编辑的过程中,使用eps代码来实现在游戏中读取、写入、修改这些字符串。感谢armoha大佬发明的euddraft,里面丰富的内置函数足以让作者们可以像写C++代码一样自由地操控这些字符串。
学习本文内容的必备软件:
(1) armoha的euddraft0.9.0.4
https://github.com/armoha/euddraft/releases
(2) scmd2的最新版2020-06-24

【第一节】计算机基础知识:字符串的编解码
本节内容算是一个小科普,意在帮助大家了解字符串的本质。
我们知道,计算机内的数据都是以1和0的形式储存的,我们之所以可以在电脑屏幕上看到各种各样的图案和符号,是因为计算机把它自身储存的数据用某种特定的方式解读出来之后呈现给了我们。反之亦然,电脑也无法直接储存任何一个“字”,因为“字”的本质是特定的图案,电脑必须把他们转化为数据(0和1的组合)来储存。因此,我们从电脑中读取数据时,需要把数据解读为人类能理解的图案,这个过程叫做“解码(decode)”;而我们在让电脑储存新的数据时,我们给电脑输入各种图案,电脑把这些图案转化为数据储存起来,这个过程叫做“编码(encode)”。由于我们讨论的领域是字符的编解码,因此在下文我将称“图案”为“字符(character)”,即我们人类用肉眼看到的具体的字符。多个字符连起来,就叫“字符串(string)”。某个特定的字符被转化成某个具体的数据,某个具体的数据又如何被解读为某个具体的字符,就要需要一套“转化标准”,又称“编码标准”。一套特定的编码标准会同时定义如何编码以及如何解码。
下文会经常出现“字符”和“数据”这两个概念,我再次澄清它们的含义:
字符/字符串:显示在电脑屏幕上的肉眼可见的文本,由数据解码而成。
数据:储存在电脑内的真实内容,由字符/字符串编码而成。

下面我们来看一个例子。
打开记事本,输入“星际123”,然后保存。现在我们使用的是utf8编码标准。我们用二进制查看器看看电脑储存了什么:

p01

p01


可以看到,“星际123”这几个字符被编码为“E6 98 9F E9 99 85 31 32 33”这一串数据,储存在了电脑中。说明在utf8标准下,“星际123”就会被编码为这些数据。
关闭文件,然后再打开,我们仍然能看到“星际123”,这不是因为电脑记住了“星际123”这几个字,而是因为电脑用utf8标准来解码“E6 98 9F E9 99 85 31 32 33”这一串数据,解码的结果是“星际123”这几个字。

字符串在被编码时,会被拆解为一个一个的单独的字符,分别编码,然后再把结果连起来。单个字符的编码通常是以字节为单位的,一个字符的编码结果可能是1字节、2字节、3字节......每个字符都有唯一对应的编码,每一串用utf8标准编码生成的数据都能被唯一解码成一串确定的字符。
比如我们用utf8编码标准来编码“星际123”,首先这里面有5个字符,按照utf8的标准:
"星": E6 98 9F
"际": E9 99 85
"1": 31
"2": 32
"3": 33
所以"星际123"这个字符串用utf8的编码结果就是“E6 98 9F E9 99 85 31 32 33”,总共9个字节长度。
在utf8标准下,所有的英文字母、数字以及键盘上的基本符号,都会被编码为1字节的数据(百度"ASCII"以了解更多),而汉字通常是3字节。

在解码“E6 98 9F E9 99 85 31 32 33”这串数据时,文件的解码器从头开始一个字节一个字节地阅读数据,看到第一个字节是"E6",根据utf8标准,可知这是一个3字节字符,因此用前3个字节"E6 98 9F"解码出字符"星",然后阅读到"E9"这个字节,根据utf8标准,可知这是一个3字节字符。。。以此类推,最终解码出字符串“星际123”。


下面,让我们换一套编码标准来解码这一串数据。如果我们改用GB2132这套编码标准来解码,我们可以看到,文件显示的字符变为“鏄熼檯123”,而文件本身的内容没有变化,储存着的数据仍然是“E6 98 9F E9 99 85 31 32 33”。

p02

p02


这是因为,在GB2132标准下,一个汉字被编码为2字节的数据,因此在解码器阅读数据时:
E6 98: "鏄"
9F E9: "熼"
99 85: "檯"
31: "1"
32: "2"
33: "3"
我们可以看到,同一串数据,用不同的编码标准来解码,会得到不同的结果。如果我们想要让输入的字符串和我们之后读取显示出来的相同,就必须使用同一套标准来编码和解码。上面的例子中,之所“星际123”变成了“鏄熼檯123”,就是因为我们使用utf8来编码,而使用了GB2312来解码。


如果我们改用GB2312标准来编码“星际123”,我们得到这一串数据“D0 C7 BC CA 31 32 33”,如下图所示。

p03

p03


而我们如果用utf8标准来解码这一串数据,就会得到如下乱码:

p04

p04


可以发现,utf8标准无法解码这一串数据,因为编码器看到第一个字节"D0"后,根据utf8标准,这只能是一个2字节字符,因此尝试解码"D0 C7",而utf8标准中没有任何字符的编码是"D0 C7",所以无法解码,即"D0 C7..."在utf8标准下不合法,即"D0 C7..."不在定义域内。然后解码器打算放弃第一个字节"D0",尝试查看下一个字节"C7"......
在星际游戏中出现的所有乱码,都是由于编解码过程使用的编码标准不统一导致的。暴雪在绝大多数情况下,游戏中会使用utf8来解码。


p05

p05

注:本节内容使用的文本编辑器为Notepad++,使用的二进制查看器为HxD








 楼主| 发表于 2020-11-20 02:13:17 | 显示全部楼层
本帖最后由 PereC 于 2020-11-20 22:58 编辑

【第二节】C语言风格字符串(Null-terminated string)

在星际游戏中,所有字符串在被编码时,结尾都会加一个字节"0x00"(空字符、字符串终止符),标志着该字符串终止。比如,字符串"abcd"储存后的数据是:
61 62 63 64 00
共消耗5个字节。这5个字节在内存中处于相邻的位置,假设0x61这个字节的地址为0x000000A0,那么字节0x62的地址就是0x000000A1,...,终止符所在的地址就是0x000000A5
因此,在读取内存中的字符串时,我们并不需要直到这个字符串的长度,只需要获取这个字符串的首个字节的地址,然后一个字节一个字节地往后读,直到碰到0x00,便停止读取。这种“只储存字符串地址、不储存字符串长度等其他信息、在字符串结尾处有0x00字节”的字符串,就可以称为C语言风格字符串。
下面,请你准备一张地图,保证Player1是Human玩家,我们来试一试写Hello world。
在eps中写入如下代码:(除非特殊说明,下文的所有代码全部写在afterTriggerExec()的环境内)

  1. const a = Db("Hello world!");
  2. eprintln(a);
复制代码

第一行代码是eps代码中最简单的创建字符串的方式,这种方式就是创建一个C语言风格字符串。相当于C语言的 char* a = "Hello world!";
第二行代码是在屏幕下方正中央的位置(游戏中用来显示"not enough mineral"等警告信息的位置)把a的值print出来。
使用euddraft0.9.0.4编译成地图后,让我们来看一看结果:


p06

p06


恭喜你成功完成Hello world!这是比DisplayText("Hello world!")强很多的代码,因为你利用变量把这个string储存了起来,这样你在之后的代码就可以自由操控它了。
虽然你在游戏中看到了"Hello world!",但是你看到的并不是真实的a。真实的a其实是"Hello world!"这个字符串的第一个字节所在的地址。让我们用以下代码来看清a的真面目:
  1. var a = Db("Hello world!");
  2. eprintln(a, ", ", hptr(a));
复制代码


第一行代码由const改成了var,导致a从地址型变量改成了整数型变量(它本身的值不变),eprintln函数遇到一个地址型变量时,会把这个变量储存的值所代表的地址(它所指向的地址)内的字符串print出来,而遇到一个整数型变量时,就会把它本身的值(这个整数值)print出来。hptr函数可以将变量的值转化为整数型变量的十六进制形式。
看一下结果:


p07

p07


这个42184520就是变量a所储存的真实值,而192460F8是这个值的十六进制写法,这个值就是"Hello world!"这个字符串的首个字节所在的内存地址,或者简单说,a储存的就是"Hello world!"这个字符串的地址。地址型变量又称为“指针(pointer)”,当a是地址型变量且它储存的值是0x192460F8时,我们称:a所指向的地址是0x192460F8
下面尝试如下代码:
  1. const a = Db("Hell\x00world!");
  2. eprintln(a);
复制代码

其中,"\x00"代表强行在字符串的这个位置写入字节0x00(即空字符),"\"叫做转义字符(escape character),它后面跟着的字符将不会被字符串编码器理解为字面意思,而是被理解为某些特殊意思,比如"\x5C"就会被字符串编码器直接看做一个整体,编码为一个字节0x5C,而并不会被理解为"反斜杠"、"x"、"5"和"C"这四个字符。上面代码的结果是:

p08

p08


可以看到,仅仅print了"Hell"这4个字符,这证实了星际读取字符串的方式:从起始地址开始,一直读到遇见空字符即停止。
用Cheat Engine查看内存,我们可以清楚地看到:

p09

p09


显而易见,const a = Db("Hell\x00world!");这一行代码做的事情其实是:向内存中连续写入"48 65 6C 6C 00 77 6F 72 6C 64 21 00"这几个字节,并将第一个字节所在的地址赋值给变量a






回复 支持 反对

使用道具 举报

 楼主| 发表于 2020-11-20 14:58:46 | 显示全部楼层
本帖最后由 PereC 于 2020-11-21 03:35 编辑

【第三节】字符串的读写、format string
本节内容将介绍一些常用函数。为了充分了解这些函数的使用方法,请各位自己多做实验。
函数名: eprintln
函数名解释: error print line (print on the error message line)
使用方法: eprintln(args*)
“args*”这个记法的含义是“若干个arguments”(以后很多函数都会用这个记法),意思是这个函数可以接受任意数量的arguments。本函数eprintln将接受的每个argument作为字符串串接(concatenate)起来,print在屏幕下方正中央位置。
请自己试验以下几个使用例:
  1. eprintln("abc", "d");
复制代码
  1. eprintln("ab", 1, "c");
复制代码
  1. const p = 0;
  2. SetDeaths(p, SetTo, 100, "Terran Marine");
  3. eprintln("Death count of Terran Marine for player", p+1, " is: ", dwread_epd(p));
复制代码

如果你想print出反斜杠、双引号,则需要使用转义字符。反斜杠用\\,双引号用\"
eprintln("a\\b, \"c\""); // a\b, "c"

函数名: ptr2s
函数名解释: pointer to string
使用方法: ptr2s(addr)

注:tct.str是该函数的旧版。euddraft作者armoha建议使用ptr2s,不要用过时函数tct.str
该函数接受一个整数型或者地址型的变量addr,返回addr所指向的地址内的字符串,返回的字符串可以直接被各种print函数接受,(它并不是一个C语言风格字符串)。
举例:
  1. const a = Db("Hello world!"); //a是一个地址型变量
  2. eprintln(a); //eprintln函数接收一个地址型变量后,print出这个变量所指向的地址内储存的字符串
复制代码

我们都知道上面print出来的结果就是"Hello world!"字符串。如果改成这样:
  1. const a = Db("Hello world!"); //a是一个地址型变量
  2. eprintln(a+1);
复制代码

自行试验后发现,print出来一个整数,这个整数是a所指向的地址再加1,之所以如此,是因为a虽然是地址型变量,但是a+1的结果是一个整数型变量,而eprintln在接受了这个整数型变量之后,直接就把这个整数给print出来了。
再看这个:
  1. const a = Db("Hello world!"); //a是一个地址型变量
  2. eprintln(a+1, " ", ptr2s(a+1));
复制代码

a+1是整数型变量,他所代表的地址是a指向的地址再加1(即往后再偏移1字节),所以这个地址是"Hello world"中的"e"所在的地址,所以ptr2s的返回结果就是"ello world!"这个字符串。

函数名: epd2s
函数名解释: EPD to string
使用方法: epd2s(epd)

该函数接受一个整数型变量(EPD值),返回这个EPD所指向的地址内的字符串。
我们知道,任何一个内存地址都唯一对应一个EPD值。相反,如果我们通过EPD值来找内存地址,那么每个EPD值都与某个4的倍数的内存地址所对应。这是eud的理论基础,基础中的基础,必须知道的。在这里重申一下内存地址与其EPD值的对应关系:
...
EPD(0x0058A360) = -1
EPD(0x0058A361) = -1
EPD(0x0058A362) = -1
EPD(0x0058A363) = -1
EPD(0x0058A364) = 0
EPD(0x0058A365) = 0
EPD(0x0058A366) = 0
EPD(0x0058A367) = 0
EPD(0x0058A368) = 1
EPD(0x0058A369) = 1
EPD(0x0058A36A) = 1
EPD(0x0058A36B) = 1
...

若a是一个地址型变量且a是4的倍数,那么epd2s(EPD(a))完全等价于ptr2s(a)
比如,epd2s(1)就会返回0x0058A368这个地址内的字符串。(当然,实际上这个地址内所储存的数据是P2的枪兵死亡数,一个4字节整数)
可以尝试如下代码:
  1. SetDeaths(P2, SetTo, 3919550694, "Terran Marine");
  2. SetDeaths(P3, SetTo, 34201, "Terran Marine");
  3. eprintln(epd2s(1));
复制代码

解释结果:
第一行的运行结果是把P2的枪兵死亡数变成3919550694,也就是向0x0058A368这个地址写入4字节数值3919550694,这个数值即0xE99F98E6,用little endian储存后得到:
0x0058A368: E6
0x0058A369: 98
0x0058A36A: 9F
0x0058A36B: E9
同理,由于34201就是0x8599,所以第二行代码的运行结果是改变如下内存:
0x0058A36C: 99
0x0058A36D: 85
0x0058A36E: 00
0x0058A36F: 00
因此epd2s(1)返回的字符串就是从epd的值为1的内存地址(0x0058A368)开始读取的字符串数据"E6 98 9F E9 99 85",用utf8解码后为"星际"
请预测下面的代码的print结果:
  1. SetDeaths(P2, SetTo, 3919550694, "Terran Marine");
  2. SetDeaths(P3, SetTo, 34201, "Terran Marine");
  3. eprintln(ptr2s(5809003));
复制代码






回复 支持 反对

使用道具 举报

 楼主| 发表于 2020-11-20 23:03:45 | 显示全部楼层
本帖最后由 PereC 于 2020-11-21 03:35 编辑

  • var a = Db("Hello world!");  
  • eprintln(a, ", ", hptr(a));  

回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Archiver|站点地图|永恒的星际联盟 ( 苏ICP备17020173号-1 )

GMT+8, 2020-12-1 15:55 , Processed in 0.013309 second(s), 6 queries , Gzip On, File On.

Powered by Discuz! X3.4

© 2001-2017 Comsenz Inc.

快速回复 返回顶部 返回列表