编程手记之字符串
[00:00.000]作词 : 魁 [00:01.000]作曲 : 折戸伸治 [00:02.000]编曲 : 中山真斗 [00:04.710]编曲 : 中山真斗 [00:31.590]伝える言葉は決まっていたはずなのに [00:31.590]明明已经决定要传达话语 [00:38.110]変わることのない景色に目をそらしてた [00:38.110]身处恒久不变的风景中 却依然难以正视 [00:44.800]小さな勇気が欲しくてうつむいた [00:44.800]渴望得到些许勇气 不禁垂头感慨 [00:51.560]白い眩しさだけに焦がれてた [00:51.560]唯有一心向往着 那耀眼的白光 [00:57.750]夏を書き綴るノートの終わりが近付いてくる [00:57.750]描绘夏日并装订成册的记录 也正当临近尾声 [01:04.570]やがて訪れる日には せめて笑顔のままで [01:04.570]想要在即将到访的日子里 勉强保持笑容 [01:10.920]手を振りたくて… [01:10.920]向你挥动双手 [01:14.870]歩き続けることでしか届かない物があるよ [01:14.870]世上存在着 只有不断前进才终可传达的事物哟 [01:21.520]今も暖かな手のぬくもりを探し続けている [01:21.520]如今 我正一如既往地探寻着 那充满情意的 手的温度 [01:28.210]いくつもの優しさを繋いでも辿り着けないから [01:28.210]即使身边环绕着层层温柔 却仍然无法再度向前 [01:34.840]今も何度でもボクは夏の面影の中 [01:34.840]直到现在 无论经历多少次 我也始终深陷于夏日的旧时记忆中 [01:41.150]繰り返すよ [01:41.150]徘徊不定着 [01:58.260]静寂をさまよう一片の幼子は [01:58.260]藏于被静寂笼罩一方的婴儿 [02:04.810]つかの間のゆりかごの中 目を閉じていた [02:04.810]转瞬间 在那摇篮里 轻轻地闭上了眼 [02:11.550]夢から目覚めるその時がくるまで [02:11.550]自从梦境中醒来 直到那个时刻降临 [02:18.090]白い眩しさに包まれている [02:18.090]均被耀眼的白光所包围 [02:24.630]夏の足跡を追いかけボクは思い出をこぼす [02:24.630]追赶着夏天的足迹之时 我不由得思绪万千 [02:31.220]何が悲しいのかさえ忘れてしまうけれど [02:31.220]甚至连何为悲伤也忘得一干二净 [02:37.530]立ち止まれない [02:37.530]但我不能就此停下脚步 [02:41.580]歩き続ける事でしか残せないものがあるよ [02:41.580]世上存在着 只有不断前进才终可遗留的事物哟 [02:48.130]あの日途切れてしまった言葉を繋ぎ止めたいだけ [02:48.130]那天 我仅仅想要维系住 那曾经失联的话语 [02:54.910]風が涙をさらったとしても忘れないで欲しい [02:54.910]即使风儿将泪水带走 也始终不愿忘记 [03:01.520]声が届かなくても 夏を刻む花火を [03:01.520]纵然声音传达不到 那标识着夏日的花火... [03:07.900]見た記憶を… [03:07.900]回想起的记忆也... [03:35.820]羽ばたいた数を数え空を舞う羽は [03:35.820]轻轻拍打翅膀 细心计数着 在空中飞舞的羽毛 [03:42.190]小さな勇気でいつも眩しさだけ求め続けていた [03:42.190]凭借小小的勇气 不停地寻找着 无论何时都会发光的事物 [03:51.560]歩き続ける事でしか届かないものがあるよ [03:51.560]世上存在着 只有不断前进才终可传达的事物哟 [03:58.120]今も暖かな手のぬくもりを探し続けている [03:58.120]如今 我正一如既往地探寻着 那充满情意的 手的温度 [04:04.800]いくつもの優しさを繋いでも辿り着けないから [04:04.800]即使身边环绕着层层温柔 却仍然无法再度向前 [04:11.540]今も何度でもボクは夏の面影を [04:11.540]直到现在 无论经历多少次 我也终于将那夏日的旧时记忆 [04:17.860]振り返るよ [04:17.860]重现于脑海中了
知其源,以用之。
在Rust的研究之中,最让我惊喜的就是字符串了!字符串是什么,搞编程的可见过太多了,但你是否有研究过,这种最基础而常用的数据结构是怎么实现的,特别是在静态编译语言中。在C语言中,本质上没有字符串的概念,只有单纯的char数组,通过’\0’来作为结尾,而在C++、go等稍微高级点的语言中,引入了对char数组更加智能的管理,从而形成了string的概念,但我并不认为它们是真正意义上的字符串,因为其本质上没有编码的概念。下面是个简单的例子
1 | #include <iostream> |
将其以UTF-8的编码写入编辑器,再在编译的时候使用UTF-8编码选项,最后在GBK编码(比如Windows的PowerShell)的代码页中执行。虽然看起来挺SB的,但理解字符串的流转过程非常重要,在编译以后字符串常量”你好,世界!”会以UTF-8的编码,存在于可执行文件的.rdata段,实际使用的时候,如果是const char*
只是在栈帧中进行了引用,而std::string
为了后续的字符操作会有一个拷贝的过程,std::cout
作为标准输出流,其不会关注编码,只会如实的按byte输出数据,具体显示的内容则由shell根据代码页查询字体以后进行显示。这么一看,似乎只是编码没有玩好而已,因为如果在go语言下就没有任何问题,为什么呢?因为其有着一套严格的编码限制,编译的时候强制UTF-8,在fmt.Println()
时只接受UTF-8编码,并在输出时根据系统编码进行灵活切换。要想扰乱go,也可以,就是准备一个GBK编码的IO,比如文本,再直接输出
1 | package main |
为什么我不喜欢这套系统呢?再来看看流行语言之一Java的实现,在较新的版本中,其String也相当于byte数组的管理机制,但区别在于其内置一个编码器,例如只有ASCII之类简单字符时,使用Latin-1以节约内存,否则使用UTF16以提升兼容性。这样的好处就是,将byte流和char流进行严格区分,两者之间进行切换的时候,都需要指定编码以保证字符流转的正确性,平常没指定的时候或许只是用了默认编码。另一方面也是一个简单的道理,如果你的string没办法对字符编码进行内部管理的话,那么就与我在C中使用const char*
配上一点操作工具就没啥区别了,如果扯静态编译和内存开销的话,并不是什么好的理由。比如开源社区喜欢的UI框架Qt,对字符串的重新实现QString,就是一种类似Java的带编码的实现,严格意义上从Byte流到Char流或从Char流到Byte流,都是需要指定编码的。当然最过优雅的非Rust莫属了,其中Vec<u8>
和&[u8]
类似byte数组所以不做讨论,所以主要的目光聚集在String
和&str
,前者是实际的字符串数据,后者则是对字符串数据的引用,有这样的区分来自于Rust对内存数据所有权的严格限制,不严谨的情况下看成C/C++中的对象和引用也是可以的。实际上,对于大多数语言字符串String的数据本身就是常量,我们看到的各种切片连接等,实际都是产生了新的字符串,内存利用确实不够环保,但自动回收机制也可以让我们省心一些。回到String这里,其内部采用UTF-8的编码,通过.chars()
可以实现带编码的单字符操作,而.bytes()
实现字节级的操作,并在标准库中只提供String::from_utf8
或str::from_utf8
来从utf-8的字节流来实现转化,如果想要实现GBK之类的其它编码,就得靠encoding_rs之类的第三方库。这时,你可能会觉得这不就又回到了,C/C++和go等静态编译型语言的处境吗?对于静态编译语言,内存管理往往都是要小心翼翼的,正因如此对于动态内存变化的String类型,我们借助了相应的标准库,但实际上我们可以对字符串要求更多一点,字符串并不是字节串,如果只是要求动态变化,诸如vector、array之类的容器难道不是更好的选择吗?只有当编码被应用的时候,字符串才有了字符的意义,对于字符管理,一个很好的准则就是,内部采用统一的编码进行流转,而输入输出时,根据外部编码情况进行灵活切换。C++就不论了,go和rust的最大区别在于,go感知不到编码,因为其内部的string根本没有编码认知,上面读文件就是一个典型的例子,如果放到rust中,像下面这样
1 | use std::fs::File; |
当a.txt采用UTF-8编码时,一切正常;(虽然你可能觉得上面的写法很奇怪,但换成正常的file.read_to_string
也是一样的结果)而当a.txt采用GBK编码时,自然就产生了编码错误

