好友
阅读权限10
听众
最后登录1970-1-1
|
扫雷刚学逆向不久,拿扫雷练手写了个小工具。实现了实时显示棋盘数据、一键切换难度、自定义棋盘大小和雷数、一键排雷、计时器控制等功能。原理就是通过CE和OD找到关键内存地址,再用MFC写个界面,调用Read/WriteProcessMemory去读写。代码写得比较糙,大佬们轻喷。
一、开发环境项目 | 说明 | 操作系统 | Windows 7 x86(32位) | 工具 | Cheat Engine(CE)、 OllyDbg(OD)、Visual Studio 2015 | 目标软件 | Windows 经典版扫雷 |
二、实现功能
1.初级、中级、高级难度切换
2.棋盘数据实时监测
3.自定义棋盘高度和宽度
4.自定义雷数与旗帜数
5.一键排雷计时器重置与暂停
三、实现过程(一)实现初级、中级、高级的切换
1. 分析 WindowProc 函数参数所有 Windows 应用程序的窗口行为都由 WindowProc 窗口消息处理函数驱动LRESULT CALLBACK WindowProc( HWND hwnd, // 接收消息的窗口句柄,是窗口的唯一标识 UINT uMsg, // 消息类型(如 WM_COMMAND 表示命令消息) WPARAM wParam, // 消息附加参数1(如命令ID 或 虚拟键码) LPARAM lParam // 消息附加参数2(如鼠标坐标 x,y));常见消息类型举例:
消息 | 含义 | WM_COMMAND | 菜单命令、按钮点击 | WM_KEYDOWN / WM_KEYUP | 键盘按键按下 / 松开 | WM_LBUTTONDOWN / WM_LBUTTONUP | 鼠标左键按下 / 松开 | WM_MOUSEMOVE | 鼠标移动 | 切换难度属于命令操作,因此:uMsg → WM_COMMANDlParam → 0(不需要坐标信息)我们需要找到的目标参数是:窗口句柄和命令ID。
2. 使用 OD 定位命令ID打开 OllyDbg,通过 文件 → 打开 载入扫雷程序。按 F9 运行后,在 查看 → 窗口 中选中标题为“扫雷”的窗口,右键选择 跟随。
在 WindowProc 的汇编码中,关注以下指令:mov edx, [arg.2] ; 对应 ebp+0xC,即 uMsg(消息类型)mov edx, [arg.4] ; 对应 ebp+0x14,即 lParam
我们已知 uMsg 为 WM_COMMAND(对应十六进制 0x111),因此在 mov edx, [arg.2] 这一行右键设置条件断点,断点条件为 edx == 0x111。这样当扫雷收到任何命令消息时,程序会在此处暂停。
在扫雷窗口中点击 游戏 → 初级,程序中断。此时查看栈帧数据:$+8 → 窗口句柄(每次运行扫雷会变化)$+C → 消息类型 = 0x111(WM_COMMAND)$+10 → 命令ID = 0x209(初级)$+14 → lParam = 0按 F2 取消断点,F9 继续运行。用同样方法分别测得中级和高级对应的命令ID:
难度 | 命令ID | 初级 | 0x209 | 中级 | 0x20A | 高级 | 0x20B |
3.功能实现:通过代码切换难度打开VS创建一个MFC,然后按照下面照片配置即可。
然后再右侧的工具箱选择Button这个按钮。选择按钮,输入初级,然后双击进入控制件的函数
接下来再这个函数写入我们控制的代码
核心逻辑:用 FindWindow 获取扫雷窗口句柄,再用用 ::SendMessage 发送对应命令ID。SendMessage 函数原型:LRESULT SendMessage(HWND hWnd, UINT Msg, WPARAM wParam, LPARAM lParam);四个参数分别对应目标窗口的 WindowProc 的四个参数:hWnd:目标窗口句柄Msg:消息类型(此处为 WM_COMMAND)wParam:命令ID(0x209 / 0x20A / 0x20B)lParam:设为 0FindWindow 函数原型:第一个参数:窗口类名,不关心时传 NULL第二个参数:窗口标题,即 _T("扫雷")完整代码:HWND hwnd = ::FindWindow(NULL, _T("扫雷"));if (hwnd != NULL){ ::SendMessage(hwnd, WM_COMMAND, 0x209, 0); // 切换为初级}两个关键细节:① 作用域前缀 ::::SendMessage 前面的双冒号表示调用全局 Windows API 函数。如果省略 ::,MFC 会调用 CWnd::SendMessage,将消息发往当前对话框自身而非扫雷窗口。因此必须使用 ::SendMessage。 ② FindWindow 找不到窗口时返回 NULL若窗口句柄为 NULL,后续 ::SendMessage 会向无效句柄发送消息,导致不可预期的结果。因此需要先检查句柄有效性。弹窗提示用户:MessageBox 有两种调用方式:API 版本 ::MessageBox(NULL, _T("内容"), _T("标题"), MB_OK); 需指定父窗口句柄MFC 版本 MessageBox(_T("内容"), _T("标题"), MB_OK); 自动以当前对话框为父窗口,仅在对话框成员函数中可用MFC 版本更简洁,弹窗会依附在程序主窗口上,推荐使用。完整代码示例:
中级、高级的实现方式一致,仅需将命令ID替换为 0x20A 和 0x20B 即可。(二)扫雷实时监测要实现扫雷的实时监测,本质上是把整个棋盘数据从内存中读取出来。关键一步是找到棋盘第一个格子的首地址,然后通过偏移就能顺次读取后续所有格子。 第一步:打开CE并附加进程 打开 Cheat Engine,点击左上角闪烁的小电脑图标,在进程列表中选中扫雷程序,点击“打开”。
第二步:设置扫描参数 由于我们不知道格子的具体数值,扫描类型选择“未知的初始值”。数值类型方面,一个格子的状态无非就是空、数字1到8、未翻开、雷这几种,用一个字节(Byte)完全够用,选更大的类型反而浪费空间,所以数值类型选“字节”。 第三步:首次扫描 在扫雷棋盘左上角第一个格子上左键点一下(翻开它),记下显示的内容,比如是个“空”。回到CE,点击“首次扫描”,会得到海量地址(例如图中约2700多万个)。
第四步:重置后再次扫描(关键步骤) 点击笑脸重置棋盘,再次翻开左上角第一个格子,假设这次显示的是“2”。由于这个格子的值发生了变化,我们在CE中: 扫描类型改为“变动的数值”点击“再次扫描”这样就能把那些值发生变化的地址筛出来,数量会大幅减少。 重复这个过程:每次点笑脸重置→翻开左上角→观察数值变化→在CE中选择对应扫描类型(值变大了就选“增加的数值”,变小了选“减少的数值”,简单起见也可以统一用“变动的数值”)→再次扫描,逐步缩小范围,最终会筛到只剩两三个地址。
第五步:分析地址与内存数据 筛出来的地址中,绿色显示的属于基地址。基地址的特点是相对稳定,程序每次运行时不会因为系统环境不同而改变。 选中其中一个地址(比如 0x1005361),然后在右键选择“反汇编此内存区域”,输入这个地址后回车,就能看到该地址对应的实时数据。观察规律会发现:棋盘显示“空”时,内存值为十六进制 0x40显示“1”时,内存值为 0x41显示“2”时,内存值为 0x42显示“3”时,内存值为 0x43以此类推,数字就是 0x40 + 数字值其他特殊值: 大量 0x0f 表示该格未翻开(未知)0x8F 表示该格是雷雷被点爆炸时,对应位置的值会变成 0xCC当雷未被引爆但被标记时,值会从 0x8F 变为 0x8A。验证地址是否准确还有个简单方法:在CE中双击该地址的数值直接修改它,观察棋盘对应格子是否发生变化。如果变了,说明找对了;没变,那这个地址就不是我们要的。 掌握了这个方法,我们就可以用CE去查找游戏中其他数据的存储地址,比如棋盘的行数和列数、剩余雷数、旗帜数等。做法都一样:通过反复扫描变化值缩小范围,然后修改数值看游戏是否响应,响应了,就证明地址找对了。
以上是我分析得出的各数据对应的内存地址我们已经找到了棋盘数据的内存地址,接下来要通过代码实现读取。核心思路是:先获取扫雷进程的句柄,然后用 ReadProcessMemory 读取指定地址的数据。涉及的函数及调用顺序
整个过程依次用到四个函数:FindWindow → GetWindowThreadProcessId → OpenProcess → ReadProcessMemory → CloseHandle1. 获取窗口句柄:FindWindow这个前面已经用过,通过窗口标题找到扫雷窗口:HWND hwnd = ::FindWindow(NULL, _T("扫雷"));2. 获取进程ID:GetWindowThreadProcessId函数原型:DWORD GetWindowThreadProcessId(HWND hWnd, LPDWORD lpdwProcessId);第一个参数是窗口句柄,第二个参数是一个 DWORD 指针,用于接收进程ID。返回值是创建该窗口的线程ID,这里我们不需要,只需关注第二个参数。DWORD processId = 0;GetWindowThreadProcessId(hwnd, &processId);3. 打开进程获取句柄:OpenProcess函数原型:HANDLE OpenProcess(DWORD dwDesiredAccess, BOOL bInheritHandle, DWORD dwProcessId);第一个参数:想要的访问权限。读取内存至少需要 PROCESS_VM_READ,后续写入数据,需加上 PROCESS_VM_WRITE 和 PROCESS_VM_OPERATION。第二个参数:句柄是否可被子进程继承,一般设为 FALSE。第三个参数:目标进程的ID,即上一步获取的 dwProcessId。返回值:成功返回进程句柄,失败返回 NULL。HANDLE handle = ::OpenProcess(PROCESS_VM_READ, FALSE, processId);4. 读取内存:ReadProcessMemory函数原型:BOOL ReadProcessMemory(HANDLE hProcess, LPCVOID lpBaseAddress, LPVOID lpBuffer, SIZE_T nSize, SIZE_T *lpNumberOfBytesRead);各参数含义:hProcess 目标进程句柄lpBaseAddress 要读取的起始地址(即我们找到的棋盘首地址)lpBuffer 用于存放读取结果的缓冲区指针nSize 要读取的字节数lpNumberOfBytesRead 实际读取的字节数(可选,不需要可传 NULL)返回值:成功返回 TRUE,失败返回 FALSE。假设我们要读取棋盘首地址 0x01005361 处的一个字节:BYTE value = 0;::ReadProcessMemory(handle , (LPCVOID)0x01005361, &value, sizeof(value), NULL);1. 关闭进程:CloseHandle函数原型:BOOL CloseHandle( [in] HANDLE hObject );参数代表想要关闭的进程,返回值如果为非零则代表成功,反之则是失败。
目前读取内存的代码写在了按钮点击函数里,但仅仅读取到数据还不够,我们需要把结果显示到界面上的显示框中。这就需要将显示框与我们代码中的变量关联起来。在界面设计器中,选中显示框控件,右键选择“添加变量”。在添加变量的向导窗口中,类别这一栏有两个选项:Control 和 Value,它们的区别如下:Control:关联控件本身,用于操作控件的行为和状态,如设置只读、禁用按钮、直接设置显示文字。Value: 关联控件中存放的数据值,如读写控件中的数值或字符串
在给显示框添加变量时,我们选择了 Control 类别,而不是 Value 类别。原因在于:后续我们要实现每隔一秒自动刷新棋盘数据。Value 变量的更新方式是通过 UpdateData(FALSE) 把变量值写入控件,但 UpdateData 有个特点——它会一次性同步该对话框中所有 Value 变量,而不是只更新某一个控件。假如我们还有其他变量需要维护,比如棋盘宽度、高度的数值,用户可能正在修改这些值,结果被每秒一次的 UpdateData 覆盖了,这显然不合理。而 Control 变量是直接获取控件对象本身(比如 CEdit),更新时调用 m_editBoard.SetWindowText(str),只操作这一个控件,完全不影响其他变量。这就是我们选 Control 的根本原因。显示方案:拼接字符串一次性展示棋盘有 32×24 个格子,每个格子的值如果只用单个数字变量存放,每次循环都会覆盖前一个值,最终只能看到最后一个格子的数据。所以我们创建一个 CString 对象,把每个格子的内容依次拼接进去,循环结束后一次性显示到控件中。这就涉及一个问题,数字类型如何追加转换成字符串。而CString 自带了 APPendFormat 函数,用法和 C 语言的 printf 一样。把读取到的数值按指定格式写入字符串即可。棋盘尺寸通过 CE 观察,棋盘数据在内存中占用 宽32、高24 的空间。但后来发现最后两行全是 0x10(未翻开),实际上只使用了前 30 行,后面两行应该是为了内存对齐而预留的保护区域。所以循环范围是 32×24 = 768 次。循环与地址递增每次循环读取一个字节,然后读取的地址加一,即可依次读完整个棋盘。为了让显示更直观,需要加上行号和列号作为表头。整体代码为:DWORD processid=0; //进程Id DWORD baseaddress=0x01005361; //棋盘的首地址 BYTE value=0; CString readboarddata; HWND hwnd=::FindWindow(NULL,_T("扫雷")); if(hwnd==NULL) { MessageBox(_T("窗口查找失败,请确认扫雷是否打开!"),_T("提示"),MB_OK); return ; } GetWindowThreadProcessId(hwnd,&processid); HANDLE handle=OpenProcess(PROCESS_VM_READ,FALSE,processid); if(handle==0) { MessageBox(_T("打开进程失败!"),_T("提示"),MB_OK); return ; } for(int i=0;i<=32;i++)//打印列号 { readboarddata.AppendFormat(_T(" %02d"),i); } for(int i=1;i<=32*24;i++)//棋盘数据的读取 { if(!ReadProcessMemory(handle,(LPCVOID)baseaddress,&value,sizeof(value),0))//读取失败直接返回 { MessageBox(_T("读取内存失败!"),_T("提示"),MB_OK); CloseHandle(handle); return ; } if(i%32==1)//打印行号 { readboarddata.AppendFormat(_T("\r\n %02d"),i/32+1); } switch(value)//通过判断Value的值转换成相对应的字符 { case 0x40:readboarddata.AppendFormat(_T(" "));//表示空 break; case 0x41:readboarddata.AppendFormat(_T(" 1"));//表示1 break; case 0x42:readboarddata.AppendFormat(_T(" 2")); break; case 0x43:readboarddata.AppendFormat(_T(" 3")); break; case 0x44:readboarddata.AppendFormat(_T(" 4")); break; case 0x45:readboarddata.AppendFormat(_T(" 5")); break; case 0x46:readboarddata.AppendFormat(_T(" 6")); break; case 0x47:readboarddata.AppendFormat(_T(" 7")); break; case 0x48:readboarddata.AppendFormat(_T(" 8")); break; case 0x8f:readboarddata.AppendFormat(_T(" *"));//表示雷 break; case 0x0f:readboarddata.AppendFormat(_T(" ?"));//表示未知 break;case 0x8a:readboarddata.AppendFormat(_T(" 雷"));//显示地雷 break; case 0xcc:readboarddata.AppendFormat(_T(" 炸"));//地雷爆炸的位置 break; default :readboarddata.AppendFormat(_T(" %02x"),value);//如果以上数值都不是则直接转换成字符串 } baseaddress++; } m_boarddata.SetWindowText(readboarddata);//调用SetWindowText函数将字符串显示出去 //SetDlgItemTextW(IDC_EDIT1,readboarddata); CloseHandle(handle); 在对话框上新建一个按钮控件,命名为“读取数据”。双击该按钮,VS 会自动生成点击事件的处理函数。在函数中直接调用我们之前写好的内存读取与显示函数即可。至此,点击按钮就能获取棋盘数据并输出到显示框中。在这里需要对编辑框的一些属性进行调整,否则显示的棋盘只有一行,且显示不全。第一个参数是Multiline为TRUE,他代表的是是否为多行;第二个参数是Vertical Scroll为TRUE垂直滑即是一个垂直的滑轮,第三个是Horizontal Scroll为TRUE,代表水平的滑轮
功能虽然实现了,但观察输出结果会发现:十六进制字符宽窄不一,行列没有对齐,阅读体验很差。这是因为默认字体是宋体等比例字体,字母和数字的宽度不同。解决方法:在资源视图中打开主对话框,选中对话框空白区域(或直接选中显示框控件),在属性面板中找到 Font 属性,点击右侧的 … 按钮,字体选择 Courier New(等宽字体),确定后重新编译运行即可。此时所有字符占据相同宽度,行列自然对齐。
目前我们已经可以成功读取棋盘数据并显示,但每次都需要手动点击“读取数据”按钮。在实际排雷过程中,这样操作显然过于繁琐。最好的方式是实现每秒自动刷新,这就需要用到计时器。
Windows API 提供了 SetTimer 函数来创建定时器,其原型为:UINT_PTR SetTimer( HWND hWnd, // 1. 窗口句柄,接收定时器消息的窗口句柄 UINT_PTR nIDEvent, // 2. 定时器ID,定时器的编号,用于区分多个定时器 UINT uElapse, // 3. 触发间隔,单位为毫秒TIMERPROC lpTimerFunc // 4. 回调函数,如果传 NULL,定时器到期后系统会自动发送 WM_TIMER 消息,我们只需在 OnTimer 函数中编写处理逻辑即可。MFC 中的 CWnd 类对 API 版本进行了封装,省略了窗口句柄参数。因为对话框本身就是一个窗口,CWnd 的成员函数会自动将自身的句柄传进去,所以我们只需三个参数即可调用:UINT SetTimer( UINT nIDEvent, // 定时器ID UINT nElapse, // 时间间隔(毫秒) CALLBACK* lpfnTimer // 回调函数,一般传 NULL);在本项目中,我们选择定时器ID为 1,时间间隔为 1000 毫秒(即 1 秒),回调参数设为 NULL,让系统通过消息机制自动触发 OnTimer。 将 SetTimer 放在 OnInitDialog 中,主要是因为OnInitDialog 是对话框的初始化函数,在窗口即将显示时执行,这里是启动定时器的最佳时机。启动定时器后,还需要重写 OnTimer 函数来处理定时消息。操作步骤如下:在类视图中选中 C扫雷Dlg,右键选择类向导---->上方选项卡切换到消息---->在左侧消息列表中找到 WM_TIMER,双击或点击右侧的添加处理程序---->点击编辑代码,VS 会自动生成 OnTimer 函数的声明、消息映射和函数体。在生成的函数中,先判断传入的定时器ID是否为 1,如果是,则调用我们之前写好的读取并显示棋盘数据的函数:void CsaoleiDlg::OnTimer(UINT_PTR nIDEvent){ if (nIDEvent == 1) { // 调用读取棋盘数据的函数 OnEnChangEdit1(); } CDialogEx::OnTimer(nIDEvent);}实现每秒自动刷新后,会发现一个严重的问题:当扫雷游戏没有打开时,程序会每秒弹出一个“窗口查找失败”的错误框。 因为定时器每隔一秒就触发一次读取函数,只要扫雷没打开,FindWindow 就返回 NULL,错误弹框就会源源不断地弹出来。只要用户不点确定或者不打开扫雷,弹框就会堆满屏幕。解决方案:设置一个标志位我们在头文件中声明一个成员变量 flag,用来记录“是否已经弹出过警告了”。类型为 BYTE,在构造函数中初始化为为0。核心逻辑如下:第一步:判断是否需要弹窗当 FindWindow 返回 NULL 时,先检查标志位。只有在标志位为 0(即还没弹过警告)时才弹窗。弹窗之前,必须先把标志位置为 1。这里有一个极其重要的顺序问题:必须先置标志位,再弹窗。原因在于:MessageBox 是一个阻塞函数,弹框后程序会停在那行代码上,等待用户点击“确定”。如果顺序反过来——先弹框后置位,在用户还没点确定的这一秒内,下一轮定时器又触发了,此时标志位仍然是 FALSE,于是第二个弹框又弹出来了。如此反复,每隔一秒弹一个。先置位再弹窗,就能保证弹框的同一瞬间其他进来的检查直接跳过,不会重复弹出。 同时,注意那个 return。 一旦标志位为1,说明到目前为止扫雷始终没有打开,窗口句柄一直是无效的。如果继续往下走,把无效的句柄传给 GetWindowThreadProcessId,得到的进程ID也必然是错的,OpenProcess 和 ReadProcessMemory 也必然全部失败,还会触发它们各自的错误弹框。所以在判断进程和内存读取是否成功时,也应该判断标志位是否为一,如果为1则说明一直获取的是无效窗口句柄,则不必弹出相对于提示窗口。第二步:恢复正常后复位标志位当用户打开了扫雷,FindWindow 成功获取到窗口句柄(非 NULL)时,说明一切恢复正常。此时需要把标志位重置为0,这样如果用户之后又关闭了扫雷,下一次检测才能正确弹出警告。
(三)实现棋盘宽度和高度的修改通过前面的学习,我们已经掌握了使用 CE 查找内存地址的方法。经过查找,最终确定存放棋盘宽度和高度的内存地址分别为 0x010056A8 和 0x010056AC。只要修改这两个地址中的数据,就可以改变棋盘的大小。 第一步:添加界面控件在资源视图中打开主对话框,从右侧工具箱中拖入两个编辑框控件(Edit Control),分别用于输入高度和宽度值。为了让用户知道每个编辑框的用途,再拖入两个静态文本控件(Static Text),分别放在两个编辑框旁边,文本内容设置为“高:”和“宽:”。 第二步:为编辑框绑定变量选中用于输入高度的编辑框,右键选择“添加变量”。在向导中: 类别选择 Value(数值类型)变量类型选择 BYTE(字节),因为棋盘最大不过 30 行左右,一个字节完全够用变量名设为 m_height用同样的方法为宽度的编辑框绑定变量 m_width。 第三步:设置初始值在 OnInitDialog 函数中,可以为这两个变量设置初始值(比如都设为 9,对应初级难度),然后调用 UpdateData(FALSE) 将初始值显示到控件上:m_height = 9;m_width = 9;UpdateData(FALSE);第四步:实现修改功能,在函数中实现以下流程: 1. 获取用户输入调用 UpdateData(TRUE),将控件中的数值同步到 m_height 和 m_width 变量中。2. 写入目标进程内存找到扫雷窗口 → 获取进程ID → 打开进程 → 写入内存。前几步和读取棋盘数据时完全一样,区别在于最后一步:之前用的是 ReadProcessMemory(读取),现在要用 WriteProcessMemory(写入)。这两个函数的参数形式几乎相同:BOOL WriteProcessMemory( HANDLE hProcess, LPVOID lpBaseAddress, // 目标地址 LPCVOID lpBuffer, // 要写入的数据 SIZE_T nSize, // 写入大小 SIZE_T *lpNumberOfBytesWritten);与 ReadProcessMemory 相比,仅仅是第三个参数的含义从“存放读取结果的缓冲区”变成了“要写入的数据”,其余参数完全一致。3. 权限配置在打开进程时,将之前的PROCESS_VM_READ,改为PROCESS_VM_WRITE和PROCESS_VM_OPERATION:PROCESS_VM_WRITE:允许调用 WriteProcessMemory 写入内存PROCESS_VM_OPERATION:允许修改内存保护属性,有时写入前需要此权限
这里有一个重要的细节:修改后需要刷新直接修改内存中的数值后,扫雷并不会立即改变棋盘大小。需要点击一次笑脸按钮(相当于重新开局),扫雷才会按照新的大小重新生成棋盘。为了让用户修改后立即看到效果,可以在写入完成后模拟一次开局操作。通过之前学过的 SendMessage,向扫雷窗口发送开局命令(命令可通过 OD 找到):::SendMessage(hwnd, WM_COMMAND, 0x1fe, 0);这样,数值一修改就会立即生效,无需用户手动点击笑脸。
在后续使用中发现,修改棋盘尺寸的输入框存在一个严重问题,下面分别说明原因和解决方法。问题:输入过程中数值被提前读取导致崩溃当把棋盘高度从 9 改成 15 时,正常的操作是先删除 9,然后依次输入 1 和 5。但问题在于:刚输入 1、还没输入 5 的时候,程序就已经把这个 1 读走了。棋盘尺寸有最小值限制(通常是 9),1 远远低于最小值,导致扫雷崩溃。原因: 编辑框控件设置了 EN_CHANGE 事件关联。EN_CHANGE 会在控件内容发生任何变化时立即触发,也就是说,只要用户敲了一个字符、删了一个字符,对应的事件函数就会被调用。事件函数里执行了 UpdateData(TRUE) 获取数值并写入内存,于是不完整的数值就被写进去了,造成崩溃。解决方法: 取消 EN_CHANGE 事件的关联。 不再让“内容变化”自动触发修改,而是改为“用户按下回车键”才执行写入操作。这样可以确保写入的是用户最终确认完成的完整数值。
具体操作:在对话框设计器中选中编辑框控件。右键选择“添加事件处理程序”,找到 EN_CHANGE 事件,将已有的关联删除(或在属性面板的消息列表中将 EN_CHANGE 清空)。配合问题的解决方案,在 PreTranslateMessage 中拦截回车键,回车时调用修改函数。前面提到,在编辑框中按下回车键会触发对话框默认的 OnOK 命令,导致程序意外关闭。我们需要重写 PreTranslateMessage 函数,在消息到达默认处理之前把回车键截住,改为执行我们自定义的修改宽高逻辑。 第一步:在头文件中添加声明打开 扫雷Dlg.h,在类 C扫雷Dlg 的内部添加虚函数声明:virtual BOOL PreTranslateMessage(MSG* pMsg);第二步:在源文件中实现函数打开 扫雷Dlg.cpp,添加函数体。整体逻辑如下:判断消息类型是否为 按键按下(WM_KEYDOWN),并且按下的键是否是 回车(VK_RETURN)。如果是回车,则调用 GetFocus() 获取当前拥有焦点的控件指针。检查该指针是否有效(非空),然后通过 GetDlgCtrlID() 获取控件的资源 ID。如果该 ID 等于高度编辑框(IDC_EDIT_HEIGHT)或宽度编辑框(IDC_EDIT_WIDTH)的 ID,则调用统一修改宽高的函数。处理完回车键后,必须 return TRUE,告诉 MFC 消息已被处理,不再往下传递,从而阻止默认的 OnOK 执行。对于其他所有消息(包括其他按键),调用基类的 PreTranslateMessage 继续默认处理。示例代码:
几个关键点说明return TRUE 的作用:告诉框架“我已经处理了这条消息”,消息循环不会再把回车键交给底层的 OnOK 处理,窗口自然不会被关闭。GetFocus 返回的是 CWnd*,而非 HWND。通过该指针可以直接调用 GetDlgCtrlID() 获取控件 ID,无需额外转换。GetDlgCtrlID() 返回的是控件在属性面板中设置的那个 ID,即 IDC_EDIT_HEIGHT 这样的标识符。为什么先判断 ID:因为回车键可能在任何控件上按下(比如按钮),只有焦点在宽高编辑框时我们才拦截并执行修改,否则按默认行为处理。 (四)控制扫雷的数量,旗帜的数量修改雷数的实现方式与修改宽高基本一致,步骤完全可复用,仅有少数细节差异。第一步:添加界面控件在对话框上拖入一个新的编辑框和一个静态文本,静态文本作为标签(如“雷数:”),编辑框用于用户输入。第二步:绑定变量选中编辑框,右键选择“添加变量”:类别选 Value,变量类型选 UINT(无符号整数,4字节),因为雷数的最大值可达 435,一个字节(最大 255)不够用变量名设为 m_mine第三步:设置初始值在 OnInitDialog 中给变量赋初始值(如 10,即最小雷数)m_mine= 10;第四步:删除 EN_CHANGE 事件关联与宽高编辑框一样,如果编辑框绑定了 EN_CHANGE 事件,会导致输入过程中数值被提前读取。在属性面板中找到该编辑框的 EN_CHANGE 消息,删除关联。第五步:编写写入函数在头文件 扫雷Dlg.h 中声明自定义函数:在源文件 扫雷Dlg.cpp 中实现。逻辑与修改宽高的函数完全相同:调用 UpdateData(TRUE) 获取编辑框中的数值到 m_mine。通过 FindWindow 找到扫雷窗口,获取进程ID,调用 OpenProcess 打开进程。使用 WriteProcessMemory 将新雷数写入对应的内存地址。发送开局命令刷新棋盘。第六步:在 PreTranslateMessage 中增加判断在 PreTranslateMessage 中,将雷数编辑框的 ID加入判断条件,使其也能响应回车键。经过实测,扫雷的雷数存在明确的上限和下限:最小雷数:10最大雷数:435(而非 30×24 = 720,这是实测结果)其旗帜数和雷数的修改是完全一样的这里就不再过多陈述了。(五)一键排雷功能通过前面的学习,我们已经能够读取棋盘上每个格子的完整数据。每个格子的值代表了它的状态——是雷(0x8F)、是数字、是空格(0x40)、还是边界(0x10)。一键排雷的核心原理很简单:遍历棋盘的每一个格子,如果它不是雷也不是边界,就模拟鼠标左键点击它,把它翻开。当所有非雷格子都被翻开后,扫雷会自动为剩余的格子插上旗子,游戏即告胜利。因此,问题的关键从“读取内存”转变为如何通过代码模拟鼠标点击。坐标定位:找到每个格子的位置要模拟鼠标点击,必须先知道每个格子在屏幕上的坐标。我们需要获取两个数据: 起始坐标:棋盘左上角第一个格子的中心位置格子间距:相邻两个格子中心之间的像素距离使用 Visual Studio 自带的 Spy++ 工具可以精确获取这些数据:在 VS 菜单栏中选择 工具 → Spy++。在 Spy++ 中找到扫雷窗口,右键选择 消息,打开消息监视窗口。
将鼠标悬停在扫雷棋盘左上角第一个格子的正中心(此时消息窗口会不断刷新鼠标移动消息),然后按下鼠标左键。消息窗口会立即出现 WM_LBUTTONDOWN 消息,记录下此时消息列表中的位置。
右键选择 停止,在停止的位置附近找到 WM_LBUTTONDOWN,展开后即可看到该消息附带的坐标参数,这就是第一个格子的中心坐标。用同样的方法,将鼠标向右移动一格并点击,可算出横向间距;向下移动一格并点击,可算出纵向间距。多测几组数据取平均值,结果更准确。经过实测,得到的参考数据为:起始坐标:X = 20,Y = 60格子间距:横向和纵向均为 16 像素模拟鼠标点击:SendMessage有了坐标,就可以通过 SendMessage 函数向扫雷窗口发送鼠标点击消息。要完整模拟一次点击,需要发送两条消息:鼠标按下:WM_LBUTTONDOWN鼠标抬起:WM_LBUTTONUP原因在于,Windows 中一个完整的点击动作由“按下”和“抬起”两个事件组成。如果只发按下不发抬起,扫雷会认为鼠标一直处于按住状态,不会触发翻开操作。发送消息时,坐标需要封装为 LPARAM 类型,使用 MAKELPARAM(x, y) 宏即可完成转换。完整调用示例:// 鼠标按下::SendMessage(hWnd, WM_LBUTTONDOWN, MK_LBUTTON, MAKELPARAM(x, y));// 鼠标抬起(wParam 为 0,表示按键已释放)::SendMessage(hWnd, WM_LBUTTONUP, 0, MAKELPARAM(x, y));循环逻辑与坐标计算:遍历棋盘时,设循环变量 i 从 0 开始(或从 1 开始并调整公式),每读取一个格子的值,先进行判断:如果值为 0x8F(雷),跳过,不点击如果值为 0x10(边界),跳过,不点击其他值(空格、数字),执行点击点击时需要将内存索引 i 转换为屏幕坐标。转换公式如下:// 列号(0 ~ 31)int col = i % 32;// 行号(0 ~ 23)int row = i / 32;// 实际像素坐标int x = 20 + col * 16;int y = 60 + row * 16;
(六)重新记时、和时间暂停除了修改棋盘数据,我们还可以操控扫雷自带的计时器。主要实现两个功能:重置计时器(清零)和暂停计时器(冻结时间)。一、重置计时器:写入零值这个功能非常简单。扫雷的计时器每秒钟数值加一,我们只需要用 CE 找到存放时间的地址,然后通过 WriteProcessMemory 写入 0 即可。操作流程与之前修改宽高、雷数完全一致——找到地址后,在按钮点击事件中执行写入操作即可。因为逻辑已经重复多次,此处不再赘述。二、暂停计时器:找到控制时间流动的标志位让计时器停下来,比单纯清零要复杂一些。核心不在于写入动作本身,而在于定位那个"控制时间是否流动"的关键地址。通过反复操作游戏,可以发现计时器的行为规律:游戏开始前:时间不走游戏进行中:时间每秒递增游戏结束(无论输赢):时间停止这说明游戏中必然存在一个状态标志,它决定了计时器在当前时刻"该不该走"。我们的目标是找到这个标志位。两种可能性分析在 CE 搜索之前,可以先做逻辑推理。这个标志位可能存在两种设计模式:模式 设计思路 假设A:两态模式 只用两个值区分"走"和"停" 比如 0 = 停止,1 = 运行B:多态模式 用不同值对应游戏的不同阶段 比如 0 = 未开始,1 = 运行中,2 = 胜利,3 = 失败到底采用哪种模式,只能通过实际测试来验证。CE 实测定位方法很简单:反复在"时间在走"和"时间停止"两种状态下切换,用 CE 反复扫描变化的数值:游戏未开始时,搜索当前值点击开局,时间开始走,立即搜索变了的数值故意踩雷结束游戏,时间停止,再次搜索变化的数值重复以上步骤,逐步缩小范围最终筛选出的地址,就是控制时间流动的状态标志位。实测结论经过反复测试,得出结论:这个标志位采用两态模式。0 = 停止(未开始、游戏结束)1 = 运行中(时间每秒递增)它只负责表示"当前是否处于游戏进行中",不需要区分是胜利还是失败、是未开始还是已结束。找到这个地址后,暂停计时器就变得极其简单:将这个标志位写入 0 即可。计时器会立刻冻结,显示的时间值不再变化。写入 1 则恢复走动。 |
免费评分
-
查看全部评分
|