[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
2
3
4
5
6
7
#include <iostream>

int main()
{
std::string str = "你好,世界!";
std::cout << str;
}

将其以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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
"fmt"
"io/ioutil"
)

func main() {
// a.txt是个GBK编码文件
content, err := ioutil.ReadFile("a.txt")
if err != nil {
panic(err)
}
fmt.Println(string(content))
}

为什么我不喜欢这套系统呢?再来看看流行语言之一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_utf8str::from_utf8来从utf-8的字节流来实现转化,如果想要实现GBK之类的其它编码,就得靠encoding_rs之类的第三方库。这时,你可能会觉得这不就又回到了,C/C++和go等静态编译型语言的处境吗?对于静态编译语言,内存管理往往都是要小心翼翼的,正因如此对于动态内存变化的String类型,我们借助了相应的标准库,但实际上我们可以对字符串要求更多一点,字符串并不是字节串,如果只是要求动态变化,诸如vector、array之类的容器难道不是更好的选择吗?只有当编码被应用的时候,字符串才有了字符的意义,对于字符管理,一个很好的准则就是,内部采用统一的编码进行流转,而输入输出时,根据外部编码情况进行灵活切换。C++就不论了,go和rust的最大区别在于,go感知不到编码,因为其内部的string根本没有编码认知,上面读文件就是一个典型的例子,如果放到rust中,像下面这样

1
2
3
4
5
6
7
8
9
10
11
12
13
use std::fs::File;
use std::io::{BufReader, BufRead, Error};

fn main() -> Result<(), Error> {
let input = File::open("a.txt")?;
let buffered = BufReader::new(input);

for line in buffered.lines() {
println!("{}", line?);
}

Ok(())
}

当a.txt采用UTF-8编码时,一切正常;(虽然你可能觉得上面的写法很奇怪,但换成正常的file.read_to_string也是一样的结果)而当a.txt采用GBK编码时,自然就产生了编码错误

这说明了,rust确实可以感知到外部IO输入输出编码的变化,想要多识别几种编码也很简单,比如像下面这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use std::fs::File;
use std::io::Read;
use std::io::Error;
use encoding::{Encoding, DecoderTrap, all::GBK};

fn main() -> Result<(),Error> {
// 读取文件内容
let mut file = File::open("a.txt")?;
let mut contents = Vec::new();
file.read_to_end(&mut contents)?;
// 尝试解码为UTF-8
if let Ok(utf8_str) = String::from_utf8(contents.clone()) {
println!("UTF-8 : {}", utf8_str);
// 如果解码失败,则尝试使用GBK解码
} else if let Ok(gbk_str) = GBK.decode(&contents, DecoderTrap::Strict) {
println!("GBK : {}", gbk_str);
} else {
println!("解码失败");
}
Ok(())
}

这样其就可以感知两种字符串的编码类型了,我们要明白的是,这种限制出自rust本身对于string的实现。

无意义的内卷和精神内耗,并常常以此自我感动,这很有趣吗?

既然以字符串为题,我们再来讲个字符串相关的话题,即翻译,准确来说是HOOK型翻译器,更深层次来说,就是如何从程序中获取字符串,图片字符就直接PASS得了,这样无论如何,字符总是会出现在内存中的,我就以我最喜欢的翻译器之一LunaTranslator来讲讲,其中的一些小奥秘吧。LunaTranslator是一个综合性强,非常值得学习的项目,但其实我们不需要了解这么多,不过我还是先说一下其基本的运行流程吧。当一个游戏被启动或附加时,翻译器程序的LunaHost通过进程间通信方法之管道与之建立连接,并启动事件监听线程

