用C#制作游戏修改器的最佳做法

前言

根据我的经验,C#算是windows下最适合写修改器的工具了,Winform可以很轻易做出自己想要的UI,.NET平台下的各种组件可以大大减少很多必要的操作,而即使组件中不提供的功能,也可以通过平台调用很容易的实现。

没错,我就是来安利C#和Winform的。

说明

本文不是通用型的教程,而是对于用C#制作修改器可能遇见的问题的讨论与总结,阅读本文没法让你写出一个修改器,但是能够让你的C#修改器更加“好用”,或是写一个泛用型的游戏修改类库。

文章假设读者了解游戏修改的基础知识和原理,同时对于C#有一定的了解:如果您曾用CE或OD等工具修改过任何游戏,用任何语言写过一个修改器,用C#写过一些程序,那么一定可以非常轻松地阅读本文。否则,稍微查找一下相关资料,也可以很顺畅地阅读下去。

另外,本文的大部分内容都依赖于Windows Vista或更高版本的系统,并以Winform为例。


正文

1.管理员权限

要改游戏肯定是需要管理员权限的,也就是说,需要修改器以管理员权限运行。要做到这一点,最简单的做法是编辑 app.manifest 文件,修改其中的用户帐户控制级别即可:

<!-- 默认的值 -->
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
<!-- 修改后的值 -->
<requestedExecutionLevel  level="requireAdministrator" uiAccess="false" /> 

这样一来,在运行程序后,UAC会通知需要使用管理员权限。当然,UAC是可以关闭的,所以如果有必要——尽管笔者认为不用这么干——还可以在程序运行后判断自身是否具备管理员权限:

using System.Security.Principal;
using System.Threading;
var principal = new WindowsPrincipal(WindowsIdentity.GetCurrent());
if (!principal.IsInRole(WindowsBuiltInRole.Administrator))
   //todo:非管理员权限处理

在此基础上,既可以简单地警告使用者现在修改器用不成,也可以以管理员权限重新运行自身:

using System.Diagnostics;
Process.Start(new ProcessStartInfo
  FileName = Application.ExecutablePath,
  Verb = "runas" //管理员权限
//todo: 退出自身

2.只运行一个进程实例

对于修改器而言,只运行一个进程实例不是必须的,但在某些时候非常重要,比较常见的情况是当你的修改器有代码注入或数值锁定功能,这个时候如果有多个进程在运行,那么粗心的使用者很可能会面对“失效”的功能或错乱的效果而一脸懵逼。

基于这种考虑,可以尝试在main函数下使用 system.threading.mutex 使得程序只能运行一个实例:

using System.Threading;
using (var mutex = new Mutex(true, "互斥提名,通常使用GUID即可", out var createNew))
  if (createNew) //初次运行
    Application.EnableVisualStyles();
    Application.SetCompatibleTextRenderingDefault(false);
    Application.Run(new FormMain());
    Environment.Exit(0);
}

3.找出目标进程

Process类 提供了对其他进程的控制与访问,通过它可以很容易获取游戏的目标进程。

通常情况下,修改器通过进程名来获取目标进程,在C++中,要做到这一点需要通过遍历来查找,但在C#中,通过 Process.GetProcessesByName 方法可以很容易做到。当然,这个方法返回的是一个 Process 实例的数组,我们应当考虑到它很可能不会得到唯一的结果(即数组长度=1),所以最好的做法是对其进行判断:

using System.Diagnostics;
foreach (var proc in Process.GetProcessesByName("进程名,不包括.exe后缀"))
  if(condition)
     //todo:是目标进程
    根据MSDN上Process类的描述,一个Process实例必须释放,因为它占据着珍贵的句柄资源。
    而且即对应标进程已退出,也应当调用Dispose方法释放句柄。
    对于游戏进程的Process实例而言,我们在使用完毕或其退出后,也应该进行适当处理。
    proc.Dispose();

Process类 中有大把的进程信息帮助我们判断该进程是否是目标进程,比如 Process.MainWindowTitle 属性、 Process.MainModule 属性中的 ProcessModule.FileVersionInfo 属性等等。