这说明了,rust确实可以感知到外部IO输入输出编码的变化,想要多识别几种编码也很简单,比如像下面这样
1 | use std::fs::File; |
这样其就可以感知两种字符串的编码类型了,我们要明白的是,这种限制出自rust本身对于string的实现。
无意义的内卷和精神内耗,并常常以此自我感动,这很有趣吗?
既然以字符串为题,我们再来讲个字符串相关的话题,即翻译,准确来说是HOOK型翻译器,更深层次来说,就是如何从程序中获取字符串,图片字符就直接PASS得了,这样无论如何,字符总是会出现在内存中的,我就以我最喜欢的翻译器之一LunaTranslator来讲讲,其中的一些小奥秘吧。LunaTranslator是一个综合性强,非常值得学习的项目,但其实我们不需要了解这么多,不过我还是先说一下其基本的运行流程吧。当一个游戏被启动或附加时,翻译器程序的LunaHost
通过进程间通信方法之管道与之建立连接,并启动事件监听线程
1 | void ConnectProcess(DWORD processId) |
至于通信的数据来源,我们一点点来说,当管道启动以后,翻译器会通过shareddllproxy.exe
(它还能与各种翻译工具建立连接,但我们并不关注获取文本以后的事了)的小工具将LunaHook.dll
注入游戏
1 | def start_unsafe(self, pids): |
而整个LunaHook.dll
才是我们获取文本的核心,其源码位于src/cpp/LunaHook/LunaHook
中,在被加载函数DllMain
中并没有什么值得注意的点
1 | BOOL WINAPI DllMain(HINSTANCE hModule, DWORD fdwReason, LPVOID) |
主要有两件事,一个是使用了MinHook的库,其原理和我们以前用过的Detours库是类似的,就是在进程内存相应位置写入无条件跳转指令JMP,另一个则是启动核心的Pipe线程
1 | DWORD WINAPI Pipe(LPVOID) |
嗯,这个死循环写得挺有特色的。循环的前半段用于与翻译器的LunaHost建立管道连接并有一小段信息获取的过程,完成以后其开始等待hostPipe的请求,虽然一眼看去Host只搞了一个设置语言的请求,但实际其他行为是通过UI触发的
1 | src\LunaTranslator\gui\selecthook.py |
对于细节,我们就不关注了,插入移除Hook和传输文本也是基操,所以查找Hook才是最有趣的事。在基础的HOST_COMMAND_INSERT_PC_HOOKS
指令中,其会把Window常用的各种与字符串相关的函数钩住
1 |
|
在钩子初始化的时候有个HIJACK()
的函数,它实际是用来判断游戏引擎或平台用的,并可以根据情形建立不同的钩子,如果无法识别就只能通过系统API了,这实际类似于以前使用vnr时的特殊码查找,在enginecollection32.cpp
和enginecollection64.cpp
进行了各种游戏引擎的枚举
1 |
|
这里的safe只是用来套壳用的,实际的检测方案和钩子attach_function()
由各引擎决定,例如Krkr引擎
1 |
|
类型CHECK_BY::CUSTOM
表示,由引擎的check_by_target() -> bool
函数来自行判断,此处检测了两个情形,一是文件由.xp3
后缀封包,二是,游戏程序的.rsrc
段是否包含“TVP(KIRIKIRI)”的标志字符(krkr本身是可以免封包的)。在戏画社的NeXAS引擎下,总共尝试了5种钩子
1 |
|
其中的_2()
为13个字节的特征匹配,其自动过滤了以@
开头的控制字符串,虽然这游戏我没玩过更没反汇编调试过,但我研究过不少galgame,所以基本都能猜出个大概;_3()
挺有趣的,查找两次控制字符@v
后,先反查出其所处的引用位置,再反查出其所处的函数;嗯,感觉没啥继续讲的必要了,这些hook实际就是对我们反汇编调试的过程模拟,或许我更想表达的是,在二进制程序中查找想要的变量值是个复杂的学问,比如CheatEngine,我感觉它就两个作用,查找变量和修改变量,但仅仅这两个作用其衍生出了一系列的方法和脚本,而我们的字符串实际只是众多变量类型的一小部分罢了。其实,还有一个值得想想的东西,为什么字符串这么普通的东西,会有这么多的存在形式?嗯,反正我不太懂,但可以胡乱的给出结论,“大概是代码和人的多样性吧”。