host.cpp src/cpp/LunaHook/LunaHost/
1
2
3
4
5
6
7
8
9
10
void ConnectProcess(DWORD processId)
{
if (processId == GetCurrentProcessId())
return;
HANDLE hookPipe = CreateNamedPipeW((std::wstring(HOOK_PIPE) + std::to_wstring(processId)).c_str(), PIPE_ACCESS_INBOUND, PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE, PIPE_UNLIMITED_INSTANCES, 0, PIPE_BUFFER_SIZE, MAXDWORD, &allAccess);
HANDLE hostPipe = CreateNamedPipeW((std::wstring(HOST_PIPE) + std::to_wstring(processId)).c_str(), PIPE_ACCESS_OUTBOUND, PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE, PIPE_UNLIMITED_INSTANCES, PIPE_BUFFER_SIZE, 0, MAXDWORD, &allAccess);
HANDLE pipeAvailableEvent = CreateEventW(&allAccess, FALSE, FALSE, (std::wstring(PIPE_AVAILABLE_EVENT) + std::to_wstring(processId)).c_str());
SetEvent(pipeAvailableEvent);
std::thread(__handlepipethread, hookPipe, hostPipe, pipeAvailableEvent).detach();
}

至于通信的数据来源,我们一点点来说,当管道启动以后,翻译器会通过shareddllproxy.exe(它还能与各种翻译工具建立连接,但我们并不关注获取文本以后的事了)的小工具将LunaHook.dll注入游戏

texthook.py src/LunaTranslator/textsource/
1
2
3
4
5
6
7
8
9
10
def start_unsafe(self, pids):
injectpids = []
for pid in pids:
self.Luna_ConnectProcess(pid)
if self.Luna_CheckIfNeedInject(pid):
injectpids.append(pid)
if len(injectpids):
arch = ["32", "64"][self.is64bit]
dll = os.path.abspath("files/plugins/LunaHook/LunaHook{}.dll".format(arch))
injectdll(injectpids, arch, dll)

而整个LunaHook.dll才是我们获取文本的核心,其源码位于src/cpp/LunaHook/LunaHook中,在被加载函数DllMain中并没有什么值得注意的点