当然也有其他判断方法,比如计算CRC32,或者通过游戏的“特征数据”进行判断等等,这里不再赘述。

另一种修改器常用的获取目标进程的方法是通过目标窗口信息来获取, Process类 不提供类似功能,我们必须使用Win32 API:先用 FindWindow 函数获取指定窗口句柄,再通过 GetWindowThreadProcessId 函数通过窗口句柄获取目标进程的PID(关于平台调用的问题将在下文“附录1:平台调用技巧”一节中讨论)。获取PID之后,就可以通过 Process.GetProcessById 方法打开目标进程了,这样做虽然稍微麻烦一些,但是从某个角度讲,可以保证获取目标进程的“准确度”。

4.找到目标模块的基址

Process类 中的 Process.MainModule 属性 Process.Modules 属性 中(即 ProcessModule 类 )其实已经提供了这个数据,但某种特殊的情况下,这两个数据可能将无法得到正确的结果:当修改器是X64位,而目标进程是X86时。

对于修改器而言,通常情况下是对游戏适配的,也就是说,游戏是X86,修改器也是X86,游戏是X64,修改器也是X64,大多数情况下,只要这样做就足够了。然而其实还存在着另一种情况:某些游戏既有X86版本也有X64版本。

我们当然可以针对两种情况写两个版本的修改器,但在平台调用时,需要手动调整API,这其实有些麻烦,也不利于有关类库的封装,而且用户体验也并不很好——对于C#而言,这些麻烦事其实有更好的解决方法。

首先,设置修改器的平台为Any CPU,并关闭“首选32位平台选项”,同时对Win32 API的平台调用进行适当处理(见后文)。

接着使用 Environment.Is64BitOperatingSystem 属性 判断系统是否为X64平台,如果是的话,再通过 IsWow64Process 函数判断目标程序是否为X64进程(X64进程则结果为false),如果目标不是X64进程,则执行以下步骤:

乍看上去这种做法似乎有些麻烦,但一旦你把它们封装成一个类库,那么可以很容易地适用于任何情况。

有一点需要注意的是,根据 .NET Framework 4 的迁移问题 中的说明, 无论哪种方法都无法获取到CLR4及以上程序集中的托管dll(模块),这种情况下没有什么很好的解决方案,最常见的做法是注入一个C/CLR的dll,然后通过反射来获取。当然99%的时候我们不会遇到这种问题,所以不做讨论。

5.后续工作以及总结

到目前为止,正式修改游戏的前置工作已经都做好了,剩下的工作只是调用各种Win32 API进行修改而已,但.NET给我们的帮助远不止如此。

比如很多时候,修改一个游戏真正的第一步是通过 OpenProcess 函数打开目标进程,但在C#中并不需要,因为 Process.Handle 属性 已经提供了最大权限的操作句柄。

又比如修改器是在一个线程或非线程Timer中循环判断目标进程是否在运行的,那么在捕获游戏进程后,可以绑定目标进程退出事件,在目标进程退出后进行处理。

还有提供字节数组到各种类型的数据以及不同编码的字符串之间的转换等等。

至于shellcode注入、Aobscan、导入表获取之类的问题,不在本文讨论范围之内。


附录1:平台调用技巧

其实在MSDN中的 平台调用详解 里面已经很详尽地说明了这个问题,而在 用平台调用封送数据 和其他文章中也说明了不同数据的封送方式,所以在这里,只简单总结一些相关技巧和要点。

A.使用 pinvoke.net 网站 和 jaredpar/pinvoke 项目 来获取目标API的定义方式。

比如对于 ReadProcessMemory 函数 而言,在Pinvoke.net网站中,我们可以查到它的定义方式: ReadProcessMemory (user32)

而通过 jaredpar/pinvoke 项目,也可以很简单的查到它的定义方式,如 图1-1

图1-1

B.细节处理与泛用设计

ReadProcessMemory 函数为例,我们注意到上面两个“渠道”给出的C#声明是不同的:

