几个月前,Reddit上有一篇帖子(链接),描述了一款游戏,它使用记事本的开源克隆来处理所有的输入和渲染。当我读到这件事的时候,我想,如果能看到一些类似的东西在普通的Windows记事本上运行,那将会是一件非常酷的事情。然后我花了太多的空闲时间来做这件事。
我最终制作了一款Snake游戏和一个使用普通记事本完成所有输入和渲染任务的小型光线跟踪器,并在此过程中了解了DLL注入、API挂钩和内存扫描。似乎把我学到的东西写出来可能会成为一本有趣的读物,并给我一个机会展示我同时构建的那些愚蠢的东西,所以这就是接下来几篇博客帖子要讲的内容。
由于篇幅的关系,我把这篇文章分成了两篇博文。第一个帖子将讨论内存扫描器的工作原理,以及我如何使用它将notepad.exe转换为30+fps的渲染目标。我还将讨论我构建的渲染到记事本中的光线跟踪器。
第二个帖子将讨论使用windows钩子来捕获输入并分享我创建的Snake游戏,该游戏几乎使用了这两个帖子中描述的所有内容。
如果你只是想看看代码,整个项目(包括光线跟踪器和蛇游戏)都在gihub上。
很明显,开始这一切的地方是讨论如何将关键事件发送到正在运行的记事本实例。这是这个项目中最无聊的部分,所以我就长话短说了。
如果您从未使用Win32控件构建过应用程序(就像我没有过的那样),您可能会惊讶地了解到,从菜单栏到按钮,从技术上讲,每个UI元素都是它自己的“窗口”,而将键输入发送到程序涉及将输入发送到您想要接收的UI元素。幸运的是,Visual Studio附带了一个名为Spy++的工具,它可以列出组成给定应用程序的所有窗口。
Spy++透露,我要找的记事本子窗口是“编辑”窗口。一旦我知道了这一点,就只需要找出正确的Win32函数调用组合来获得该UI元素的HWND,然后将键输入发送到那里。拿到HWND看起来像这样:
HWND GetWindowForProcessAndClassName(DWORD PID,const char*className){HWND curWnd=GetTopWindow(0);//0 arg表示获取Z顺序字符classNameBuf[256]的顶部窗口;而(curWnd!=NULL){DWORD curPid;DWORD dwThreadId=GetWindowThreadProcessId(curWnd,&;curPid。HWND Child Window=FindWindowEx(curWnd,NULL,className,NULL);IF(Child Window!=NULL)RETURN CHILD Window;}curWnd=GetNextWindow(curWnd,GW_HWNDNEXT);}RETURN NULL;}。
一旦我有了正确控件的HWND,在记事本的编辑控件中绘制一个字符只需使用PostMessage向其发送一个WM_CHAR事件即可。
请注意,如果你想自己使用Spy++,你可能会想要使用它的64位版本,这令人费解地不是Visual Studio 2019默认启动的Verion。相反,您需要在Visual Studio程序文件中搜索“spyxx_amd64.exe”。
大约10秒后,我才意识到,即使我能找到一种不那么麻烦的方式,使用窗口消息将整个游戏屏幕绘制到记事本上,也太慢了,甚至连接近30 Hz的刷新周期都太慢了。它也真的很无聊,所以我没有花太长时间去寻找让它走得更快的方法。
在设置假键输入时,我想起了CheatEngine。这是一个让用户在他们的机器上运行的进程中查找和修改内存的程序。大多数时候,它被人们用来试图在游戏中作弊或做其他让游戏开发人员伤心的事情,但事实证明,它也可以成为一种善的力量。
像CheatEngine这样的内存扫描器通过查找目标进程中包含特定值的所有内存地址来工作。假设你正在玩一个游戏,你想给自己更多的健康,你可以按照如下所示的过程进行:
使用内存扫描器查找游戏内存中存储您健康价值的所有地址(比方说100个)。
搜索您以前找到的所有地址(存储100)以查找现在存储92的地址。
重复此过程,直到您拥有单个内存地址(很可能是存储您的健康的地址)。
这几乎就是我所做的,只是我搜索的不是健康值,而是存储当前显示在记事本中的文本字符串的内存。经过反复试验,我能够使用CheatEngine查找(并更改)正在显示的文本。我还学到了关于记事本的三个重要信息:
记事本的编辑窗口在屏幕上存储UTF-16格式的文本,即使窗口的右下角显示您的文件是UTF-8。
如果我不断删除并重新键入同一字符串,CheatEngine将开始在内存中查找此数据的多个副本(可能是撤消缓冲区?)。
我无法用更长的字符串替换显示的文本,这意味着记事本没有预先分配文本缓冲区。
尽管不能修改文本缓冲区的长度,但这似乎很有希望,所以我决定编写自己的小型内存扫描器嵌入到我的项目中。
我找不到很多关于构建内存扫描仪的信息,但我确实找到了Chris Wellons的一篇很棒的博客文章,其中谈到了他为自己的作弊工具编写的内存扫描仪(并提供了链接)。根据那篇博客文章和我使用CheatEngine的一些经验,我能够拼凑出内存扫描仪的基本算法如下所示:
对于目标进程分配的每个内存块,如果该块已提交并启用了读/写,则在发现IT返回该地址时扫描该块的内容以查找我们的字节模式
我的整个内存扫描器实现最终只有大约40行代码,所以我将遍历所有这些代码。
内存扫描程序需要做的第一件事是迭代进程分配的内存。
因为Windows上每个64位进程的虚拟内存范围是相同的(0x00000000000到0x7FFFFFFFFFFF),所以我从指向地址0的指针开始,并使用VirtualQueryEx获取有关目标程序的虚拟地址的信息。
VirtualQueryEx将具有相同内存属性的连续页面分组到MEMORY_BASIC_INFORMATION结构中,因此VirtualQueryEx为给定地址返回的结构很可能包含有关多个页面的信息。返回的MEMORY_BASIC_INFORMATION存储这组共享的内存属性,以及页面跨度的起始地址和整个跨度的大小。
一旦我有了第一个MEMORY_BASIC_INFORMATION结构,遍历内存就只需将当前结构的BaseAddress和RegionSize成员相加,并将新地址提供给VirtualQueryEx以获得下一组连续的页面。
char*FindBytePatternInProcessMemory(Handle Process,Const char*Pattern,size_t patternLen){char*basePtr=(char*)0x0;memory_basic_information memInfo;While(VirtualQueryEx(process,(void*)basePtr,&;memo,sizeof(Memory_Basic_Information)){const DWORD mem_Commit=0x1000;const DWORD。state==mem_Commit&;&;memInfo。Protect==PAGE_ReadWrite){//在此内存中搜索我们的模式}basePtr=(char*)备忘录信息。BaseAddress+备忘录信息。RegionSize;}}。
上面的代码稍微提前了一点,还通过检查.State和.Protect结构成员来确定是否提交了一组页面并启用了读/写。您可以在MEMORY_BASIC_INFORMATION的文档中找到这些变量的所有可能值,但是我的扫描仪关心的值是状态0x1000(MEM_COMMIT)和保护级别0x04(PAGE_READWRITE)。
不可能直接读取不同进程的地址空间中的数据(或者至少,我没有无意中知道如何做到这一点)。相反,我首先需要将页面范围的内容复制到内存扫描器的地址空间。我用ReadProcessMemory做到了这一点。
一旦将内存复制到本地可见的缓冲区,就可以很容易地在其中搜索字节模式。为了简单起见,在我的第一个扫描器实现中,我忽略了内存中可能存在目标字节模式的多个副本的可能性。后来,我为这个问题想出了一个老生常谈的解决办法,使我不必在我的扫描仪逻辑中实际解决这个问题。
char*FindPattern(char*src,size_t srcLen,const char*pattern,size_t patternLen){char*cur=src;size_t curPos=0;While(curPos<;srcLen){if(memcmp(cur,pattern,patternLen)==0){return cur;}curPos++;cur=&;src[curPos。
如果FindPattern()返回匹配指针,则需要将其地址转换为目标进程地址空间中相同位内存的地址。为此,我从从FindPattern返回的地址中减去本地缓冲区的起始地址以获得偏移量,然后将其添加到目标进程中的内存块的基地址上。你可以在下面看到这个。
char*FindBytePatternInProcessMemory(Handle Process,Const char*Pattern,size_t patternLen){MEMORY_BASIC_INFORMATION备忘录信息;char*basePtr=(char*)0x0;While(VirtualQueryEx(Process,(void*)basePtr,&;memo,sizeof(MEMORY_BASIC_INFORMATION)){const DWORD mem_Commit=0x1000;const DWORD。state==mem_Commit&;&;memInfo。Protect==PAGE_ReadWrite){char*remoteMemRegionPtr=(char*)memInfo。BaseAddress;char*localCopyContents=(char*)malloc(备忘录信息。RegionSize);Size_T bytesRead=0;IF(ReadProcessMemory(Process,memInfo.。BaseAddress、localCopyContents、memInfo。RegionSize,&;bytesRead)){char*Match=FindPattern(localCopyContents,memInfo.。RegionSize,Pattern,patternLen);IF(Match){uint64_t diff=(Uint64_T)Match-(Uint64_T)(LocalCopyContents);char*processPtr=remoteMemRegionPtr+diff;return processPtr;}}free(LocalCopyContents);}basePtr=(char*)备忘录信息。BaseAddress+备忘录信息。RegionSize;}}。
如果您想看到这方面的工作示例,请查看本文随附的GitHub资源库中的“MemoryScanner”项目。在记事本上试试吧!(它还没有在其他任何东西上试用过,所以YMMV)。
请记住,前面的记事本将其屏幕文本缓冲区存储为UTF-16数据,因此提供给FindBytePatternInMemory()的字节模式也必须是UTF-16。对于简单字符串,这只需要在每个字符后添加一个零字节。GitHub中的MemoryScanner项目为您完成此操作:
//将输入字符串转换为UTF16(Hackly)const size_t patternLen=strlen(argv[2]);char*pattern=new char[patternLen*2];for(int i=0;i<;patternLen;++i){pattern[i*2]=argv[2][i];pattern[i*2+1]=0x0;}。
一旦我在记事本中获得了显示的文本缓冲区的地址,下一步就是使用WriteProcessMemory修改它。为此编写代码是微不足道的,但我很快就了解到,仅仅写入文本缓冲区并不足以让记事本重新绘制它的Edit控件。
幸运的是,Win32API支持这一点,并提供了InvalidateRect函数来强制控件重绘自身。
void UpdateText(HINSTANCE进程,HWND editWindow,char*notepadTextBuffer,char*replacementTextBuffer,int len){size_t write=0;WriteProcessMemory(process,notepadTextBuffer,replacementTextBuffer,len,&;Writed);recr;GetClientRect(editWindow,&;r);InvalidateRect(editWindow,&;r)。
工作内存扫描仪和功能齐全的记事本渲染器之间的差距小得令人惊讶。只有三个问题需要解决,才能从我到目前为止描述的内容转移到本文开始时取笑的光线跟踪器。
第一期本身并不是什么大问题。添加对MoveWindow的调用并不重要,但我将其包括在列表中,因为这是我如何处理列表中下一个问题的重要部分。
我最后硬编码了我想要的记事本窗口的大小,然后计算了准确填满那个大小的窗口需要多少个字符(等宽字体)。然后,在调用MoveWindow之后,我通过向记事本发送那么多WM_CHAR消息来预先分配屏幕上的文本缓冲区。这感觉像是作弊,但这是一种好的作弊。
为了确保我始终有唯一的字节模式可供搜索,我只是随机化了我在WM_CHAR消息中发送的字符。
我已经在代码中包含了这可能是什么样子。GitHub存储库中的实际代码的格式略有不同,但工作方式相同。
void PreallocateTextBuffer(DWORD ProcessId){HWND editWindow=GetWindowForProcessAndClassName(processId,";Edit";);//Consolas(大小为11)个字符MoveWindow(实例)填充一个1365x768窗口需要131*30个字符。topWindow,100,100,1365,768,true);size_t charCount=131*30;size_t utf16BufferSize=charCount*2;char*frame Buffer=(char*)malloc(Utf16BufferSize);for(int i=0;i<;charCount;i++){char v=0x41+(rand()%26);PostMessage(editWindow,WM_ChaerSize)。frame Buffer[i*2+1]=0x00;}睡眠(5000);//等待输入消息完成处理.速度很慢。//现在使用frame Buffer作为要搜索的唯一字节模式}
这对最终产品来说意味着,在我可以获取文本缓冲区指针并清除屏幕之前,我必须立即看到我的记事本窗口慢慢地充满了随机字符。
所有这些都依赖于使用已知的字体和字体大小才能正常工作。我本来打算添加一些代码来强制记事本使用我想要的字体(Consolas,11pt),但由于某种原因,发送WM_SETFONT消息不断扰乱字体的显示方式,我不想找出哪里出了问题。Consolas 11pt是我系统上的默认记事本字体,对我来说已经足够好了。
解释如何构建光线跟踪器远远超出了我在这篇文章中要讨论的范围。如果您一般不熟悉光线跟踪,请转到ScratchAPixel,学习一些光线跟踪以获得更好的效果。我想以快速讨论一下将光线跟踪器连接到我刚才所说的所有东西的具体细节来结束这篇文章。
从帧缓冲区开始可能是有意义的。为了最小化WriteProcessMemory调用的数量(出于理智和性能的考虑),我分配了一个与记事本的文本缓冲区大小相同的光线跟踪器本地缓冲区(字符数*2(因为UTF16))。所有呈现计算都将写入此本地缓冲区,直到帧结束,此时我使用单个WriteProcessMemory调用一次替换记事本缓冲区的全部内容。这就产生了一组非常简单的绘图函数:
void drawChar(int x,int y,char c);//本地缓冲区void clearScreen();//本地缓冲区void swapBuffersAndRedraw();//推送更改并刷新屏幕。
在光线跟踪方面,考虑到渲染目标的低分辨率(131x30),我必须保持非常简单,因为没有足够的“像素”来很好地显示精细细节。我最终只跟踪了一条主光线,每个渲染到的像素都有一条阴影光线,我想过要去掉阴影,直到我在Paul Bourke的网站上找到了一个很好的灰度浮点到ascii色带。拥有如此低复杂度的场景和很小的渲染表面也意味着我根本不需要并行化渲染。
我也遇到了一些问题,让事情看起来正确,因为字符比他们的宽度高。最后,我通过将宽高比计算中使用的宽度值减半来“修复”这个问题。
我还没有找到可行的解决方案的另一个问题是,如此频繁地更新记事本的编辑控件的内容会导致非常明显的闪烁。我尝试了很多不同的方法来消除这个问题,包括尝试通过分配两倍的字符数来加倍缓冲编辑控件,以及使用WM_VSCROLL消息通过调整滚动条位置来“交换”缓冲区。不幸的是,我的尝试都没有奏效,闪光依然存在。
我用记事本制作实时游戏的下一个(也是最后一个)部分是弄清楚如何处理用户输入。如果你已经走到这一步,并渴望得到更多,下一篇帖子可以在这里找到!
本网站上的帖子是我自己的,不一定代表我雇主的立场、战略或观点。