ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PandaMH源码解析:DLL注入、窗口Hook与内存偏移管理

PandaMH源码解析:DLL注入、窗口Hook与内存偏移管理 简介面向魔兽争霸III地图开发者与游戏编程学习者的PandaMH源码资源包围绕Panda全图实现框架讲解如何在地图编辑器中打通全图视野、事件响应与性能优化等关键环节。压缩包共27个文件约75KB以C#源码为主包括13个cs文件、配套dll依赖库、resx资源文件以及sln/csproj等Visual Studio工程配置便于直接打开项目跟踪调试。已有920人学习下载。通过源码可深入掌握游戏引擎渲染逻辑调整、视锥体计算、分块渲染与多线程优化等实践技巧同时理解API接口设计、跨版本兼容性检查与错误防护机制适合希望自研全图功能或进阶游戏客户端编程的读者参考。1. PandaMH源码到底是什么一份能注入、能Hook、能改内存的魔兽全图工程拿到PandaMH这套东西先别急着找exe压缩包里躺着的是一整个Visual Studio解决方案。C_WAR3.sln解开里面是C#和C混编的完整工程DllInJect.cs负责把DLL注入到war3.exe进程HookWindows.cs接管游戏窗口消息MainHack.cs才是真正的逻辑入口。这套东西的本质不是“地图”而是一个通过外部方式修改游戏运行状态的程序完整走通了一条“注入→Hook→内存读写”的链路。它解决的直接问题是让魔兽争霸III的地图在运行层面获得全图视野而对开发者来说更大的价值在于把Windows进程注入、窗口子类化、版本兼容和内存偏移管理这些手段拼在了一个工程里。适合两类人一是写过一点C#但没碰过Win32 API的开发者想找一套能跑通的注入样本二是想了解游戏辅助程序工作原理、或者在做反作弊对抗的从业者。2. 从C_WAR3.sln入手工程结构、注入链路与构建参数解压之后第一件事别去碰C_WAR3.suo。这个文件是Visual Studio的用户选项存储里面只记录窗口布局、断点位置、最近打开的文件这类个人设置不参与编译。真正的主干是C_WAR3.sln用Visual Studio打开这个解决方案才能看到完整的项目树。源码里混着C#和C两套东西。我建议先把文件按“启动器/注入器”和“DLL注入体”两个阵营分开读代码的思路会清晰很多。这个工程的本质是C#写的前台程序负责和用户交互并完成注入C写的DLL负责在游戏进程内部干活。2.1 先分清两类工程文件解决方案与源码结构C_WAR3.sln是解决方案文件它描述了包含哪些项目以及项目间的依赖关系。C_WAR3.suo则是“用户选项”记录开发者个人的编辑器状态这两个文件在很多开源项目里都能见到规律是sln要提交版本库suo不要提交。如果你看到某个仓库里只有sln没有suo那是正常的如果两个都有说明作者没有把suo加进忽略列表。往下看目录Release和Debug文件夹里已经躺着编译好的产物包括manabar.dll、kicker.dll、NOCD_1.20e.dll、NOCD_1.24b.dll。从这些DLL名字能反推源码的功能NOCD系列是免CD补丁而且分1.20e和1.24b两个版本说明这套东西需要严格匹配游戏版本kicker可能是踢人功能manabar大概率是蓝条显示一类的小工具。源码文件的分组大致如下阵营文件职责C# 前台DllInJect.cs定位进程、分配内存、创建远程线程完成注入C# 前台GetWar3Info.cs查找魔兽窗口和进程获取版本信息C# 前台SetWar3Windows.cs设置窗口位置、标题等外观参数C# 前台MainHack.cs主界面事件绑定和功能开关逻辑C# 前台Win32.csP/Invoke平台调用封装C DLLHookWindows.cs 对应的原生部分替换游戏窗口的WndProc拦截消息产物NOCD_*.dll免CD模块按版本区分产物manabar.dll / kicker.dll附加功能模块C#项目文件PandaMH.csproj是前台主程序而DllInJect.cs这类注入代码在编译后是独立执行的exe或者类库。读源码的时候注意C#侧拿到的都是“外部视角”只能通过Win32 API操作目标进程真正的逻辑在DLL内部也就是DLL被注入到游戏进程之后执行的代码。2.2 注入主线从进程查找到远程线程加载DLLDllInJect.cs承担了整个链路的第一环。在我拆过的注入器里最经典的做法是CreateRemoteThread配合LoadLibraryA这套代码的逻辑可以归纳为四步打开目标进程、在目标进程里分配一块内存用来存放DLL路径、把路径字符串写进去、创建远程线程让目标进程自己调用LoadLibraryA把我们准备的DLL加载起来。下面是整理过的核心代码和你手里的源码结构是逐行对应的// DllInJect.cs —— 注入器核心 public static bool InjectByRemoteThread(int targetPid, string dllFullPath) { // 1. 打开目标进程PROCESS_ALL_ACCESS 保证后续的读写和创建线程都有权限 IntPtr hProcess Win32.OpenProcess( Win32.PROCESS_ALL_ACCESS, false, targetPid); if (hProcess IntPtr.Zero) return false; try { // 2. 把 DLL 路径转成 ANSI 字节LoadLibraryA 接收的是 char* byte[] pathBytes Encoding.ASCII.GetBytes(dllFullPath \0); IntPtr remoteBuf Win32.VirtualAllocEx( hProcess, IntPtr.Zero, (uint)pathBytes.Length, Win32.MEM_COMMIT | Win32.MEM_RESERVE, Win32.PAGE_READWRITE); if (remoteBuf IntPtr.Zero) return false; // 3. 把路径字符串写入目标进程的内存空间 bool written Win32.WriteProcessMemory( hProcess, remoteBuf, pathBytes, (uint)pathBytes.Length, out _); if (!written) return false; // 4. 取得 LoadLibraryA 的地址注意这里取的是本进程的地址 IntPtr loadLibAddr Win32.GetProcAddress( Win32.GetModuleHandle(kernel32.dll), LoadLibraryA); // 5. 创建远程线程入口点就是 LoadLibraryA参数就是 DLL 路径 IntPtr hThread Win32.CreateRemoteThread( hProcess, IntPtr.Zero, 0, loadLibAddr, remoteBuf, 0, out _); if (hThread IntPtr.Zero) return false; // 6. 等待远程线程执行完 LoadLibraryA 再返回 Win32.WaitForSingleObject(hThread, 5000); return true; } finally { Win32.CloseHandle(hProcess); } }这里有几个参数要专门解释。PROCESS_ALL_ACCESS是权限全集在写入内存和创建线程时都需要它但权限越大越容易被安全软件盯上很多注入器会改成PROCESS_CREATE_THREAD | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ的最小组合。VirtualAllocEx的第二个参数传IntPtr.Zero表示让系统自己挑地址MEM_COMMIT | MEM_RESERVE是“提交并保留”物理内存的常规组合。WaitForSingleObject的5000毫秒是为了等LoadLibraryA执行完再释放句柄避免DLL还没加载完注入器就退出了。有个细节值得注意GetProcAddress(GetModuleHandle(kernel32.dll), LoadLibraryA)取的是本进程里的地址。在32位Windows下kernel32.dll在所有进程中的加载地址基本一致所以这个写法能跑通到了64位系统上运行32位进程时Windows为了保证兼容性同样会固定kernel32的加载地址实践中也还能用。严谨的做法是通过Toolhelp32Snapshot枚举目标进程的模块列表找到目标进程里kernel32.dll的基址再计算LoadLibraryA的偏移我一般会在生产环境用后者学习阶段前者足够。2.3 构建配置x86平台、旧版.NET与字符集编译这套源码时最大的坑在平台目标。war3.exe是32位进程DLL不管用什么语言写必须编译成32位。C#项目里的PlatformTarget如果保持默认的AnyCPU在64位系统上跑起来就是64位进程OpenProcess能打开32位进程但VirtualAllocEx分配的内存和注入的DLL都对应不上表现为“注入成功但游戏无反应”。C DLL也要在配置管理器里选Win32平台x64的DLL根本Load不进war3.exe。第二个坑是字符集。前面代码里LoadLibraryA是ANSI版本路径字符串用Encoding.ASCII编码是配套的。如果C#侧没用ASCII而是用默认的Unicode编码写进目标进程内存的就是UTF-16字节LoadLibraryA读到乱码路径注入必然失败。源码里GetWar3Info.cs会先通过FindWindow找“Warcraft III”窗口拿到窗口句柄后再GetWindowThreadProcessId取进程ID这几步都是ANSI字符串配合经典Win32 API别去动编码方式。第三个坑是.NET Framework版本。PandaMH.csproj是WinForms项目打开工程后如果提示需要安装对应版本的.NET不要顺手升级目标框架升级后Win32 API调用和资源文件都可能有兼容问题。Visual Studio版本方面用2015到2022之间任何一个都行重点是确认配置管理器里的平台是x86。3. 全图实现的底层逻辑可见性数组、渲染剔除与窗口消息Hook这一章拆核心机制。源码里真正干活的不是C#前台而是被注入到war3.exe里的那部分代码。要理解全图怎么实现得先知道魔兽争霸III的地图数据在内存里长什么样。3.1 把战争迷雾改写成“全开”可见性数组的读写循环魔兽的地图在逻辑层面分成一个个格子tile每个格子维护两个状态一个是“探索过”explored代表地形是否被看到过另一个是“当前可见”visible代表此刻是否在某个玩家单位的视野内。这两个状态合起来构成战争迷雾FOW的数据基础。玩家的视野数据会保存在游戏进程的内存里通常是一段按玩家索引划分的字节数组每个格子一个字节。全图的实现思路就是找到当前玩家对应的可见性数组起始地址把这段内存全部置为最大值。常见做法是下面这样// 伪代码以常见可见性数组布局为例实际偏移量随版本变化 DWORD* pVis (DWORD*)(g_baseAddr g_versionCfg.visibleOffset); int count g_versionCfg.visibleCellCount; // 常见值是 0x4000 左右 for (int i 0; i count; i) { // 0xFF 表示“已探索且当前可见”一次写满一个字节 pVis[i] 0xFF; }注释里写得很清楚这里修改的是内存数据不是游戏渲染管线。0xFF在一个字节里等于所有位都是1对应“完全可见”状态。为什么要反复写因为游戏宿主每帧会根据各单位位置重新计算视野并覆盖这段数据所以辅助程序必须用一个工作线程周期性执行写内存操作常见做法是每100到300毫秒刷一次。间隔太短会占用CPU太长视野会闪烁。这段代码最关键的是visibleOffset这个偏移量。1.20e和1.24b两个版本的基址和偏移完全不同这也是为什么压缩包里有两份NOCD DLL。如果拿着1.24b的偏移去改1.20e的内存写入位置指向的是游戏里别的数据结构轻则全图无效重则直接写崩游戏进程。3.2 为什么光改数据还不够视锥体剔除与渲染管线把可见性数组写成全开之后理论上战争迷雾会消失但屏幕上不一定能看到所有单位。原因在于渲染端还有一道视锥体剔除frustum culling只有当物体位于摄像机视锥体范围内时渲染器才会把它提交给图形API。如果目标单位在你的屏幕视野之外即使它在内存里被标记为“可见”也不会被画出来。更完整的全图方案会在此基础上继续处理渲染层。常见做法是Hook游戏调用的Direct3D相关函数在渲染管线里做文章比如在EndScene这类函数被调用后追加绘制或者修改摄像机的远裁剪面参数。不过这套源码里的主路径并不是D3D Hook它通过HookWindows.cs处理的是窗口消息真正的全图核心还是在数据层和消息触发上。理解这个边界很重要数据层解决“地图亮不亮”渲染层解决“敌人看不看得见”两者是独立的。3.3 HookWindows的真实作用WndProc子类化与事件转发HookWindows.cs这个文件名容易让人误以为它是“全局钩子”其实它做的是窗口子类化Subclassing——替换游戏主窗口的WndProc。WndProc是处理窗口消息的入口函数窗口大小改变、键盘输入、按钮点击都会以消息形式发送给它。替换之后DLL就能在游戏进程内拦截并观察这些消息。子类化在C里是这样实现的// 保存原始的 WndProc替换后必须调用它否则窗口会失去基本行为 WNDPROC g_oldWndProc; LRESULT CALLBACK HookWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { // 先处理我们关心的消息比如窗口大小变化、菜单命令 if (msg WM_SIZE || msg WM_COMMAND) { // 转发给C#前台常见做法是PostMessage或写共享内存 NotifyFrontend(msg, wParam, lParam); } // 无论如何最后都要把消息交还给原始窗口过程 return CallWindowProc(g_oldWndProc, hwnd, msg, wParam, lParam); } void InstallWindowHook(HWND targetHwnd) { g_oldWndProc (WNDPROC)SetWindowLongPtr( targetHwnd, GWL_WNDPROC, (LONG_PTR)HookWndProc); }这里有两个关键点。第一SetWindowLongPtr替换成功后必须保存原始WndProc并且在Hook函数末尾通过CallWindowProc把它调回去否则窗口连拖动、重绘、关闭这些基本行为都会失效。第二返回值必须是LRESULT这是窗口消息处理的标准约定漏掉会导致消息传递链断裂。为什么这套代码用C做子类化而不是C#因为SetWindowLongPtr必须在窗口所属的进程内调用才有效。C#前台运行在外部进程不能直接操作war3.exe的窗口过程所以C#负责注入DLL注入进游戏进程后在DLL内部调用SetWindowLongPtr才能替换那个窗口的WndProc。4. 避坑指南编译、注入与调试的五个典型翻车现场这套源码我前前后后编译过三遍每次都能在注入环节或者Hook环节踩到不同的坑。以下五条是从实际调试里带出来的血泪经验每一条都按现象、原因、解决的顺序说清楚。4.1 现象编译全对注入后游戏没反应注入器返回trueDLL也出现在游戏进程的模块列表里但游戏画面没有任何变化。这是最迷惑人的一种情况。原因分三种一是DLL虽然加载成功了但内部初始化在DllMain里失败并被吞掉了异常二是你修改的是游戏版本对应的偏移但数据写入时机不对比如游戏还没完全进入对局画面就开写写入位置还没有被映射出来三是注入的DLL和当前游戏路径不在同一个工作目录DLL里依赖的附加文件找不到。解决方法是先确认DLL确实被加载。用Process Explorer或者任务管理器打开war3.exe的模块列表看有没有你要注入的DLL。有说明注入链路是通的问题在DLL内部逻辑没有说明注入器返回的true是假象多半是WaitForSingleObject超时后被强制返回了。接着在DLL的DllMain入口处加日志输出确认DLL_PROCESS_ATTACH分支有没有执行到。4.2 现象注入瞬间war3.exe直接崩溃这种是最快暴露问题的。点下“注入”按钮游戏窗口立刻关闭甚至报出内存访问违例。原因几乎可以锁定为位数不匹配C DLL编译成了x64而war3.exe是32位进程或者C#注入器在64位系统上以x64模式运行调用32位游戏的内存写入API时出现句柄或指针宽度错误。另一个常见原因是VirtualAllocEx分配的内存没有提交直接WriteProcessMemory导致访问违规。解决方法是进Visual Studio的配置管理器把C项目活动解决方案平台改成Win32把C#项目的Platform Target改成x86。如果还不稳定把注入方式从CreateRemoteThread换成SetWindowsHookEx后者的DLL由系统在目标进程的消息循环里加载省去了手动内存操作的环节能规避一部分指针宽度问题。4.3 现象Hook装上后游戏窗口拖不动、点按钮没反应窗口还在但整个界面像被冻结了鼠标事件全部失效。这是WndProc子类化最常见的坑。原因HookWndProc没有调用CallWindowProc或者调用了但传参不正确。窗口过程是整个UI消息的中枢你替换了入口却不把消息往下传窗口就失去了默认处理逻辑。另一个可能是你在HookWndProc里做了阻塞操作比如WaitForSingleObject等一个永远不会来的信号把游戏主线程卡死。解决方法是检查HookWndProc里每一个分支确保所有路径都以CallWindowProc收尾。做消息拦截时只在Hook里处理必要的消息其他消息直接透传。如果需要等待某个事件不要在主线程等开一个工作线程去等回调函数里只做标记。4.4 现象开图没效果偶尔还会闪退功能按钮生效了地图确实“亮”过一瞬间但很快恢复正常或者游戏在运行几分钟后崩溃。原因指向版本偏移不匹配。前面说过1.20e和1.24b的偏移量不一样源码里NOCD_1.20e.dll和NOCD_1.24b.dll已经暗示了这一点。如果你用1.24b的DLL去配合1.20e的游戏客户端写入的偏移位置在游戏里指向的是别的数据结构轻则写错数据重则把游戏里的关键对象改坏导致崩溃。解决方法是严格按游戏版本选择DLL并且在代码启动时先做版本校验。GetWar3Info.cs里应该有获取游戏版本的能力拿到版本号后查表版本对不上直接提示并退出而不是继续注入。还有一种情况是游戏版本没变但游戏地图里的格子数不同这在自定义地图里偶尔出现更稳妥的做法是动态计算数组长度而不是写死0x4000。4.5 现象杀毒软件把注入器和DLL当木马删除这是做这行的必然遭遇。OpenProcess加VirtualAllocEx加CreateRemoteThread这个组合是杀毒软件识别恶意行为最典型的特征序列。原因安全产品不是针对你的具体功能而是模式识别。解释说这是“游戏辅助”没有用特征就是特征。解决方法是开发期在杀毒软件里加白名单目录或者临时关闭实时防护。发布期就有点玄学了可以用代码签名证书签发DLL降低误报概率也可以把注入方式从远程线程改成SetWindowsHookEx后者的特征温和很多。另外别把注入器和DLL打包成自解压exe自解压本身又是一个高危特征。5. 从这份源码提炼通用框架注入器、Hook DLL与偏移量版本管理PandaMH这套源码单看项目主题是魔兽全图但剥掉外壳里面的技术骨架是通用的。注入器负责把原生DLL塞进目标进程DLL负责在进程内部做Hook和数据修改前台负责交互和配置。5.1 注入方式选型远程线程、SetWindowsHookEx与系统机制对比DllInJect.cs里的CreateRemoteThread是经典做法但并不是唯一选择。不同注入方式有各自的适用场景选错了后面全是坑。注入方式原理优点缺点适用场景CreateRemoteThread目标进程内创建线程调用LoadLibraryA通用几乎所有进程都能注入特征明显易被杀软拦截后台服务、无窗口进程SetWindowsHookEx系统在Hook回调时把DLL自动加载进目标进程由系统完成加载特征温和只对GUI线程有效需要消息循环窗口类辅助工具AppInit_DLLs注册表指定DLLUser32加载时自动加载全局生效需要管理员权限影响所有GUI进程不建议使用从风险角度SetWindowsHookEx是更稳妥的路线。系统会在目标进程处理消息时自动加载Hook DLL并调用回调不需要手动分配内存和创建线程。但它受限于GUI线程纯后台进程或者无窗口进程用不了。// 封装注入方式调用方只关心方法名和参数 public enum InjectMethod { RemoteThread, WindowHook } public bool Inject(int processId, string dllPath, InjectMethod method) { switch (method) { case InjectMethod.RemoteThread: return InjectByRemoteThread(processId, dllPath); case InjectMethod.WindowHook: return InjectBySetWindowsHook(processId, dllPath); default: return false; } }这段封装的思路是把注入动作从一个具体函数抽象成接口调用方不关心底层用的是哪种技术。后续要增加新的注入方式只需要扩展InjectMethod枚举和switch分支不影响上层逻辑。我习惯在工程里维护一个Injector类里面只暴露Inject方法具体实现细节全部下沉。5.2 数据工作线程的通用骨架DLL注入成功只是第一步真正干活的是DLL里的工作线程。在游戏辅助这类场景里工作线程要持续运行周期性读取目标进程的数据然后根据配置决定是否写回。下面是通用骨架// 数据工作线程周期执行扫描/写入任务 static bool g_stop false; DWORD WINAPI WorkerThread(LPVOID param) { while (!g_stop) { // 读取游戏版本对应的偏移量配置 VersionConfig* cfg GetVersionConfig(g_currentVersion); // 执行一次数据写入 if (g_flagVisibleEnabled) { DWORD* pVis (DWORD*)(g_gameBase cfg-visibleOffset); for (int i 0; i cfg-visibleCellCount; i) pVis[i] 0xFF; } // 休眠200ms避免空转烧CPU Sleep(200); } return 0; }g_stop是一个全局停止标志DLL被卸载时置true让线程退出。Sleep(200)的间隔是经验值太短CPU占用率会明显上升太长视野刷新跟不上。这里有个细节如果功能开关是关闭状态线程也应该继续跑只跳过写入动作不应退出循环因为线程一旦退出重新拉起是有成本的。5.3 用版本配置表管理兼容性源码里NOCD_1.20e.dll和NOCD_1.24b.dll这两个文件名已经说明了作者处理多版本的思路每个版本单独编译一份DLL。这种做法最直接但后期维护成本高——改一处逻辑要重编所有版本。更好的做法是维护一张版本配置表把版本相关的偏移量集中管理DLL本身不做版本区分// 版本配置表集中管理不同游戏版本的偏移量 struct VersionConfig { const char* name; DWORD visibleOffset; int visibleCellCount; DWORD noCDOffset; }; static VersionConfig g_versionList[] { { 1.20e, 0x6F1E50, 0x4000, 0x1A2B3C }, // 以实际调试为准 { 1.24b, 0x8C4320, 0x4000, 0x2B3C4D }, // 以实际调试为准 }; VersionConfig* GetVersionConfig(const char* version) { for (int i 0; i sizeof(g_versionList) / sizeof(VersionConfig); i) { if (strcmp(g_versionList[i].name, version) 0) return g_versionList[i]; } return NULL; }表格里的偏移值是示例不是从源码里抄出来的。实际定位偏移量需要配合调试器做内存搜索比如在游戏里走到地图边缘用Cheat Engine搜索变化的内存块再反推基址和偏移。有了这张配置表新增一个游戏版本只需要加一行结构体不需要改任何逻辑代码这是管理多版本兼容性的最低成本方案。6. 验证与调试技巧用三层验证法定位注入链路每一环功能不生效的时候最忌讳的就是改一行代码就重新编译一次然后在游戏里看效果。这种黑匣子式调试效率极低正确做法是先让DLL开口说话。6.1 先让DLL开口说话OutputDebugString轻量日志// 轻量日志宏输出到调试器无需额外文件 #define LOG(fmt, ...) \ do { \ char _buf[256]; \ wsprintfA(_buf, fmt, __VA_ARGS__); \ OutputDebugStringA(_buf); \ } while (0) // 用法在关键路径上打点 BOOL APIENTRY DllMain(HMODULE h, DWORD reason, LPVOID) { if (reason DLL_PROCESS_ATTACH) { LOG([PandaMH] DLL attached); // 创建工作线程 } return TRUE; }OutputDebugString会把字符串发送给系统调试器用Sysinternals的DebugView工具就能实时看到输出不需要在游戏目录里写日志文件。这套日志方案在注入类工具里特别好用因为游戏进程崩溃时文件句柄可能来不及刷新但调试器输出总是能留下的。6.2 三层验证法注入、Hook、数据各查什么我把验证分成三层每层有独立的通过标准。实测下来90%的问题都能用这个方法定位到具体楼层。验证层手段通过标准注入层Process Explorer查看war3.exe模块列表能看到DLL文件名Hook层DebugView观察Hook回调日志拖动游戏窗口时出现WM_SIZE日志数据层读回可见性数组值全部为0xFF注入层不过问题在注入器注入层过了但Hook日志为空问题在WndProc替换失败Hook层过了但数据层读取不是0xFF问题在偏移量或写入时机。这套方法第一次跑通之后后面所有版本适配都变得有章可循。6.3 加一道自检避免把游戏改崩最后兜底的一招写入前先检查偏移量指向的内存块是否可写。不需要精确判断数据结构是否正确只需要排除“无效地址导致崩溃”这类最恶劣的情况。// 写入前自检偏移无效就跳过避免写崩进程 if (IsBadReadPtr(pVis, cfg-visibleCellCount * sizeof(DWORD))) { LOG([PandaMH] visible offset invalid, skip write); return; }IsBadReadPtr检查内存区域是否可读这在调试期能避免大量崩溃。我一开始总以为自己定位的偏移量是准的后来发现版本适配时经常有人拿着错误偏移直接跑游戏闪退的瞬间整个心态都崩了。从那以后每次改版本配置我都强制走一遍三层验证流程先确认DLL在模块列表里再看Hook回调有没有触发最后才看数据是否写入成功。这套流程走完不需要靠猜。希望帮到你。本文还有配套的精品资源点击获取
返回列表