[DllImport("kernel32.dll", SetLastError=true)]
static extern Int32 ReadProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress,[In, Out] byte[] buffer, UInt32 size, out IntPtr lpNumberOfBytesRead);
[DllImport("kernel32.dll", EntryPoint = "ReadProcessMemory")]
[return:MarshalAs(UnmanagedType.Bool)]
public static extern bool ReadProcessMemory(IntPtr hProcess,IntPtr lpBaseAddress, IntPtr lpBuffer, int nSize, ref int lpNumberOfBytesRead);

哪种更好呢?如果你对比过函数原型,就会发现其实哪种方式都有缺点。对于第一种方式而言,它的 size参数(对应函数原型的nSize参数)是UInt32,但是其实它的原型是size_t。

而第二种方式,首先它没有设置SetLastError属性,一旦函数调用失败,我们不知道其原因;另一个问题在于它把lpBuffer参数类型定义为IntPtr,我们固然可以通过 Marshal.AllocHGlobal 方法 (Int32) 为其分配空间,但后续的访问、转换和销毁其实都比较麻烦,尤其是对于Single或者Double等 Marshal 类 中并未提供读取方法的基础型等;最后它同样没有考虑到size_t的问题。

当然,对于修改器而言,可能无需考虑太多情况,但如果有必要,我会选择这样的定义方式:

[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool ReadProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, Byte[] lpBuffer, IntPtr nSize, IntPtr lpNumberOfBytesRead);
public static Byte[] ReadMemory(IntPtr hProcess, IntPtr lpBaseAddress, Int32 length)
    var buffer = new Byte[length];
    if (!ReadProcessMemory(hProcess, lpBaseAddress, buffer, new IntPtr(length), IntPtr.Zero))
        throw new Win32Exception(Marshal.GetLastWin32Error());
    return buffer;
public static Int32 ReadInt32(IntPtr hProcess, IntPtr lpBaseAddress)
    return BitConverter.ToInt32(ReadMemory(hProcess, lpBaseAddress, 4), 0);

它的好处在于,无论我将修改器编译为什么平台,数据都会以正确的方式封送。当然很多时候不一定需要检测函数调用错误,SetLastError属性可以省略。

另一种常见的情况是,我们除了要读取基础类型之外,还可能要读取某种结构。这种情况下,一种做法是把ReadProcessMemory的lpBuffer类型声明为IntPtr进行处理:

[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool ReadProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, IntPtr lpBuffer, IntPtr nSize, IntPtr lpNumberOfBytesRead);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool ReadProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, Byte[] lpBuffer, IntPtr nSize, IntPtr lpNumberOfBytesRead);
public static Byte[] ReadMemory(IntPtr hProcess, IntPtr lpBaseAddress, Int32 length)
    var buffer = new Byte[length];
    if (!ReadProcessMemory(hProcess, lpBaseAddress, buffer, new IntPtr(length), IntPtr.Zero))
        throw new Win32Exception(Marshal.GetLastWin32Error());
    return buffer;
public static Int32 ReadInt32(IntPtr hProcess, IntPtr lpBaseAddress)
    return BitConverter.ToInt32(ReadMemory(hProcess, lpBaseAddress, 4), 0);
public static T ReadMemory<T>(IntPtr hProcess, IntPtr lpBaseAddress) where T : new()
    var result = new T();
    var length = Marshal.SizeOf(result);
    var buffer = Marshal.AllocHGlobal(length);
    if (!ReadProcessMemory(hProcess, lpBaseAddress, buffer, new IntPtr(length), IntPtr.Zero))
        throw new Win32Exception(Marshal.GetLastWin32Error());
    Marshal.PtrToStructure(buffer, result);
    Marshal.FreeHGlobal(buffer);
    return result;

没错,平台调用是可以重载的……

不过这种方式我很少用,如果需要访问自定义结构,我更倾向于使用接口:

[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool ReadProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, IFormattedStruct lpBuffer, IntPtr nSize, IntPtr lpNumberOfBytesRead);
public interface IFormattedStruct
    IntPtr Size { get; }
public static T ReadMemory<T>(IntPtr hProcess, IntPtr lpBaseAddress) where T : IFormattedStruct, new()
    var buffer = new T();
    if (!ReadProcessMemory(hProcess, lpBaseAddress, buffer, buffer.Size, IntPtr.Zero))
        throw new Win32Exception(Marshal.GetLastWin32Error());
    return buffer;