main.cc src/cpp/LunaHook/LunaHook/
1
2
3
4
5
6
7
8
9
10
11
12
13
BOOL WINAPI DllMain(HINSTANCE hModule, DWORD fdwReason, LPVOID)
{
switch (fdwReason)
{
case DLL_PROCESS_ATTACH:
{
...
MH_Initialize();

...
CloseHandle(CreateThread(nullptr, 0, Pipe, nullptr, 0, nullptr)); // Using std::thread here = deadlock
...
}

主要有两件事,一个是使用了MinHook的库,其原理和我们以前用过的Detours库是类似的,就是在进程内存相应位置写入无条件跳转指令JMP,另一个则是启动核心的Pipe线程

main.cc src/cpp/LunaHook/LunaHook/
1
2
3
4
5
6
7
8
9
10
11
12
DWORD WINAPI Pipe(LPVOID)
{
for (bool running = true; running; hookPipe = INVALID_HANDLE_VALUE)
...
if (dont_detach)
{
host_connected = false;
return Pipe(0);
}
else
...
}

嗯,这个死循环写得挺有特色的。循环的前半段用于与翻译器的LunaHost建立管道连接并有一小段信息获取的过程,完成以后其开始等待hostPipe的请求,虽然一眼看去Host只搞了一个设置语言的请求,但实际其他行为是通过UI触发的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
src\LunaTranslator\gui\selecthook.py
class searchhookparam(LDialog):
...
def __init__(self, parent) -> None:
...
btn.clicked.connect(self.searchstart)
...

def searchstart(self):
...
gobject.baseobject.textsource.findhook(
usestruct, dumpvalues.get("addresses", None)
)
...

src\LunaTranslator\textsource\texthook.py
class texthook(basetext):
@threader
def findhook(self, usestruct, addresses):
...
self.Luna_FindHooks(pid, usestruct, _callback, addresses)
...

src\cpp\LunaHook\LunaHost\LunaHostDll.cpp
C_LUNA_API void Luna_FindHooks(DWORD pid, SearchParam sp, findhookcallback_t findhookcallback, LPCWSTR addresses)
{
Host::FindHooks(pid, sp, [=](HookParam hp, std::wstring text)
...

src\cpp\LunaHook\LunaHost\host.cpp
namespace Host
...
void FindHooks(DWORD processId, SearchParam sp, HookEventHandler HookFound, LPCWSTR addresses)
...
prs.at(processId).Send(FindHookCmd(sp));

对于细节,我们就不关注了,插入移除Hook和传输文本也是基操,所以查找Hook才是最有趣的事。在基础的HOST_COMMAND_INSERT_PC_HOOKS指令中,其会把Window常用的各种与字符串相关的函数钩住

pchooks.cpp src/cpp/LunaHook/LunaHook/engines/pchooks/
1
2
3
4
5
6
7
8
9
10
11
12

// jichi 7/17/2014: Renamed from InitDefaultHook
void PcHooks::hookGDIFunctions(void *ptr)
...
// jichi 6/18/2015: GDI+ functions
void PcHooks::hookGDIPlusFunctions(void *ptr)
...
void PcHooks::hookD3DXFunctions(HMODULE d3dxModule, void *ptr)
// jichi 10/2/2013
// Note: All functions does not have NO_CONTEXT attribute and will be filtered.
void PcHooks::hookOtherPcFunctions(void *ptr)

在钩子初始化的时候有个HIJACK()的函数,它实际是用来判断游戏引擎或平台用的,并可以根据情形建立不同的钩子,如果无法识别就只能通过系统API了,这实际类似于以前使用vnr时的特殊码查找,在enginecollection32.cppenginecollection64.cpp进行了各种游戏引擎的枚举

enginecontrol.cpp src/cpp/LunaHook/LunaHook/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

bool checkengine()
{

auto engines = check_engines();
...
for (auto m : engines)
{
...
bool matched = safematch(m);

...
if (!matched)
continue;
ConsoleOutput(TR[MatchedEngine], m->getenginename());
bool attached = safeattach(m);
if (attached)
{
...
if (jittypedefault != JITTYPE::PC)
{
spDefault.isjithook = true;
spDefault.minAddress = 0;
spDefault.maxAddress = -1;
}
}
...
}

return false;
}

这里的safe只是用来套壳用的,实际的检测方案和钩子attach_function()由各引擎决定,例如Krkr引擎

KiriKiri.h src/cpp/LunaHook/LunaHook/engine64/
1
2
3
4
5
6
7
8
9
10
11
12

KiriKiri()
{

check_by = CHECK_BY::CUSTOM;
is_engine_certain = false;
check_by_target = []()
{
return Util::CheckFile(L"*.xp3") || Util::SearchResourceString(L"TVP(KIRIKIRI)");
};
};

类型CHECK_BY::CUSTOM表示,由引擎的check_by_target() -> bool函数来自行判断,此处检测了两个情形,一是文件由.xp3后缀封包,二是,游戏程序的.rsrc段是否包含“TVP(KIRIKIRI)”的标志字符(krkr本身是可以免封包的)。在戏画社的NeXAS引擎下,总共尝试了5种钩子

NeXAS.cpp src/cpp/LunaHook/LunaHook/engine32/
1
2
3
4
5
6
7

bool NeXAS::attach_function()
{
auto _ = _2() || _3() || b4();
return InsertNeXASHookA() || InsertNeXASHookW() || _;
}

其中的_2()为13个字节的特征匹配,其自动过滤了以@开头的控制字符串,虽然这游戏我没玩过更没反汇编调试过,但我研究过不少galgame,所以基本都能猜出个大概;_3()挺有趣的,查找两次控制字符@v后,先反查出其所处的引用位置,再反查出其所处的函数;嗯,感觉没啥继续讲的必要了,这些hook实际就是对我们反汇编调试的过程模拟,或许我更想表达的是,在二进制程序中查找想要的变量值是个复杂的学问,比如CheatEngine,我感觉它就两个作用,查找变量和修改变量,但仅仅这两个作用其衍生出了一系列的方法和脚本,而我们的字符串实际只是众多变量类型的一小部分罢了。其实,还有一个值得想想的东西,为什么字符串这么普通的东西,会有这么多的存在形式?嗯,反正我不太懂,但可以胡乱的给出结论,“大概是代码和人的多样性吧”。