YimMenuV2 如何在 GTA 5: Enhanced 中实现实时菜单:特征码定位、D3D12 渲染与 Lua 脚本
【免费下载链接】YimMenuV2Experimental menu for GTA 5: Enhanced项目地址: https://gitcode.com/GitHub_Trending/yi/YimMenuV2
给 GTA 5: Enhanced 写菜单类工具,最先撞上的墙是版本更新:游戏一换版本,内存布局就跟着变,任何写死的偏移量在下一次启动时都会指向错误的位置,菜单当场失效。YimMenuV2 的应对方式是不记地址、只记指令特征,配合一套钩子、渲染和脚本层,把"定位游戏内部"这件事做成可维护的结构。
方案地图:五层各管一件事
整套方案拆成五个相对独立的层,从内存到界面到扩展,各司其职:
| 层 | 职责 | 位置 |
|---|---|---|
| 特征码扫描 | 用指令特征定位指针与函数地址 | src/core/memory/PatternScanner.cpp |
| 钩子层 | 基于 MinHook 拦截游戏函数 | src/core/hooking/DetourHook.hpp |
| 渲染层 | 挂钩 D3D12 Present,绘制 ImGui | src/core/renderer/D3D12Hook.cpp |
| 脚本协程层 | 用 Fiber 在脚本线程里跑菜单逻辑 | src/core/backend/ScriptMgr.cpp |
| Lua 扩展层 | 沙箱加载用户脚本 | src/core/scripting/LuaManager.cpp |
启动顺序在 src/main.cpp 里写得很直白:ModuleMgr加载模块 →Pointers::Init()扫特征 →AnticheatBypass::RunOnStartup()→Hooking::Init()→ScriptMgr::Init()→D3D12Hook::Init()→Renderer::Init()→GUI::Init()→FiberPool::Init(16),最后注册一组脚本。任何一步失败就走EARLY_UNLOAD退出。下面按层拆开。
特征码扫描:用指令序列而不是地址找东西
问题在于游戏更新后地址会变,硬编码偏移必然腐烂。做法是把每条特征写成十六进制字节序列,用?做通配,在目标模块的字节流里滑动匹配。src/core/memory/Pattern.hpp 里的Pattern模板在编译期就把特征字符串解析成字节数组,同时算出特征哈希:
constexpr auto scriptThreadsPtrn = Pattern<"48 8B 05 ? ? ? ? 48 89 34 F8 48 FF C7 48 39 FB 75 97">("ScriptThreads"); scanner.Add(scriptThreadsPtrn, this { ScriptThreads = ptr.Add(3).Rip().As<rage::atArray<rage::scrThread*>*>(); });src/game/pointers/Pointers.cpp 的Pointers::Init()按这条模式把几十条特征一一登记,匹配成功后回调里用PointerCalculator加偏移、取 RIP 相对地址,得到指针或函数地址,统一存进全局的Pointers结构。
PatternScanner::Scan()里有个容易漏掉的细节:非手动映射注入时,每条特征的匹配任务会丢进std::async并行跑,一条扫描能省出几百毫秒;命中的偏移会以"特征哈希 + 模块大小"为键写进PatternCache,二次注入时直接走缓存,不再全文扫描。另外整条链路是"一票否决":任何一条特征没命中,Init()整体失败并直接卸载,而不是带着残缺的指针表继续跑。
函数拦截:两跳定位与延迟启用
地址找到之后,要真正插进游戏的执行流,靠的是拦截。src/core/hooking/DetourHook.hpp 的DetourHook是一个基于 MinHook 的模板类,构造时接收目标地址、菜单自己的回调,内部调MH_CreateHook并保留原函数指针,供拦截之后透传回原逻辑:
DetourHook(const std::string_view name, void* target, T detour) : BaseHook(name), m_TargetFunc(target), m_DetourFunc(reinterpret_cast<void*>(detour)), m_OriginalFunc(nullptr) { OptimizeHook(); if (const auto result = MH_CreateHook(m_TargetFunc, m_DetourFunc, &m_OriginalFunc); result != MH_OK) { LOGF(FATAL, "Failed to create hook {}", name); throw std::runtime_error("Failed to create hook!"); } }所有钩子在 src/core/hooking/Hooking.cpp 的构造函数里登记,按功能分成 Anticheat、Script、Network、Matchmaking、Spoofing 等一组静态成员。两个容易漏掉的点:一是OptimizeHook()会沿着0xE9(jmp)跳板一路跳到真正的函数体再挂钩,否则挂到的只是跳板指令,行为会不对;二是Enable()/Disable()只是把钩子排进 MinHook 的队列,Hooking::InitImpl()最后统一m_MinHook.ApplyQueued()一次性生效,避免逐个启用时对游戏线程制造中间状态。
D3D12 渲染:先造假设备,再钓真交换链
ImGui 要画在游戏窗口上,前提是先拿到游戏正在用的 D3D12 设备、交换链和命令队列,而注入时机往往赶在游戏创建这些对象之前。src/core/renderer/D3D12Hook.cpp 的InitImpl()先自建一套临时对象探路:
auto d3d12Module = LoadLibraryA("d3d12.dll"); auto d3d12CreateDevice = reinterpret_cast<decltype(D3D12CreateDevice)*>( GetProcAddress(d3d12Module, "D3D12CreateDevice")); // 创建 dummy 设备、factory、command queue、swap chain // 然后在自己交换链的内存里搜 commandQueue 指针 for (uint32_t i = 0; i < 512 * sizeof(void*); i += sizeof(void*)) if (*reinterpret_cast<ID3D12CommandQueue**>(base) == commandQueue) m_CommandQueueOffset = i;这段代码决定了一件事:只要从临时交换链上量出"命令队列成员离对象头的偏移",之后拿到游戏的真实交换链就能靠同一偏移直接读出它的命令队列,不需要任何额外钩子。
Present 钩装分两阶段。第一阶段直接在交换链虚表第 8 槽位上打补丁(RawVtablePatch),捕获到真实交换链后卸载临时补丁,改用VMTHook按实例克隆一份虚表来钩Present和ResizeBuffers——代码注释里写明这是故意的:克隆虚表保证钩一个对象不影响别的对象。装上之后注册回调链:Renderer在OnPresent里创建描述堆和渲染目标视图,之后每帧走NewFrame→ 业务绘制 →RenderDrawData;OnResizeBuffers释放过期的 backbuffer;OnDeviceReset整体销毁重建。
脚本协程:让菜单逻辑跑在游戏的线程上
游戏原语函数要求在 RAGE 脚本线程的上下文里调用,普通工作线程直接调会破坏同步状态。YimMenuV2 的解法是把每块需要持续执行的逻辑封装成一个Script对象,内部用 Windows Fiber 承载回调,src/core/backend/ScriptMgr.cpp 负责调度:
void ScriptMgr::TickImpl() { std::lock_guard lock(m_Mutex); static bool ensure_main_fiber = (ConvertThreadToFiber(nullptr), true); for (const auto& script : m_Scripts) script->Tick(); }Script::Tick()只做一件事:在到期的情况下SwitchToFiber切进自己的子纤程跑回调;回调里调ScriptMgr::Yield()就切回主纤程。main.cpp里一次性注册了十来个脚本(NativeHooks、Tunables、Self、GUI、LuaManager、HotkeySystem、Commands等),另加FiberPool::Init(16)开出 16 个空闲纤程,专门用来异步执行FiberPool::Push()丢进来的回调。
这里最容易看走眼的是调度权归属:Tick()只在游戏脚本线程的帧循环里被调一次,意味着菜单的全部"帧逻辑"都运行在那一条线程上,游戏原语调用才安全。YieldImpl()会区分当前处于 Lua 协程还是普通脚本纤程,分别转发到LuaScript::Yield或Script::Yield。
Lua 脚本层:不改 C++ 也能加功能
不想重新编译加功能时,Lua 层就是入口。脚本放在%appdata%/YimMenuV2/scripts,src/core/scripting/LuaManager.cpp 在启动时递归加载.lua文件,每个脚本跑在独立的 LuaJIT 状态里,API 面在 docs/lua-api.md 里列全了:notify、menu、memory、natives、entities这些全局表都可以用,但io、os、ffi以及load、loadfile这类直接加载函数全部不可用,package.cpath保持为空,防止脚本越出沙箱。
一个值得学的磁盘约定:禁用一个脚本时,文件会被移动到scripts/disabled/下并保留相对路径,重新启用时再移回去。脚本的"开关状态"直接反映在目录结构上,重启菜单后状态不丢,也不需要在设置文件里再存一份。
设置持久化:一个 JSON 管所有状态
最后一条线是配置。FileMgr以%appdata%/YimMenuV2为根管理文件和目录,Settings::Initialize(FileMgr::GetProjectFile("./settings.json"))让所有实现IStateSerializer的组件(命令、热键、子菜单状态)把状态序列化进同一个 JSON。main.cpp的主循环里定期Settings::Tick()做防抖落盘,避免每帧写盘。日志则走LogHelper,输出到appdata/YimMenuV2/cout.log。
最小验证路径:注入后看三件事
- 构建:
cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build --config Release。在 Windows 上直接构建;在 Linux/macOS 上 cmake/cross-compile.cmake 默认开启交叉编译,会自动找 Zig 或 Clang(MinGW) 工具链并静态链接。 - 注入:用注入器(如 Xenos)在 GTA 5: Enhanced 主菜单阶段注入构建出的 DLL,然后看
cout.log:应当出现Welcome to YimMenuV2!、ScriptMgr initialized这类行,以及每条特征码的Found pattern [...]记录。 - 打开界面:游戏内按
INSERT或Ctrl+\,ImGui 菜单应该盖在画面上,右上角弹出 "Loaded succesfully" 通知。 - 加一个 Lua 脚本:往
scripts目录丢一个.lua文件,刷新菜单,它应出现在 Settings > Lua Scripts 的 Disabled 列表里,点击即加载运行。
避坑记录
- 现象:钩子全装上、日志无报错,但界面始终不出现。原因:Present 钩子没捕获到真实交换链,日志里没有
D3D12 Hook initialized.这一行。改法:优先排查交换链创建路径和CreateSwapchain补丁是否生效,不要急着去改 ImGui 初始化代码。 - 现象:游戏更新后注入直接退出。原因:某条特征码没命中,日志里有
Failed to find pattern [...]和Some patterns could not be found, unloading.。改法:逐条更新特征码,不要靠放宽通配符硬凑——通配符一多,命中位置就不可信了。 - 现象:日志出现
Some socialclub patterns could not be found但菜单照常工作。原因:socialclub.dll的特征由Pointers::LateInit()单独扫,失败只降级 SocialClub 相关功能,不是致命错误。改法:需要对应功能时再补特征码,否则可以忽略这条告警。
五层结构里,特征码定位、MinHook 拦截、D3D12 两阶段钩子、Fiber 脚本模型、Lua 沙箱各自独立,改任何一层时只动该层的代码,不要跨层打补丁。这个"每层可单独失败、单独修复"的划分,是这套代码最值得借鉴的地方。 🎮
【免费下载链接】YimMenuV2Experimental menu for GTA 5: Enhanced项目地址: https://gitcode.com/GitHub_Trending/yi/YimMenuV2
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考