更简单了不是吗,只要你自定义的类型继承了IFormattedStruct接口就可以。当然,如果这部分代码是作为一个类库发布的,为了安全起见,可以在代码中对实现了IFormattedStruct接口的类型进行判断,判断其是否是“格式化”的:

Debug.Assert(typeof(T).IsLayoutSequential || typeof(T).IsExplicitLayout, "Type must have LayoutKind.Explicit attribute or LayoutKind.Sequential attribute.");

总之,非托管API的调用和数据封送其实非常灵活,如何选择还应根据实际情况进行考量。

C. 句柄处理

依然以 ReadProcessMemory 函数 为例,它的第一个参数是 HANDLE hProcess ,通常情况下我们可以把Handle类型表示为 IntPtr 结构 (System) ,但这个句柄我们通过 Process.Handle 属性 即可获取,而GC是不可控的,所以更好的方法是使用 HandleRef 结构 进行封装。

[DllImport("kernel32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern Boolean ReadProcessMemory(HandleRef hProcess, IntPtr lpBaseAddress, Byte[] lpBuffer, IntPtr nSize, IntPtr lpNumberOfBytesRead);
public static Byte[] ReadMemory(HandleRef hProcess, IntPtr lpBaseAddress, Int32 length)
    var buffer = new Byte[length];
    ReadProcessMemory(hProcess, lpBaseAddress, buffer, new IntPtr(length), IntPtr.Zero);
    return buffer;

实际调用时,也很简单,例如目标进程为process,则:

var result=ReadMemory(new HandleRef(process,process.Handle),0x400000,4);

HandleRef 结构 类似,.NET中还有许多特定的句柄类型,在调用有关API的时候,不妨使用之,比如 SafeWaitHandle 类 SafeHandle 类 (System.Runtime.InteropServices) 等等,这里不再赘述。

句柄在windows中非常重要,调用非托管API时对特定类型的句柄进行包装是必须注意的事情。


附录2:内存读写与代码注入的优化

A.内存读写优化

在大多数时候,正常读写游戏内存是没问题的,然而有的时候我们会发现一个问题:CE脚本执行成功,但写成修改器游戏就崩溃了,这是因为内存保护属性的问题。

如果只是写一个一般的修改器,那么可以参考CE的内存区域属性,在读写关键位置时用 VirtualProtectEx 函数修改其保护属性,再进行读写。

如果是一个泛用型的修改器类库,那么建议增加一组方法,例如:

//伪代码
public static Int32 ReadInt32Safe(IntPtr hProcess, IntPtr lpBaseAddress)
    ChangeProtect();
    return BitConverter.ToInt32(ReadMemory(hProcess, lpBaseAddress, 4), 0);
    RestoreProtect();

一些特殊的情况下我们可能需要调试器功能进行处理,相关内容在下文讨论。

B.代码注入优化

通常我们没必要对于注入代码的操作进行优化,但考虑某种特殊的情况:用于做跳转的位置是一个公共代码段,游戏从UI更新到数据判定都使用这部分代码。

这很恶心,虽然99%的时候我们直接注入代码是没问题的,但总有那么1%的概率会发生这种场景:当我们在启用/取消代码的时候,游戏崩溃了……

当然大多数时候我们没必要对此进行处理,因为出错的概率很小,但如果你愿意,确实有方法可以保证游戏不会出错:先让它暂停下来,再修改跳转代码。

对于非多线程的游戏而言,暂停起来比较简单,用 CreateToolhelp32Snapshot 获取进程信息遍历后用 SuspendThread 函数和 ResumeThread 函数挂起唤醒线程即可。

但对于多线程游戏而言,更好的方法是通过调试器API实现,也就是利用 DebugActiveProcess WaitForDebugEvent 附加到进程,监听调式事件,然后设置INT3断点(0xcc),然后修改代码,然后取消断点,然后用 DebugActiveProcessStop 函数关闭调试器。


本章内容只讨论思路,不涉及如何实施,因为这不再本文讨论范围之内,不再赘述,看雪论坛上有很多资料。


附录3:一个支持多版本的内存修改器示例

本章演示了如何制作一个完整的、自动检测游戏进程、支持多目标版本的游戏修改器。

修改目标是 Cheat Engine 7.1附带的教程程序(进程名:Tutorial-x86_64.exe),启用修改器后,教程的step 2的 Next按钮 将直接可用。

修改器通过Visual Studio 2017制作。

编译平台为 .NET Framework 4.5.2,X64位。

A. 创建项目和准备工作

管理员身份 运行VS2017,并创建一个 .NET4.5.2 Winform 项目,项目名和解决方案名均为 CETT ,如图2-1.

图2-1

配置管理器 中新建 X64 平台,因为我们的目标版本就是X64位的,无需使用Any CPU。

图2-2

添加新项 中选择 应用程序清单文件 ,就使用默认的文件名 app.manifest ,如图2-3。

图2-3

修改 app.manifest 文件,将 <requestedExecutionLevel level="asInvoker" uiAccess="false" /> 修改为 <requestedExecutionLevel level="requireAdministrator" uiAccess="false" />

<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
  <requestedExecutionLevel  level="asInvoker" uiAccess="false" />
  <requestedExecutionLevel  level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>

修改 Program.cs 文件,将 Main函数 部分修改为以下内容:

       /// <summary>
        /// 应用程序的主入口点。
        /// </summary>
        [STAThread]
        static void Main()
            var principal = new WindowsPrincipal(WindowsIdentity.GetCurrent());
            if (principal.IsInRole(WindowsBuiltInRole.Administrator))
                using (var mutex = new Mutex(true, "4e40a3ee-1fa6-4ab3-9cd1-bf0733d920ae", out var createNew))
                    if (createNew)
                        Application.EnableVisualStyles();
                        Application.SetCompatibleTextRenderingDefault(false);
                        Application.Run(new Form1());
                        Environment.Exit(0);
                Process.Start(new ProcessStartInfo
                    FileName = Application.ExecutablePath,
                    Verb = "runas"
        }

Form1 FormBorderStyle 属性修改为 FixedSingle MaximizeBox 属性设置为 false StartPosition 属性设置为 CenterScreen Text 属性修改为 CETT。 并调整 Form1 大小到合适状态,并绑定 Form1.FormClosed 事件。

添加一个 CheckBox 控件 CheckBoxPassBy , 其 Enabled 属性设置为 false Text 属性修改为 Passby CE tutorial step 2, 绑定 CheckBoxPassBy.CheckedChanged 事件。

添加一个 Timer 控件 TimerListener , Enabled 属性设置为 true ,然后为其绑定 TimerListener.Tick 事件。


到此为止,前期的准备工作已经全部完成,最终UI效果如图2-4。

图2-4

B.修改实现思路

以CE 6.7附带的Tutorial-x86_64.exe为例,我们找到了通过教程Step 2的关键代码位于"Tutorial-x86_64.exe"+2B423,如图2-5:

图2-5

所以我们只需要把这个跳转代码NOP掉就可以了,也就是说把0x75 0x38改成0x90 0x90.

同时我们发现这个内存段的内存为只读+运行,如图2-6所示:

图2-6

另外,因为是针对多个教程版本的,所以我们不能直接使用"Tutorial-x86_64.exe"+2B423这个地址,而应当在主模块(Tutorial-x86_64.exe)中搜寻这个代码段所在的位置,CE称这种搜索为 AOBScan

所以综合来看,我们的修改器要实行以下步骤:

  1. 捕获游戏
  2. AOBScan代码段
  3. 修改内存保护属性
  4. 修改代码

要完成这四个步骤,我们需要使用以下四个Win32 API: ReadProcessMemory function WriteProcessMemory function VirtualProtectEx function VirtualQueryEx function


C、平台调用以及AOBScan的实现


   //只适配X64平台。
        [DllImport("kernel32.dll")]
        [return: MarshalAs(UnmanagedType.Bool)]
        private static extern Boolean ReadProcessMemory(HandleRef hProcess, Int64 lpBaseAddress, [In, Out] Byte[] lpBuffer, Int64 nSize, Int64 lpNumberOfBytesRead);
        [DllImport("kernel32.dll")]
        [return: MarshalAs(UnmanagedType.Bool)]
        private static extern Boolean WriteProcessMemory(HandleRef hProcess, Int64 lpBaseAddress, Byte[] lpBuffer, Int64 nSize, Int64 lpNumberOfBytesWritten);
        [DllImport("kernel32.dll")]
        [return: MarshalAs(UnmanagedType.Bool)]
        private static extern Boolean VirtualProtectEx(HandleRef hProcess, Int64 lpAddress, Int64 dwSize, UInt32 flNewProtect, out UInt32 lpflOldProtect);
        [DllImport("kernel32.dll")]
        private static extern Int64 VirtualQueryEx(HandleRef hProcess, Int64 lpAddress, out MEMORY_BASIC_INFORMATION64 lpBuffer, Int64 dwLength);
        //详见 https://msdn.microsoft.com/en-us/library/windows/desktop/aa366775(v=vs.85).aspx 备注
        [StructLayout(LayoutKind.Sequential, Pack = 16)]
        private struct MEMORY_BASIC_INFORMATION64
            public Int64 BaseAddress;
            public Int64 AllocationBase;
            public UInt32 AllocationProtect;
            public UInt32 __alignment1;
            public Int64 RegionSize;
            public UInt32 State;
            public UInt32 Protect;
            public UInt32 Type;
            public UInt32 __alignment2;
        private const Int64 MEMORY_BASIC_INFORMATION64_SIZE = 48;
        //https://msdn.microsoft.com/zh-cn/library/windows/desktop/aa366786(v=vs.85).aspx
        private const UInt32 PAGE_EXECUTE_READ = 0x20;
        private const UInt32 PAGE_EXECUTE_READWRITE = 0x40;
        // https://msdn.microsoft.com/en-us/library/windows/desktop/aa366775(v=vs.85).aspx
        private const UInt32 MEM_COMMIT = 0x1000;
        //全局变量,表示游戏进程
        private Process GameProcess = null;
        private Byte[] ReadBytes(Int64 address, Int64 length)
            var buffer = new Byte[length];
            ReadProcessMemory(new HandleRef(GameProcess, GameProcess.Handle), address, buffer, length, 0);
            return buffer;
        private void WriteBytes(Int64 address, Byte[] data)
            WriteProcessMemory(new HandleRef(GameProcess, GameProcess.Handle), address, data, data.LongLength, 0);
        private void WriteBytes(Int64 address, Byte[] data,Int32 length)
            WriteProcessMemory(new HandleRef(GameProcess, GameProcess.Handle), address, data, length, 0);
        private Int64 Aobscan(Byte[] data)
            var address = GameProcess.MainModule.BaseAddress.ToInt64();
            var stopAddress = address + GameProcess.MainModule.ModuleMemorySize;
            var handle = new HandleRef(GameProcess, GameProcess.Handle);
            while (address < stopAddress)
                //遍历内存页信息
                var infoLength = VirtualQueryEx(handle, address, out var memInfo, MEMORY_BASIC_INFORMATION64_SIZE);
                if (infoLength == 0)
                    return 0;
                //判断页面信息,如果State不是MEM_COMMIT,或Protect属性不是PAGE_EXECUTE_READ,则忽略
                //需要注意的是,这里我只比较了PAGE_EXECUTE_READ,实际上,如果写成通用的类,应该判断是否存在PAGE_GUARD位,这样才适用多种情况,具体见MSDN。
                if ((memInfo.State & MEM_COMMIT) != 0 && memInfo.Protect == PAGE_EXECUTE_READ)
                    //读取整个内存页
                    var buffer = ReadBytes(memInfo.BaseAddress, memInfo.RegionSize);
                    var index = QSIndexOf(buffer, data); //查找,
                    if (index!=-1)
                        //找到则修改Protect属性。
                        var currentAddress = memInfo.BaseAddress + index;
                        VirtualProtectEx(handle, currentAddress, 2, PAGE_EXECUTE_READWRITE, out _);
                        return currentAddress;
                address = memInfo.BaseAddress + memInfo.RegionSize;
            return 0;
        #region Sunday Quick-Search算法的C#实现
        private static Int32[] FlagBuffer = new Int32[256];
        private static Int32 QSIndexOf(Byte[] source, Byte[] pattern)
            if (source.Length < pattern.Length)
                return -1;
            var sLength = source.Length;
            var pLength = pattern.Length;
            var pMaxIndex = pLength - 1;
            var startIndex = 0;
            var endPos = sLength - pLength;
            var badMov = pLength + 1;
            for (Int32 i = 0; i < 256; i++)
                FlagBuffer[i] = badMov;
            for (int i = 0; i <=pMaxIndex; i++)
                FlagBuffer[pattern[i]] = pLength - i;
            Int32 pIndex, step, result = -1;
            while (startIndex <= endPos)
                for (pIndex = 0; pIndex <= pMaxIndex && source[startIndex + pIndex] == pattern[pIndex]; pIndex++)
                    if (pIndex == pMaxIndex)
                        result = startIndex;
                if (result > -1) break;
                step = startIndex + pLength;
                if (step >= sLength) break;
                startIndex += FlagBuffer[source[step]];
            return result;
        #endregion


代码其实很简单,我们对照着MSDN的API说明写就可以,比较复杂的反而是AOBSCAN用到的Sunday Quick-Search算法……具体见代码,加的注释应该足够了。

到了这里,剩下的工作就很简单了,无非就是业务流程的处理。

D.业务流程处理

一些全局变量:

       /* 
        Tutorial-x86_64.exe+2B423 - 75 38                 - jne Tutorial-x86_64.exe+2B45D
        Tutorial-x86_64.exe+2B425 - 48 8B 8B 60070000     - mov rcx,[rbx+00000760]
        private static readonly Byte[] AsmArray =  new Byte[] { 0x75, 0x38, 0x48, 0x8B, 0x8B, 0x60, 0x07, 0x00, 0x00 };
        private static readonly Byte[] NOPCode= new Byte[] { 0x90, 0x90 };        
        private Int64 AsmAddress = 0;

接着,在 TimerListener.Tick 事件中添加用于监听的代码,并绑定游戏进程的退出事件:

        //TimerListener.Tick事件
        private void TimerListener_Tick(object sender, EventArgs e)
            if (GameProcess == null)
                var processes = Process.GetProcessesByName("Tutorial-x86_64");
                if (processes.Length > 0)
                    Thread.Sleep(1000);//等待进程加载完毕,1S足够了
                    foreach (var process in processes)
                            //一旦捕获过,就不再尝试了,直接释放
                            if (GameProcess == null && process.MainWindowTitle == "Tutorial-x86_64")
                                GameProcess = process;
                                AsmAddress = Aobscan(AsmArray);
                                if (AsmAddress == 0) //查找失败,说明不是正确的进程或不支持修改
                                    GameProcess = null;
                                    process.Dispose();
                                    //绑定退出事件
                                    GameProcess.EnableRaisingEvents = true;
                                    GameProcess.Exited += GameProcess_Exited;
                                    //激活Checkbox
                                    CheckBoxPassBy.Enabled = true;
                                process.Dispose();
                        catch
                            process.Dispose();
                            GameProcess = null;
        private delegate void DelegateGameClose();
        private void GameClose()
            CheckBoxPassBy.Enabled = false;
            CheckBoxPassBy.Checked = false;
        private void GameProcess_Exited(object sender, EventArgs e)
            this.Invoke(new DelegateGameClose(GameClose));
            if (GameProcess != null)
                GameProcess.Exited -= GameProcess_Exited;
                GameProcess.Dispose();
                GameProcess = null;

最后,处理 CheckBoxPassBy.CheckedChanged 事件和 Form1.FormClosed 事件:

        private void CheckBoxPassBy_CheckedChanged(object sender, EventArgs e)
            //如果游戏已退出就不修改
            if (CheckBoxPassBy.Enabled == false) return;
            if (CheckBoxPassBy.Checked) 
                WriteBytes(AsmAddress, NOPCode);
                WriteBytes(AsmAddress, AsmArray, 2);
        private void Form1_FormClosed(object sender, FormClosedEventArgs e)